🔀 Commit

fixed issues with sticker sharing, added info about blog post being public
Commit0d9763c5f1e606693f4a5cfc295a59d15b699abd
AuthorJabJab <noreply@xmpp.tel>
Date2026-07-06
Parent11eee094
commit 0d9763c5f1e606693f4a5cfc295a59d15b699abd
Author: JabJab <noreply@xmpp.tel>
Date:   Mon Jul 6 15:30:11 2026 +0300

    fixed issues with sticker sharing, added info about blog post being public
---
 build.gradle                                       |   2 +-
 src/main/AndroidManifest.xml                       |  32 +--
 .../jabjab/services/XmppConnectionService.java     | 155 ++++++------
 src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java |   6 +-
 .../tel/xmpp/jabjab/ui/BlogComposerActivity.java   |  40 +++-
 .../tel/xmpp/jabjab/ui/JabJabDeepLinkActivity.java |  30 +--
 .../xmpp/jabjab/ui/PeerStickerPackActivity.java    | 260 +++++++++++++++++++++
 .../tel/xmpp/jabjab/ui/StickerPickerActivity.java  |  22 ++
 .../java/tel/xmpp/jabjab/ui/StoryViewActivity.java |   4 +-
 .../tel/xmpp/jabjab/ui/WebStickerPackActivity.java |   7 +-
 .../java/tel/xmpp/jabjab/ui/util/StickerStore.java |  82 +++++--
 .../xmpp/jabjab/ui/util/TranslationService.java    |   2 +-
 .../tel/xmpp/jabjab/ui/util/UpdateChecker.java     |  26 +--
 src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java |   6 +-
 src/main/res/layout/activity_peer_sticker_pack.xml | 140 +++++++++++
 src/main/res/menu/sticker_picker.xml               |   3 +
 src/main/res/values-fa-rIR/strings.xml             |   1 +
 src/main/res/values-id/strings.xml                 |   1 +
 src/main/res/values-iw/strings.xml                 |   1 +
 src/main/res/values-ko/strings.xml                 |   1 +
 src/main/res/values-uk/strings.xml                 |   1 +
 src/main/res/values/strings.xml                    |   1 +
 22 files changed, 658 insertions(+), 165 deletions(-)

diff --git a/build.gradle b/build.gradle
index d85cbb8..cb97f93 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,7 +113,7 @@ android {
 
     defaultConfig {
         minSdkVersion 23
-        versionCode 42290
+        versionCode 42291
         versionName "1.0.1"
         applicationId "tel.xmpp.jabjab"
         resValue "string", "applicationId", applicationId
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 8301c29..7d37c11 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -374,25 +374,9 @@
         </activity>
         <activity
             android:name=".ui.MultiShareActivity"
-            android:exported="true"
+            android:exported="false"
             android:label="@string/multi_share_title"
-            android:launchMode="singleTop">
-
-            <intent-filter>
-                <action android:name="android.intent.action.SEND" />
-                <action android:name="android.intent.action.SEND_MULTIPLE" />
-                <category android:name="android.intent.category.DEFAULT" />
-                <data android:mimeType="text/plain" />
-            </intent-filter>
-
-            <intent-filter>
-                <action android:name="android.intent.action.SEND" />
-                <action android:name="android.intent.action.SEND_MULTIPLE" />
-                <category android:name="android.intent.category.DEFAULT" />
-                <data android:mimeType="*/*" />
-            </intent-filter>
-
-        </activity>
+            android:launchMode="singleTop" />
         <activity
             android:name=".ui.TrustKeysActivity"
             android:label="@string/trust_omemo_fingerprints" />
@@ -470,6 +454,11 @@
             </intent-filter>
         </activity>
 
+        <activity
+            android:name=".ui.PeerStickerPackActivity"
+            android:exported="false"
+            android:theme="@style/Theme.Conversations3" />
+
         <activity
             android:name=".ui.WebStickerPackActivity"
             android:exported="true">
@@ -480,13 +469,6 @@
                 <data android:scheme="https"
                       android:host="stickers.xmpp.tel" />
             </intent-filter>
-            <intent-filter>
-                <action android:name="android.intent.action.VIEW" />
-                <category android:name="android.intent.category.DEFAULT" />
-                <category android:name="android.intent.category.BROWSABLE" />
-                <data android:scheme="jabjab"
-                      android:host="sticker" />
-            </intent-filter>
         </activity>
 
         <provider
diff --git a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
index 5f59481..5b26519 100644
--- a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
+++ b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
@@ -4115,76 +4115,35 @@ public class XmppConnectionService extends Service {
         });
     }
 
+    public interface StickerPackEntriesCallback {
+        void onEntries(String packName, List<StickerStore.StickerEntry> entries);
+    }
+
+    /** Fetches a PubSub sticker pack item and parses it, without installing. */
+    public void fetchStickerPackEntries(
+            final Account account, final tel.xmpp.jabjab.xmpp.Jid from,
+            final String packNode,
+            final StickerPackEntriesCallback onSuccess, final Runnable onFailure) {
+        Log.d(Config.LOGTAG, "STICKER fetchStickerPackEntries from=" + from + " node=" + packNode);
+        final Iq fetchIq = mIqGenerator.retrieveStickerPack(from, packNode);
+        sendIqPacket(account, fetchIq, result -> {
+            final String[] nameHolder = {null};
+            final List<StickerStore.StickerEntry> entries = parseStickerPackEntries(result, packNode, nameHolder);
+            if (entries == null) { if (onFailure != null) onFailure.run(); return; }
+            if (onSuccess != null) onSuccess.onEntries(nameHolder[0], entries);
+        });
+    }
+
     public void fetchAndInstallStickerPack(
             final Account account, final tel.xmpp.jabjab.xmpp.Jid from,
             final String packNode, final Runnable onSuccess, final Runnable onFailure) {
-        Log.d(Config.LOGTAG, "XmppConnectionService: fetchAndInstallStickerPack from=" + from + " node=" + packNode);
+        Log.d(Config.LOGTAG, "STICKER fetchAndInstallStickerPack from=" + from + " node=" + packNode);
         final Iq fetchIq = mIqGenerator.retrieveStickerPack(from, packNode);
         sendIqPacket(account, fetchIq, result -> {
-            if (result == null || result.getType() == Iq.Type.ERROR
-                    || result.getType() == Iq.Type.TIMEOUT) {
-                Log.w(Config.LOGTAG, "XmppConnectionService: fetchAndInstallStickerPack IQ failed node=" + packNode + " type=" + (result != null ? result.getType() : "null"));
-                if (onFailure != null) onFailure.run();
-                return;
-            }
-            Log.d(Config.LOGTAG, "XmppConnectionService: fetchAndInstallStickerPack got IQ result, parsing pack XML");
-            final tel.xmpp.jabjab.xml.Element pubsub =
-                    result.findChild("pubsub", tel.xmpp.jabjab.xml.Namespace.PUB_SUB);
-            if (pubsub == null) { Log.w(Config.LOGTAG, "XmppConnectionService: no <pubsub> in result"); if (onFailure != null) onFailure.run(); return; }
-            final tel.xmpp.jabjab.xml.Element items = pubsub.findChild("items");
-            if (items == null) { Log.w(Config.LOGTAG, "XmppConnectionService: no <items> in pubsub"); if (onFailure != null) onFailure.run(); return; }
-            final tel.xmpp.jabjab.xml.Element item = items.findChild("item");
-            if (item == null) { Log.w(Config.LOGTAG, "XmppConnectionService: no <item> in items"); if (onFailure != null) onFailure.run(); return; }
-            final tel.xmpp.jabjab.xml.Element packEl =
-                    item.findChild("pack", tel.xmpp.jabjab.xml.Namespace.STICKERS);
-            if (packEl == null) { Log.w(Config.LOGTAG, "XmppConnectionService: no <pack> element in item"); if (onFailure != null) onFailure.run(); return; }
-            final tel.xmpp.jabjab.xml.Element nameEl = packEl.findChild("name");
-            final String packName = nameEl != null && nameEl.getContent() != null
-                    ? nameEl.getContent() : "Sticker Pack";
-            Log.d(Config.LOGTAG, "XmppConnectionService: parsing sticker pack name=" + packName);
-            final List<StickerStore.StickerEntry> entries = new ArrayList<>();
-            for (final tel.xmpp.jabjab.xml.Element stickerEl : packEl.getChildren()) {
-                if (!"sticker".equals(stickerEl.getName())) continue;
-                // Find URL: JabJab puts <url-data> directly under <sticker>;
-                // Moxxy/spec wraps it in <sources> which may arrive without xmlns.
-                String url = null;
-                final tel.xmpp.jabjab.xml.Element directUrl = stickerEl.findChild("url-data");
-                if (directUrl != null) {
-                    url = directUrl.getAttribute("target");
-                } else {
-                    for (final tel.xmpp.jabjab.xml.Element child : stickerEl.getChildren()) {
-                        if ("sources".equals(child.getName())) {
-                            final tel.xmpp.jabjab.xml.Element ud = child.findChild("url-data");
-                            if (ud != null) { url = ud.getAttribute("target"); break; }
-                        }
-                    }
-                }
-                if (url == null || url.isEmpty()) continue;
-                // Find emoji and SHA-256 hash from <file> (XEP-0446) or bare <emoji>.
-                // Check by element name only — xmlns may be absent on children inside <pack>.
-                String emoji = null;
-                String sha256 = null;
-                final tel.xmpp.jabjab.xml.Element emojiEl = stickerEl.findChild("emoji");
-                if (emojiEl != null) {
-                    emoji = emojiEl.getContent();
-                } else {
-                    for (final tel.xmpp.jabjab.xml.Element child : stickerEl.getChildren()) {
-                        if ("file".equals(child.getName())) {
-                            final tel.xmpp.jabjab.xml.Element desc = child.findChild("desc");
-                            if (desc != null) emoji = desc.getContent();
-                            for (final tel.xmpp.jabjab.xml.Element h : child.getChildren()) {
-                                if ("hash".equals(h.getName())
-                                        && "sha-256".equals(h.getAttribute("algo"))) {
-                                    sha256 = h.getContent();
-                                    break;
-                                }
-                            }
-                            break;
-                        }
-                    }
-                }
-                entries.add(new StickerStore.StickerEntry(url, emoji, sha256));
-            }
+            final String[] nameHolder = {null};
+            final List<StickerStore.StickerEntry> entries = parseStickerPackEntries(result, packNode, nameHolder);
+            if (entries == null) { if (onFailure != null) onFailure.run(); return; }
+            final String packName = nameHolder[0];
             FILE_ATTACHMENT_EXECUTOR.execute(() -> {
                 try {
                     StickerStore.installFromPep(
@@ -4198,6 +4157,72 @@ public class XmppConnectionService extends Service {
         });
     }
 
+    // Returns parsed entries, or null on failure. nameHolder[0] is set to the pack name.
+    private List<StickerStore.StickerEntry> parseStickerPackEntries(
+            final Iq result, final String packNode, final String[] nameHolder) {
+        if (result == null || result.getType() == Iq.Type.ERROR
+                || result.getType() == Iq.Type.TIMEOUT) {
+            Log.w(Config.LOGTAG, "STICKER parseStickerPackEntries: IQ failed node=" + packNode
+                    + " type=" + (result != null ? result.getType() : "null"));
+            return null;
+        }
+        final tel.xmpp.jabjab.xml.Element pubsub =
+                result.findChild("pubsub", tel.xmpp.jabjab.xml.Namespace.PUB_SUB);
+        if (pubsub == null) { Log.w(Config.LOGTAG, "STICKER no <pubsub> in result"); return null; }
+        final tel.xmpp.jabjab.xml.Element items = pubsub.findChild("items");
+        if (items == null) { Log.w(Config.LOGTAG, "STICKER no <items> in pubsub"); return null; }
+        final tel.xmpp.jabjab.xml.Element item = items.findChild("item");
+        if (item == null) { Log.w(Config.LOGTAG, "STICKER no <item> in items"); return null; }
+        final tel.xmpp.jabjab.xml.Element packEl =
+                item.findChild("pack", tel.xmpp.jabjab.xml.Namespace.STICKERS);
+        if (packEl == null) { Log.w(Config.LOGTAG, "STICKER no <pack> in item"); return null; }
+        final tel.xmpp.jabjab.xml.Element nameEl = packEl.findChild("name");
+        nameHolder[0] = (nameEl != null && nameEl.getContent() != null)
+                ? nameEl.getContent() : "Sticker Pack";
+        Log.d(Config.LOGTAG, "STICKER parseStickerPackEntries name=" + nameHolder[0]
+                + " children=" + packEl.getChildren().size());
+        final List<StickerStore.StickerEntry> entries = new ArrayList<>();
+        for (final tel.xmpp.jabjab.xml.Element stickerEl : packEl.getChildren()) {
+            if (!"sticker".equals(stickerEl.getName())) continue;
+            String url = null;
+            final tel.xmpp.jabjab.xml.Element directUrl = stickerEl.findChild("url-data");
+            if (directUrl != null) {
+                url = directUrl.getAttribute("target");
+            } else {
+                for (final tel.xmpp.jabjab.xml.Element child : stickerEl.getChildren()) {
+                    if ("sources".equals(child.getName())) {
+                        final tel.xmpp.jabjab.xml.Element ud = child.findChild("url-data");
+                        if (ud != null) { url = ud.getAttribute("target"); break; }
+                    }
+                }
+            }
+            if (url == null || url.isEmpty()) continue;
+            String emoji = null;
+            String sha256 = null;
+            final tel.xmpp.jabjab.xml.Element emojiEl = stickerEl.findChild("emoji");
+            if (emojiEl != null) {
+                emoji = emojiEl.getContent();
+            } else {
+                for (final tel.xmpp.jabjab.xml.Element child : stickerEl.getChildren()) {
+                    if ("file".equals(child.getName())) {
+                        final tel.xmpp.jabjab.xml.Element desc = child.findChild("desc");
+                        if (desc != null) emoji = desc.getContent();
+                        for (final tel.xmpp.jabjab.xml.Element h : child.getChildren()) {
+                            if ("hash".equals(h.getName())
+                                    && "sha-256".equals(h.getAttribute("algo"))) {
+                                sha256 = h.getContent();
+                                break;
+                            }
+                        }
+                        break;
+                    }
+                }
+            }
+            entries.add(new StickerStore.StickerEntry(url, emoji, sha256));
+        }
+        return entries;
+    }
+
     public QuickConversationsService getQuickConversationsService() {
         return this.mQuickConversationsService;
     }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
index 645ea00..72b12b4 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
@@ -287,7 +287,7 @@ public class BlogActivity extends XmppActivity
                     }
                     @Override
                     public void onFailure(final Throwable t) {
-                        Log.d(Config.LOGTAG, "Retry edit failed for " + editId, t);
+                        Log.d(Config.LOGTAG, "BLOG retry edit failed for " + editId, t);
                         draftStore.save(draft);
                         runOnUiThread(() -> { if (!isDestroyed()) updateDraftBanner(); });
                     }
@@ -315,7 +315,7 @@ public class BlogActivity extends XmppActivity
                         }
                         @Override
                         public void onFailure(final Throwable t) {
-                            Log.d(Config.LOGTAG, "Retry publish failed for " + draftId, t);
+                            Log.d(Config.LOGTAG, "BLOG retry publish failed for " + draftId, t);
                             // Re-save so user can retry manually
                             draftStore.save(draft);
                             runOnUiThread(() -> { if (!isDestroyed()) updateDraftBanner(); });
@@ -643,7 +643,7 @@ public class BlogActivity extends XmppActivity
                 return uploaded;
             }
         } catch (final Exception e) {
-            Log.d(Config.LOGTAG, "Pending image resolve error", e);
+            Log.d(Config.LOGTAG, "BLOG pending image resolve error", e);
         }
         return null; // publish without image rather than with a local path
     }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogComposerActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogComposerActivity.java
index 856d385..d8c9913 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogComposerActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogComposerActivity.java
@@ -101,6 +101,8 @@ public class BlogComposerActivity extends XmppActivity
         charCounter = findViewById(R.id.char_counter);
         publishButton = findViewById(R.id.publish_post);
 
+        showPublicWarningIfNeeded();
+
         titleField.setFilters(new android.text.InputFilter[]{
                 new android.text.InputFilter.LengthFilter(MAX_TITLE_LENGTH)});
         bodyField.setFilters(new android.text.InputFilter[]{
@@ -282,7 +284,7 @@ public class BlogComposerActivity extends XmppActivity
                     });
                 }
             } catch (final Exception e) {
-                Log.d(Config.LOGTAG, "Header image upload error", e);
+                Log.d(Config.LOGTAG, "BLOG header image upload error", e);
             }
         }).start();
     }
@@ -309,10 +311,10 @@ public class BlogComposerActivity extends XmppActivity
                     final String url = new JSONObject(response.body().string()).optString("url", null);
                     if (url != null) return url;
                 }
-                Log.d(Config.LOGTAG, "Header image upload failed: " + response.code());
+                Log.d(Config.LOGTAG, "BLOG header image upload failed: " + response.code());
             }
         } catch (final Exception e) {
-            Log.d(Config.LOGTAG, "Header image upload error", e);
+            Log.d(Config.LOGTAG, "BLOG header image upload error", e);
         }
         return null;
     }
@@ -343,11 +345,11 @@ public class BlogComposerActivity extends XmppActivity
                         .build();
                 try (final var response = client.newCall(request).execute()) {
                     if (!response.isSuccessful()) {
-                        Log.d(Config.LOGTAG, "Blog API delete failed: " + response.code());
+                        Log.d(Config.LOGTAG, "BLOG API delete failed: " + response.code());
                     }
                 }
             } catch (final Exception e) {
-                Log.d(Config.LOGTAG, "Blog web delete error", e);
+                Log.d(Config.LOGTAG, "BLOG web delete error", e);
             }
         }).start();
     }
@@ -383,11 +385,11 @@ public class BlogComposerActivity extends XmppActivity
                         .build();
                 try (final var response = client.newCall(request).execute()) {
                     if (!response.isSuccessful()) {
-                        Log.d(Config.LOGTAG, "Blog API update failed: " + response.code());
+                        Log.d(Config.LOGTAG, "BLOG API update failed: " + response.code());
                     }
                 }
             } catch (final Exception e) {
-                Log.d(Config.LOGTAG, "Blog web update error", e);
+                Log.d(Config.LOGTAG, "BLOG web update error", e);
             }
         }).start();
     }
@@ -442,7 +444,7 @@ public class BlogComposerActivity extends XmppActivity
                         headerImageUrl = null; // upload failed, publish without image
                     }
                 } catch (final Exception e) {
-                    Log.d(Config.LOGTAG, "Pending image upload error", e);
+                    Log.d(Config.LOGTAG, "BLOG pending image upload error", e);
                     headerImageUrl = null;
                 }
                 doPublish(title, body);
@@ -482,7 +484,7 @@ public class BlogComposerActivity extends XmppActivity
 
                         @Override
                         public void onFailure(final Throwable t) {
-                            Log.d(Config.LOGTAG, "Failed to edit blog post", t);
+                            Log.d(Config.LOGTAG, "BLOG failed to edit post", t);
                             saveDraft(title, body);
                             runOnUiThread(() -> {
                                 Toast.makeText(BlogComposerActivity.this,
@@ -512,7 +514,7 @@ public class BlogComposerActivity extends XmppActivity
 
                         @Override
                         public void onFailure(final Throwable t) {
-                            Log.d(Config.LOGTAG, "Failed to publish blog post", t);
+                            Log.d(Config.LOGTAG, "BLOG failed to publish post", t);
                             saveDraft(title, body);
                             runOnUiThread(() -> {
                                 Toast.makeText(BlogComposerActivity.this,
@@ -545,6 +547,20 @@ public class BlogComposerActivity extends XmppActivity
         currentDraftId = null;
     }
 
+    private void showPublicWarningIfNeeded() {
+        final android.content.SharedPreferences prefs =
+                getSharedPreferences("jabjab_settings", MODE_PRIVATE);
+        if (prefs.getBoolean("blog_public_warning_shown", false)) return;
+        new androidx.appcompat.app.AlertDialog.Builder(this)
+                .setTitle(R.string.blog_title)
+                .setMessage(R.string.wizard_blog_public_warning)
+                .setPositiveButton(android.R.string.ok, (d, w) ->
+                        prefs.edit().putBoolean("blog_public_warning_shown", true).apply())
+                .setOnCancelListener(d ->
+                        prefs.edit().putBoolean("blog_public_warning_shown", true).apply())
+                .show();
+    }
+
     private void mirrorToWeb(
             final String id, final String jid,
             final String title, final String body, final String published) {
@@ -580,11 +596,11 @@ public class BlogComposerActivity extends XmppActivity
                         .build();
                 try (final var response = client.newCall(request).execute()) {
                     if (!response.isSuccessful()) {
-                        Log.d(Config.LOGTAG, "Blog API mirror failed: " + response.code());
+                        Log.d(Config.LOGTAG, "BLOG API mirror failed: " + response.code());
                     }
                 }
             } catch (final Exception e) {
-                Log.d(Config.LOGTAG, "Blog web mirror error", e);
+                Log.d(Config.LOGTAG, "BLOG web mirror error", e);
             }
         }).start();
     }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/JabJabDeepLinkActivity.java b/src/main/java/tel/xmpp/jabjab/ui/JabJabDeepLinkActivity.java
index 3ed7b83..344c542 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/JabJabDeepLinkActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/JabJabDeepLinkActivity.java
@@ -4,7 +4,6 @@ import android.content.Intent;
 import android.net.Uri;
 import android.os.Bundle;
 import android.widget.Toast;
-import androidx.appcompat.app.AlertDialog;
 import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.entities.Account;
 import tel.xmpp.jabjab.ui.util.UpdateChecker;
@@ -72,28 +71,13 @@ public class JabJabDeepLinkActivity extends XmppActivity {
             return;
         }
 
-        final Jid fromFinal = from;
-        new AlertDialog.Builder(this)
-                .setTitle(R.string.install_sticker_pack)
-                .setMessage(getString(R.string.install_sticker_pack_from_peer))
-                .setPositiveButton(R.string.install, (d, w) ->
-                        xmppConnectionService.fetchAndInstallStickerPack(
-                                account, fromFinal, node,
-                                () -> runOnUiThread(() -> {
-                                    Toast.makeText(this,
-                                            R.string.sticker_pack_installed,
-                                            Toast.LENGTH_SHORT).show();
-                                    finish();
-                                }),
-                                () -> runOnUiThread(() -> {
-                                    Toast.makeText(this,
-                                            R.string.sticker_pack_fetch_failed,
-                                            Toast.LENGTH_SHORT).show();
-                                    finish();
-                                })))
-                .setNegativeButton(R.string.cancel, (d, w) -> finish())
-                .setOnCancelListener(d -> finish())
-                .show();
+        final Intent intent = new Intent(this, PeerStickerPackActivity.class);
+        intent.putExtra(PeerStickerPackActivity.EXTRA_FROM_JID, fromStr);
+        intent.putExtra(PeerStickerPackActivity.EXTRA_NODE, node);
+        intent.putExtra(PeerStickerPackActivity.EXTRA_ACCOUNT,
+                account.getJid().asBareJid().toString());
+        startActivity(intent);
+        finish();
     }
 
     // jabjab://story?jid=user@example.com  OR  jabjab://story/user@example.com
diff --git a/src/main/java/tel/xmpp/jabjab/ui/PeerStickerPackActivity.java b/src/main/java/tel/xmpp/jabjab/ui/PeerStickerPackActivity.java
new file mode 100644
index 0000000..b90ef9b
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/PeerStickerPackActivity.java
@@ -0,0 +1,260 @@
+package tel.xmpp.jabjab.ui;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.progressindicator.LinearProgressIndicator;
+import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.entities.Account;
+import tel.xmpp.jabjab.services.XmppConnectionService;
+import tel.xmpp.jabjab.ui.util.StickerStore;
+import tel.xmpp.jabjab.xmpp.Jid;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Shows a peer's sticker pack fetched via XEP-0449 PubSub before installing.
+ * Launched by JabJabDeepLinkActivity for jabjab://sticker?from=jid&node=...
+ */
+public class PeerStickerPackActivity extends XmppActivity {
+
+    public static final String EXTRA_FROM_JID  = "extra_from_jid";
+    public static final String EXTRA_NODE      = "extra_node";
+    public static final String EXTRA_ACCOUNT   = "extra_account";
+
+    private final ExecutorService executor = Executors.newCachedThreadPool();
+
+    private ProgressBar loadingSpinner;
+    private View contentView;
+    private TextView errorView;
+    private TextView packNameView;
+    private TextView packCountView;
+    private RecyclerView stickerGrid;
+    private View installProgressContainer;
+    private LinearProgressIndicator installProgress;
+    private TextView installProgressLabel;
+    private MaterialButton btnInstall;
+    private MaterialButton btnCancel;
+
+    private String fromJidStr;
+    private String packNode;
+    private String accountJid;
+
+    private List<StickerStore.StickerEntry> entries = new ArrayList<>();
+    private String resolvedPackName = "";
+
+    private boolean installing = false;
+
+    @Override
+    protected void onCreate(final Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_peer_sticker_pack);
+
+        final androidx.appcompat.widget.Toolbar toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        final var ab = getSupportActionBar();
+        if (ab != null) ab.setDisplayHomeAsUpEnabled(true);
+        Activities.setStatusAndNavigationBarColors(this, findViewById(android.R.id.content));
+
+        loadingSpinner        = findViewById(R.id.loading);
+        contentView           = findViewById(R.id.content);
+        errorView             = findViewById(R.id.error_view);
+        packNameView          = findViewById(R.id.pack_name);
+        packCountView         = findViewById(R.id.pack_count);
+        stickerGrid           = findViewById(R.id.sticker_grid);
+        installProgressContainer = findViewById(R.id.install_progress_container);
+        installProgress       = findViewById(R.id.install_progress);
+        installProgressLabel  = findViewById(R.id.install_progress_label);
+        btnInstall            = findViewById(R.id.btn_install);
+        btnCancel             = findViewById(R.id.btn_cancel);
+
+        fromJidStr  = getIntent().getStringExtra(EXTRA_FROM_JID);
+        packNode    = getIntent().getStringExtra(EXTRA_NODE);
+        accountJid  = getIntent().getStringExtra(EXTRA_ACCOUNT);
+
+        btnCancel.setOnClickListener(v -> finish());
+        btnInstall.setOnClickListener(v -> installPack());
+
+        stickerGrid.setLayoutManager(new GridLayoutManager(this, 4));
+        stickerGrid.setAdapter(new PreviewAdapter());
+    }
+
+    @Override
+    public void onBackendConnected() {
+        if (fromJidStr == null || packNode == null) {
+            showError(getString(R.string.sticker_pack_fetch_failed));
+            return;
+        }
+        final Account account = resolveAccount();
+        if (account == null) {
+            showError(getString(R.string.sticker_pack_fetch_failed));
+            return;
+        }
+        fetchPack(account);
+    }
+
+    private Account resolveAccount() {
+        final List<Account> accounts = xmppConnectionService.getAccounts();
+        if (accountJid != null) {
+            for (final Account a : accounts) {
+                if (a.getJid().asBareJid().toString().equals(accountJid)
+                        && a.isOnlineAndConnected()) return a;
+            }
+        }
+        for (final Account a : accounts) {
+            if (a.isOnlineAndConnected()) return a;
+        }
+        return null;
+    }
+
+    private void fetchPack(final Account account) {
+        Log.d(Config.LOGTAG, "STICKER PeerStickerPackActivity: fetching pack from=" + fromJidStr + " node=" + packNode);
+        Jid from;
+        try {
+            from = Jid.of(fromJidStr);
+        } catch (final Exception e) {
+            showError(getString(R.string.sticker_pack_fetch_failed));
+            return;
+        }
+        xmppConnectionService.fetchStickerPackEntries(account, from, packNode,
+                (name, fetchedEntries) -> runOnUiThread(() -> showPackPreview(name, fetchedEntries)),
+                () -> runOnUiThread(() -> showError(getString(R.string.sticker_pack_fetch_failed))));
+    }
+
+    private void showPackPreview(final String name, final List<StickerStore.StickerEntry> fetchedEntries) {
+        resolvedPackName = name;
+        entries = fetchedEntries;
+        packNameView.setText(name);
+        packCountView.setText(getResources().getQuantityString(
+                R.plurals.stickers_count, entries.size(), entries.size()));
+        stickerGrid.getAdapter().notifyDataSetChanged();
+        loadingSpinner.setVisibility(View.GONE);
+        contentView.setVisibility(View.VISIBLE);
+        btnInstall.setEnabled(true);
+        Log.d(Config.LOGTAG, "STICKER PeerStickerPackActivity: showing preview name=" + name + " count=" + entries.size());
+    }
+
+    private void showError(final String msg) {
+        loadingSpinner.setVisibility(View.GONE);
+        errorView.setText(msg);
+        errorView.setVisibility(View.VISIBLE);
+        btnInstall.setEnabled(false);
+    }
+
+    private void installPack() {
+        if (installing || entries.isEmpty()) return;
+        installing = true;
+        btnInstall.setEnabled(false);
+        btnCancel.setEnabled(false);
+        installProgressContainer.setVisibility(View.VISIBLE);
+        installProgress.setMax(entries.size());
+        installProgressLabel.setText(getString(R.string.installing_stickers_progress, 0, entries.size()));
+
+        executor.execute(() -> {
+            try {
+                StickerStore.installFromPep(this, resolvedPackName, packNode, entries, done ->
+                        runOnUiThread(() -> {
+                            installProgress.setProgress(done);
+                            installProgressLabel.setText(
+                                    getString(R.string.installing_stickers_progress, done, entries.size()));
+                        }));
+                // Publish the newly installed pack to PubSub
+                final Account account = resolveAccount();
+                if (account != null) {
+                    final StickerStore.Pack pack = StickerStore.packForNodeId(this, packNode);
+                    if (pack != null) {
+                        xmppConnectionService.publishStickerPack(account, pack);
+                    }
+                }
+                runOnUiThread(() -> {
+                    Toast.makeText(this, R.string.sticker_pack_installed, Toast.LENGTH_SHORT).show();
+                    finish();
+                });
+            } catch (final Exception e) {
+                Log.e(Config.LOGTAG, "STICKER PeerStickerPackActivity: install failed", e);
+                runOnUiThread(() -> {
+                    installing = false;
+                    btnInstall.setEnabled(true);
+                    btnCancel.setEnabled(true);
+                    installProgressContainer.setVisibility(View.GONE);
+                    Toast.makeText(this, R.string.sticker_pack_fetch_failed, Toast.LENGTH_SHORT).show();
+                });
+            }
+        });
+    }
+
+    @Override
+    protected void refreshUiReal() {}
+
+    @Override
+    public boolean onOptionsItemSelected(final MenuItem item) {
+        if (item.getItemId() == android.R.id.home) { finish(); return true; }
+        return super.onOptionsItemSelected(item);
+    }
+
+    // ── Thumbnail preview adapter ────────────────────────────────────────────
+
+    private class PreviewAdapter extends RecyclerView.Adapter<PreviewAdapter.VH> {
+        class VH extends RecyclerView.ViewHolder {
+            final ImageView iv;
+            VH(final ImageView v) { super(v); iv = v; }
+        }
+
+        @NonNull @Override
+        public VH onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
+            final ImageView iv = new ImageView(PeerStickerPackActivity.this);
+            final int size = (int) (parent.getWidth() / 4f);
+            final RecyclerView.LayoutParams lp =
+                    new RecyclerView.LayoutParams(size > 0 ? size : 200, size > 0 ? size : 200);
+            lp.setMargins(4, 4, 4, 4);
+            iv.setLayoutParams(lp);
+            iv.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
+            iv.setPadding(8, 8, 8, 8);
+            return new VH(iv);
+        }
+
+        @Override
+        public void onBindViewHolder(@NonNull final VH holder, final int position) {
+            final String url = entries.get(position).url();
+            holder.iv.setImageBitmap(null);
+            holder.iv.setTag(url);
+            executor.execute(() -> {
+                try {
+                    final HttpURLConnection conn =
+                            (HttpURLConnection) new URL(url).openConnection();
+                    conn.setConnectTimeout(8_000);
+                    conn.setReadTimeout(15_000);
+                    try (final InputStream is = conn.getInputStream()) {
+                        final Bitmap bm = BitmapFactory.decodeStream(is);
+                        runOnUiThread(() -> {
+                            if (url.equals(holder.iv.getTag())) holder.iv.setImageBitmap(bm);
+                        });
+                    }
+                } catch (final Exception e) {
+                    Log.d(Config.LOGTAG, "STICKER preview load failed: " + url, e);
+                }
+            });
+        }
+
+        @Override public int getItemCount() { return entries.size(); }
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StickerPickerActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StickerPickerActivity.java
index 946c375..74d73a6 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StickerPickerActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StickerPickerActivity.java
@@ -120,6 +120,13 @@ public class StickerPickerActivity extends BaseActivity {
             }
             return true;
         }
+        if (item.getItemId() == R.id.action_send_pack_link) {
+            final int pos = pager.getCurrentItem();
+            if (pos >= 0 && pos < packs.size()) {
+                sendPackLinkToContacts(packs.get(pos));
+            }
+            return true;
+        }
         if (item.getItemId() == R.id.action_remove_pack) {
             final int pos = pager.getCurrentItem();
             if (pos >= 0 && pos < packs.size()) {
@@ -173,6 +180,21 @@ public class StickerPickerActivity extends BaseActivity {
         });
     }
 
+    private void sendPackLinkToContacts(final StickerStore.Pack pack) {
+        final String accountJid = getIntent().getStringExtra(EXTRA_ACCOUNT_JID);
+        if (accountJid == null || accountJid.isEmpty()) return;
+        executor.execute(() -> {
+            final String nodeId = StickerStore.getOrCreateNodeId(this, pack);
+            final String link = "jabjab://sticker?from="
+                    + Uri.encode(accountJid) + "&node=" + Uri.encode(nodeId);
+            runOnUiThread(() -> {
+                final Intent intent = new Intent(this, MultiShareActivity.class);
+                intent.putExtra(MultiShareActivity.EXTRA_TEXT, link);
+                startActivity(intent);
+            });
+        });
+    }
+
     private void exportPack(final StickerStore.Pack pack) {
         promptExportName(pack.displayName(), name ->
                 doExportFiles(pack.stickers(), name));
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java
index e12d1c0..aad8687 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java
@@ -456,7 +456,7 @@ public class StoryViewActivity extends XmppActivity {
                     startProgressAndAdvance(STORY_DURATION_MS, STORY_DURATION_MS);
                 });
             } catch (final IOException e) {
-                Log.d(Config.LOGTAG, "Failed to load story image: " + url, e);
+                Log.d(Config.LOGTAG, "STORIES failed to load image: " + url, e);
                 runOnUiThread(() -> {
                     if (isDestroyed() || requestIndex != currentIndex) return;
                     loading.setVisibility(View.GONE);
@@ -493,7 +493,7 @@ public class StoryViewActivity extends XmppActivity {
                             }
                             imageView.setImageDrawable(d);
                         } catch (final IOException ex) {
-                            Log.d(Config.LOGTAG, "Failed to decode GIF", ex);
+                            Log.d(Config.LOGTAG, "STORIES failed to decode GIF", ex);
                             final Bitmap bm = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
                             if (bm != null) imageView.setImageBitmap(bm);
                         }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java b/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java
index 4507633..e354e60 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java
@@ -201,8 +201,11 @@ public class WebStickerPackActivity extends XmppActivity {
                     try (final InputStream is = sc.getInputStream()) {
                         final java.io.File f =
                                 StickerStore.importStickerIfNew(pack, filename, is);
-                        if (f != null && !item.emoji().isEmpty()) {
-                            StickerStore.setStickerEmoji(pack, f, item.emoji());
+                        if (f != null) {
+                            StickerStore.setStickerUrl(pack, f, item.url());
+                            if (!item.emoji().isEmpty()) {
+                                StickerStore.setStickerEmoji(pack, f, item.emoji());
+                            }
                         }
                         Log.d(Config.LOGTAG, "WebStickerPack: stored " + filename + " emoji=" + item.emoji() + " file=" + f);
                     }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java b/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
index 2833dfc..41f181c 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
@@ -165,6 +165,38 @@ public class StickerStore {
         }
     }
 
+    public static void setStickerUrl(final Pack pack, final File sticker, final String url)
+            throws IOException {
+        try {
+            JSONObject meta = readMeta(pack.dir());
+            if (meta == null) meta = buildBaseMeta(pack);
+            JSONArray stickers = meta.optJSONArray("stickers");
+            if (stickers == null) {
+                stickers = new JSONArray();
+                meta.put("stickers", stickers);
+            }
+            boolean found = false;
+            for (int i = 0; i < stickers.length(); i++) {
+                final JSONObject entry = stickers.optJSONObject(i);
+                if (entry != null && sticker.getName().equals(entry.optString("file"))) {
+                    entry.put("url", url);
+                    found = true;
+                    break;
+                }
+            }
+            if (!found) {
+                final JSONObject entry = new JSONObject();
+                entry.put("file", sticker.getName());
+                entry.put("url", url);
+                stickers.put(entry);
+            }
+            meta.put("updated", System.currentTimeMillis());
+            writeMeta(pack.dir(), meta);
+        } catch (JSONException e) {
+            throw new IOException("JSON error", e);
+        }
+    }
+
     // --- Dedup / import ---
 
     public static boolean isDuplicate(final Pack pack, final File source) {
@@ -437,13 +469,13 @@ public class StickerStore {
                 nodeId = "urn:xmpp:stickers:0#" + java.util.UUID.randomUUID();
                 meta.put("node_id", nodeId);
                 writeMeta(pack.dir(), meta);
-                Log.d(Config.LOGTAG, "StickerStore: created new PubSub node id=" + nodeId + " for pack=" + pack.displayName());
+                Log.d(Config.LOGTAG, "STICKER  created new PubSub node id=" + nodeId + " for pack=" + pack.displayName());
             } else {
-                Log.d(Config.LOGTAG, "StickerStore: reusing node id=" + nodeId + " for pack=" + pack.displayName());
+                Log.d(Config.LOGTAG, "STICKER  reusing node id=" + nodeId + " for pack=" + pack.displayName());
             }
             return nodeId;
         } catch (Exception e) {
-            Log.w(Config.LOGTAG, "StickerStore: getOrCreateNodeId failed for pack=" + pack.displayName(), e);
+            Log.w(Config.LOGTAG, "STICKER getOrCreateNodeId failed for pack=" + pack.displayName(), e);
             return "urn:xmpp:stickers:0#" + pack.id();
         }
     }
@@ -478,7 +510,7 @@ public class StickerStore {
             // so other servers that legitimately use /upload/ are not affected.
             if (url.contains("upload.xmpp.tel/upload/")) {
                 Log.d(tel.xmpp.jabjab.Config.LOGTAG,
-                        "StickerStore: evicting stale prosody-filer URL for " + accountJid + ": " + url);
+                        "STICKER evicting stale prosody-filer URL for " + accountJid + ": " + url);
                 return null;
             }
             return url;
@@ -520,8 +552,9 @@ public class StickerStore {
     }
 
     public static tel.xmpp.jabjab.xml.Element buildPepPackItem(final Pack pack) {
-        Log.d(Config.LOGTAG, "StickerStore: building PEP pack item for pack=" + pack.displayName() + " dir=" + pack.dir());
+        Log.d(Config.LOGTAG, "STICKER  building PEP pack item for pack=" + pack.displayName() + " dir=" + pack.dir());
         final JSONObject meta = readMeta(pack.dir());
+        Log.d(Config.LOGTAG, "STICKER  meta=" + (meta != null ? meta.toString() : "null"));
         final tel.xmpp.jabjab.xml.Element item = new tel.xmpp.jabjab.xml.Element("item");
         item.setAttribute("id", "current");
         final tel.xmpp.jabjab.xml.Element packEl = item.addChild("pack", tel.xmpp.jabjab.xml.Namespace.STICKERS);
@@ -532,8 +565,19 @@ public class StickerStore {
                 for (int i = 0; i < stickers.length(); i++) {
                     final JSONObject entry = stickers.optJSONObject(i);
                     if (entry == null) continue;
-                    final String url = entry.optString("url", null);
-                    if (url == null || url.isEmpty()) continue;
+                    String url = entry.optString("url", null);
+                    // For self-created stickers, fall back to any cached upload URL
+                    if (url == null || url.isEmpty()) {
+                        final JSONObject uploadUrls = entry.optJSONObject("uploadUrls");
+                        if (uploadUrls != null) {
+                            final java.util.Iterator<String> keys = uploadUrls.keys();
+                            if (keys.hasNext()) url = uploadUrls.optString(keys.next(), null);
+                        }
+                    }
+                    if (url == null || url.isEmpty()) {
+                        Log.d(Config.LOGTAG, "STICKER buildPepPackItem: skipping " + entry.optString("file") + " — no url");
+                        continue;
+                    }
                     final tel.xmpp.jabjab.xml.Element stickerEl = packEl.addChild("sticker");
                     final String emoji = entry.optString("emoji", null);
                     final String filename = entry.optString("file", null);
@@ -551,15 +595,15 @@ public class StickerStore {
                                 fileEl.addChild("hash")
                                         .setAttribute("algo", "sha-256")
                                         .setContent(b64);
-                                Log.d(Config.LOGTAG, "StickerStore: sticker file=" + filename + " sha256=" + b64);
+                                Log.d(Config.LOGTAG, "STICKER  sticker file=" + filename + " sha256=" + b64);
                             } else {
-                                Log.w(Config.LOGTAG, "StickerStore: could not compute sha256 for file=" + filename);
+                                Log.w(Config.LOGTAG, "STICKER could not compute sha256 for file=" + filename);
                             }
                         } else {
-                            Log.w(Config.LOGTAG, "StickerStore: sticker file missing: " + stickerFile);
+                            Log.w(Config.LOGTAG, "STICKER sticker file missing: " + stickerFile);
                         }
                     }
-                    Log.d(Config.LOGTAG, "StickerStore: added sticker emoji=" + emoji + " url=" + url);
+                    Log.d(Config.LOGTAG, "STICKER  added sticker emoji=" + emoji + " url=" + url);
                     stickerEl.addChild("url-data").setAttribute("target", url);
                 }
             }
@@ -569,7 +613,13 @@ public class StickerStore {
 
     public static Pack installFromPep(final Context context, final String packName,
             final String packNode, final List<StickerEntry> entries) throws IOException {
-        Log.d(Config.LOGTAG, "StickerStore: installFromPep pack=" + packName + " node=" + packNode + " stickers=" + entries.size());
+        return installFromPep(context, packName, packNode, entries, null);
+    }
+
+    public static Pack installFromPep(final Context context, final String packName,
+            final String packNode, final List<StickerEntry> entries,
+            final java.util.function.IntConsumer onProgress) throws IOException {
+        Log.d(Config.LOGTAG, "STICKER  installFromPep pack=" + packName + " node=" + packNode + " stickers=" + entries.size());
         final File root = stickerRoot(context);
         final File packDir = new File(root, packName);
         if (!packDir.exists() && !packDir.mkdirs()) {
@@ -577,21 +627,23 @@ public class StickerStore {
         }
         final JSONArray stickersJson = new JSONArray();
         int index = 1;
+        int done = 0;
         for (final StickerEntry entry : entries) {
             final String filename = String.format(java.util.Locale.ROOT, "%04d.webp", index++);
             final File dest = new File(packDir, filename);
-            Log.d(Config.LOGTAG, "StickerStore: downloading sticker url=" + entry.url() + " → " + filename);
+            Log.d(Config.LOGTAG, "STICKER  downloading sticker url=" + entry.url() + " → " + filename);
             downloadFile(entry.url(), dest);
+            if (onProgress != null) onProgress.accept(++done);
             if (entry.expectedSha256() != null) {
                 final byte[] actual = sha256(dest);
                 final byte[] expected = android.util.Base64.decode(
                         entry.expectedSha256(), android.util.Base64.DEFAULT);
                 if (!java.util.Arrays.equals(actual, expected)) {
-                    Log.w(Config.LOGTAG, "StickerStore: sha256 mismatch for " + entry.url() + " — skipping");
+                    Log.w(Config.LOGTAG, "STICKER sha256 mismatch for " + entry.url() + " — skipping");
                     dest.delete();
                     continue;
                 }
-                Log.d(Config.LOGTAG, "StickerStore: sha256 verified for " + filename);
+                Log.d(Config.LOGTAG, "STICKER  sha256 verified for " + filename);
             }
             final JSONObject stickerObj = new JSONObject();
             try {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java
index 7a36ea1..52b931f 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java
@@ -63,7 +63,7 @@ public class TranslationService {
                 db.put(hash, targetLang, result.sourceLang(), result.translatedText());
                 MAIN.post(() -> callback.onResult(result.translatedText(), result.sourceLang()));
             } catch (final Exception e) {
-                Log.d(Config.LOGTAG, "Translation request failed", e);
+                Log.d(Config.LOGTAG, "TRANSLATE translation request failed", e);
                 MAIN.post(() -> callback.onError(e));
             }
         });
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
index 723f5b1..79359bd 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
@@ -95,7 +95,7 @@ public class UpdateChecker {
                     }
                 }
             } catch (final Exception e) {
-                Log.d(Config.LOGTAG, "Update check failed", e);
+                Log.d(Config.LOGTAG, "UPDATE check failed", e);
             }
         });
     }
@@ -109,7 +109,7 @@ public class UpdateChecker {
                 if (info == null) {
                     main.post(callback::onError);
                 } else {
-                    Log.d(Config.LOGTAG, "update check: remote=" + info.versionCode()
+                    Log.d(Config.LOGTAG, "UPDATE check: remote=" + info.versionCode()
                             + " local=" + BuildConfig.VERSION_CODE);
                     if (info.versionCode() > BuildConfig.VERSION_CODE) {
                         main.post(() -> callback.onUpdateAvailable(
@@ -120,7 +120,7 @@ public class UpdateChecker {
                     }
                 }
             } catch (final Exception e) {
-                Log.d(Config.LOGTAG, "Manual update check failed", e);
+                Log.d(Config.LOGTAG, "UPDATE manual check failed", e);
                 new android.os.Handler(android.os.Looper.getMainLooper()).post(callback::onError);
             }
         });
@@ -136,7 +136,7 @@ public class UpdateChecker {
         try {
             response = client.newCall(request).execute();
         } catch (final IOException e) {
-            Log.d(Config.LOGTAG, "Could not fetch latest.json", e);
+            Log.d(Config.LOGTAG, "UPDATE could not fetch latest.json", e);
             return null;
         }
         if (!response.isSuccessful() || response.body() == null) return null;
@@ -188,7 +188,7 @@ public class UpdateChecker {
     /** Called by the Cancel notification action to abort an in-progress download. */
     public static void cancelDownload() {
         sCancelled = true;
-        Log.d(Config.LOGTAG, "update: download cancel requested");
+        Log.d(Config.LOGTAG, "UPDATE download cancel requested");
     }
 
     public void downloadAndInstall(
@@ -199,7 +199,7 @@ public class UpdateChecker {
             final String sha256) {
         // Ignore taps if a download is already running — prevents concurrent writes to the APK file.
         if (!sDownloading.compareAndSet(false, true)) {
-            Log.d(Config.LOGTAG, "update: download already in progress, ignoring duplicate request");
+            Log.d(Config.LOGTAG, "UPDATE download already in progress, ignoring duplicate request");
             return;
         }
         sCancelled = false;
@@ -207,7 +207,7 @@ public class UpdateChecker {
             try {
                 downloadAndInstallInternal(downloadUrl, versionCode, versionName, fileSize, sha256);
             } catch (final Exception e) {
-                Log.d(Config.LOGTAG, "Download failed", e);
+                Log.d(Config.LOGTAG, "UPDATE download failed", e);
                 notificationManager.cancel(NOTIFY_DOWNLOAD);
             } finally {
                 sDownloading.set(false);
@@ -229,7 +229,7 @@ public class UpdateChecker {
                 .header("User-Agent", "JabJab/" + BuildConfig.VERSION_NAME);
         if (existingBytes > 0) {
             requestBuilder.header("Range", "bytes=" + existingBytes + "-");
-            Log.d(Config.LOGTAG, "update: resuming download from byte " + existingBytes);
+            Log.d(Config.LOGTAG, "UPDATE resuming download from byte " + existingBytes);
         }
 
         final Response response = client.newCall(requestBuilder.build()).execute();
@@ -237,9 +237,9 @@ public class UpdateChecker {
         if (response.code() == 200 && existingBytes > 0) {
             // Server doesn't support range requests — discard the partial file and start fresh
             apkFile.delete();
-            Log.d(Config.LOGTAG, "update: server rejected Range, restarting download");
+            Log.d(Config.LOGTAG, "UPDATE server rejected Range, restarting download");
         } else if (!response.isSuccessful() || response.body() == null) {
-            Log.d(Config.LOGTAG, "update: unexpected response " + response.code());
+            Log.d(Config.LOGTAG, "UPDATE unexpected response " + response.code());
             return;
         }
 
@@ -265,7 +265,7 @@ public class UpdateChecker {
         }
 
         if (sCancelled) {
-            Log.d(Config.LOGTAG, "update: download cancelled by user");
+            Log.d(Config.LOGTAG, "UPDATE download cancelled by user");
             apkFile.delete();
             notificationManager.cancel(NOTIFY_DOWNLOAD);
             return;
@@ -277,12 +277,12 @@ public class UpdateChecker {
         if (!sha256.isEmpty()) {
             final String actual = sha256Of(apkFile);
             if (!sha256.equalsIgnoreCase(actual)) {
-                Log.e(Config.LOGTAG, "update: hash mismatch — expected " + sha256 + " got " + actual);
+                Log.e(Config.LOGTAG, "UPDATE hash mismatch — expected " + sha256 + " got " + actual);
                 apkFile.delete();
                 notificationManager.cancel(NOTIFY_DOWNLOAD);
                 return;
             }
-            Log.d(Config.LOGTAG, "update: SHA-256 verified OK");
+            Log.d(Config.LOGTAG, "UPDATE SHA-256 verified OK");
         }
 
         notificationManager.cancel(NOTIFY_DOWNLOAD);
diff --git a/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java b/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java
index 882e6a8..a6ea2d1 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java
@@ -577,9 +577,9 @@ public final class MimeUtils {
 
     public static String guessMimeTypeFromUriAndMime(
             final Context context, final Uri uri, final String mime) {
-        Log.d(Config.LOGTAG, "guessMimeTypeFromUriAndMime(" + uri + "," + mime + ")");
+        Log.d(Config.LOGTAG, "MIME guessMimeTypeFromUriAndMime(" + uri + "," + mime + ")");
         final String mimeFromUri = guessMimeTypeFromUri(context, uri);
-        Log.d(Config.LOGTAG, "mimeFromUri:" + mimeFromUri);
+        Log.d(Config.LOGTAG, "MIME mimeFromUri:" + mimeFromUri);
         if (PATH_PRECEDENCE_MIME_TYPE.contains(mimeFromUri)) {
             return mimeFromUri;
         } else if (mime == null || mime.equals("application/octet-stream")) {
@@ -597,7 +597,7 @@ public final class MimeUtils {
         final String mimeTypeFromName = Strings.isNullOrEmpty(name) ? null : guessFromPath(name);
         final String path = uri.getPath();
         final String mimeTypeFromPath = Strings.isNullOrEmpty(path) ? null : guessFromPath(path);
-        Log.d(Config.LOGTAG, "guessMimeTypeFromUri uri=" + uri
+        Log.d(Config.LOGTAG, "MIME guessMimeTypeFromUri uri=" + uri
                 + " name=" + name + " resolver=" + mimeTypeContentResolver
                 + " fromName=" + mimeTypeFromName + " fromPath=" + mimeTypeFromPath);
         if (PATH_PRECEDENCE_MIME_TYPE.contains(mimeTypeFromName)) {
diff --git a/src/main/res/layout/activity_peer_sticker_pack.xml b/src/main/res/layout/activity_peer_sticker_pack.xml
new file mode 100644
index 0000000..acd6cde
--- /dev/null
+++ b/src/main/res/layout/activity_peer_sticker_pack.xml
@@ -0,0 +1,140 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:fitsSystemWindows="true"
+    android:orientation="vertical">
+
+    <com.google.android.material.appbar.AppBarLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <com.google.android.material.appbar.MaterialToolbar
+            android:id="@+id/toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="?actionBarSize"
+            android:title="@string/install_sticker_pack" />
+
+    </com.google.android.material.appbar.AppBarLayout>
+
+    <!-- Loading state -->
+    <ProgressBar
+        android:id="@+id/loading"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:layout_weight="1"
+        android:visibility="visible" />
+
+    <!-- Content (pack info + sticker grid) -->
+    <ScrollView
+        android:id="@+id/content"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:visibility="gone">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical"
+            android:padding="16dp">
+
+            <TextView
+                android:id="@+id/pack_name"
+                style="?textAppearanceTitleLarge"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginBottom="4dp" />
+
+            <TextView
+                android:id="@+id/pack_description"
+                style="?textAppearanceBodyMedium"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginBottom="4dp"
+                android:alpha="0.7"
+                android:visibility="gone" />
+
+            <TextView
+                android:id="@+id/pack_count"
+                style="?textAppearanceBodySmall"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginBottom="12dp"
+                android:alpha="0.6" />
+
+            <androidx.recyclerview.widget.RecyclerView
+                android:id="@+id/sticker_grid"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:nestedScrollingEnabled="false" />
+
+        </LinearLayout>
+    </ScrollView>
+
+    <!-- Error state -->
+    <TextView
+        android:id="@+id/error_view"
+        style="?textAppearanceBodyMedium"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:gravity="center"
+        android:padding="32dp"
+        android:visibility="gone" />
+
+    <!-- Install progress -->
+    <LinearLayout
+        android:id="@+id/install_progress_container"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:paddingHorizontal="16dp"
+        android:paddingTop="8dp"
+        android:visibility="gone">
+
+        <TextView
+            android:id="@+id/install_progress_label"
+            style="?textAppearanceBodySmall"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="4dp"
+            android:alpha="0.7" />
+
+        <com.google.android.material.progressindicator.LinearProgressIndicator
+            android:id="@+id/install_progress"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:indeterminate="false"
+            app:trackCornerRadius="4dp" />
+
+    </LinearLayout>
+
+    <!-- Bottom buttons -->
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        android:padding="12dp"
+        android:gravity="end">
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/btn_cancel"
+            style="@style/Widget.Material3.Button.TextButton"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="@android:string/cancel" />
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/btn_install"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="8dp"
+            android:enabled="false"
+            android:text="@string/install" />
+
+    </LinearLayout>
+
+</LinearLayout>
diff --git a/src/main/res/menu/sticker_picker.xml b/src/main/res/menu/sticker_picker.xml
index ff6e7c5..bbc2b19 100644
--- a/src/main/res/menu/sticker_picker.xml
+++ b/src/main/res/menu/sticker_picker.xml
@@ -16,6 +16,9 @@
     <item
         android:id="@+id/action_copy_pack_link"
         android:title="@string/copy_pack_link" />
+    <item
+        android:id="@+id/action_send_pack_link"
+        android:title="@string/send_pack_link" />
     <item
         android:id="@+id/action_remove_pack"
         android:title="@string/remove_sticker_pack" />
diff --git a/src/main/res/values-fa-rIR/strings.xml b/src/main/res/values-fa-rIR/strings.xml
index 9cff703..492a4c8 100644
--- a/src/main/res/values-fa-rIR/strings.xml
+++ b/src/main/res/values-fa-rIR/strings.xml
@@ -995,4 +995,5 @@
     <string name="tab_joined_channels">عضو شده</string>
     <string name="tab_discover_channels">کشف کنید</string>
     <string name="no_joined_channels_hint">هنوز کانال یا گروه چتی وجود ندارد.\nاز تب کشف کنید برای یافتن و پیوستن به کانال‌های عمومی استفاده کنید.</string>
+    <string name="wizard_blog_public_warning">پست‌های وبلاگ در blog.xmpp.tel منتشر می‌شوند و برای هر کسی در اینترنت قابل مشاهده هستند — از جمله موتورهای جستجو و افرادی که در مخاطبین شما نیستند. آن را مانند یک وب‌سایت عمومی در نظر بگیرید.</string>
 </resources>
diff --git a/src/main/res/values-id/strings.xml b/src/main/res/values-id/strings.xml
index 98cc3e6..febc6c1 100644
--- a/src/main/res/values-id/strings.xml
+++ b/src/main/res/values-id/strings.xml
@@ -461,4 +461,5 @@
     <string name="tab_joined_channels">Diikuti</string>
     <string name="tab_discover_channels">Temukan</string>
     <string name="no_joined_channels_hint">Belum ada saluran atau obrolan grup.\nGunakan tab Temukan untuk menemukan dan bergabung dengan saluran publik.</string>
+    <string name="wizard_blog_public_warning">Postingan blog diterbitkan di blog.xmpp.tel dan dapat dilihat oleh siapa saja di internet — termasuk mesin pencari dan orang yang bukan kontak Anda. Anggap saja seperti situs web publik.</string>
 </resources>
diff --git a/src/main/res/values-iw/strings.xml b/src/main/res/values-iw/strings.xml
index b6ca1dc..36ad9d5 100644
--- a/src/main/res/values-iw/strings.xml
+++ b/src/main/res/values-iw/strings.xml
@@ -362,4 +362,5 @@
     <string name="tab_joined_channels">הצטרפתי</string>
     <string name="tab_discover_channels">גלה</string>
     <string name="no_joined_channels_hint">אין ערוצים או צ\'אטים קבוצתיים עדיין.\nהשתמש בכרטיסייה גלה כדי למצוא ולהצטרף לערוצים ציבוריים.</string>
+    <string name="wizard_blog_public_warning">פוסטים בבלוג מפורסמים ב-blog.xmpp.tel וגלויים לכל אחד באינטרנט — כולל מנועי חיפוש ואנשים שאינם באנשי הקשר שלך. חשוב על זה כאתר אינטרנט ציבורי.</string>
 </resources>
diff --git a/src/main/res/values-ko/strings.xml b/src/main/res/values-ko/strings.xml
index ff13d7a..9bc031f 100644
--- a/src/main/res/values-ko/strings.xml
+++ b/src/main/res/values-ko/strings.xml
@@ -381,4 +381,5 @@
     <string name="tab_joined_channels">참가 중</string>
     <string name="tab_discover_channels">탐색</string>
     <string name="no_joined_channels_hint">아직 채널이나 그룹 채팅이 없습니다.\n탐색 탭을 사용하여 공개 채널을 찾고 참가하세요.</string>
+    <string name="wizard_blog_public_warning">블로그 게시물은 blog.xmpp.tel에 게시되며 검색 엔진 및 연락처에 없는 사람을 포함하여 인터넷의 누구에게나 표시됩니다. 공개 웹사이트라고 생각하세요.</string>
 </resources>
diff --git a/src/main/res/values-uk/strings.xml b/src/main/res/values-uk/strings.xml
index 802e118..9929e98 100644
--- a/src/main/res/values-uk/strings.xml
+++ b/src/main/res/values-uk/strings.xml
@@ -1265,4 +1265,5 @@
     <string name="tab_joined_channels">Приєднані</string>
     <string name="tab_discover_channels">Огляд</string>
     <string name="no_joined_channels_hint">Немає каналів або групових чатів.\nВикористовуйте вкладку Огляд для пошуку та приєднання до публічних каналів.</string>
+    <string name="wizard_blog_public_warning">Публікації блогу розміщуються на blog.xmpp.tel і видимі для будь-кого в інтернеті — включаючи пошукові системи та людей, яких немає у ваших контактах. Сприймайте це як публічний веб-сайт.</string>
 </resources>
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 0ea692b..d72a175 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -1489,6 +1489,7 @@
     <string name="story_delete_failed">Failed to delete story</string>
     <string name="copy_pack_link">Copy pack link</string>
     <string name="pack_link_copied">Pack link copied to clipboard</string>
+    <string name="send_pack_link">Send to contacts</string>
     <string name="open_chat">Open chat</string>
     <string name="view_story">View story</string>
     <string name="open_link">Open</string>

Built with passion for open-source software, XMPP, privacy, and security —
the belief that people deserve communication tools that serve them, not surveil them.

Dedicated to every brave man and woman who stands up against tyranny and oppression across the world.
And in memory of those who were silenced before they could speak freely.