diff --git a/CLAUDE.md b/CLAUDE.md index cf75e343b..e9c868696 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,6 +235,9 @@ validation, allowing config-directive/URL injection. parse boundary (returns `null` -> existing "invalid QR" toast), and `RsyncManager` re-checks defensively before writing the daemon config / building the client URL. Reusable by the remaining injection fixes (S4, D2) as they migrate. +- `data/ApkAbiReader` (K2GO-510): lists the `lib//` folders of the APK that + Clone "Send app" shares; `domain/ApkShareName.archLabel` names the file after them. + **Legacy seam:** `CloneFragment.sharedApkName()`. **Slice (DONE) — deploy module-name allowlist (`org.iiab.controller.deploy.domain`)** Phase-1 security slice closing tech-debt **D2** (command injection). The install diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/CloneFragment.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/CloneFragment.java index a785a82f8..46d0de536 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/CloneFragment.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/CloneFragment.java @@ -33,12 +33,14 @@ import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import java.io.File; +import java.io.IOException; import org.appdevforall.k2go.ApkServer; import org.appdevforall.k2go.BuildConfig; import org.appdevforall.k2go.R; import org.appdevforall.k2go.ServerController; import org.appdevforall.k2go.SyncHandshakeHelper; import org.appdevforall.k2go.env.EnvironmentLock; +import org.appdevforall.k2go.sync.data.ApkAbiReader; import org.appdevforall.k2go.sync.domain.ApkShareName; import org.appdevforall.k2go.hotspot.HotspotPermissions; import org.appdevforall.k2go.hotspot.LocalHotspotManager; @@ -886,8 +888,7 @@ private void startApkServer() { if (apkServer != null) return; try { String apkPath = requireContext().getApplicationInfo().sourceDir; - String arch = (archBits() == 64) ? "arm64-v8a" : "armeabi-v7a"; - apkFileName = ApkShareName.fileName(BuildConfig.VERSION_NAME, arch); + apkFileName = sharedApkName(apkPath); apkServer = new ApkServer(shareConfig.apkPort, apkPath, apkFileName); apkServer.start(); } catch (Exception e) { @@ -896,6 +897,19 @@ private void startApkServer() { } } + /** K2GO-510: name the shared APK after the ABIs it carries (ADFA-4540), not after this phone. */ + private String sharedApkName(String apkPath) { + String arch; + try { + arch = ApkShareName.archLabel(ApkAbiReader.abis(apkPath)); + } catch (IOException | RuntimeException e) { + // The name is a hint: it must never block the share. + Log.w("IIAB-Clone", "Could not read the APK ABIs", e); + arch = null; // "unknown": do not guess + } + return ApkShareName.fileName(BuildConfig.VERSION_NAME, arch); + } + private void stopApkServer() { if (apkServer != null) { try { apkServer.stop(); } catch (Exception ignored) { } @@ -907,7 +921,9 @@ private void stopApkServer() { private void shareApkViaSheet() { try { File apk = new File(requireContext().getApplicationInfo().sourceDir); - Uri uri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".provider", apk); + // Without a display name the receiver gets the file as "base.apk". + Uri uri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".provider", + apk, apkFileName != null ? apkFileName : sharedApkName(apk.getPath())); Intent i = new Intent(Intent.ACTION_SEND) .setType("application/vnd.android.package-archive") .putExtra(Intent.EXTRA_STREAM, uri) diff --git a/controller/app/src/main/java/org/appdevforall/k2go/sync/data/ApkAbiReader.java b/controller/app/src/main/java/org/appdevforall/k2go/sync/data/ApkAbiReader.java new file mode 100644 index 000000000..c056f4936 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/sync/data/ApkAbiReader.java @@ -0,0 +1,43 @@ +/* + * ============================================================================ + * Name : ApkAbiReader.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Reads the native ABIs that an APK carries (its lib// + * folders), so the shared file is named after what it contains, + * not after the phone that sends it (ADFA-4540, K2GO-510). + * JDK zip only, so it is testable on a plain JVM. + * ============================================================================ + */ +package org.appdevforall.k2go.sync.data; + +import java.io.IOException; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public final class ApkAbiReader { + + private ApkAbiReader() { + } + + /** Distinct ABI folder names under {@code lib/} in the APK; empty when it has no native libs. */ + public static Set abis(String apkPath) throws IOException { + Set abis = new LinkedHashSet<>(); + try (ZipFile zip = new ZipFile(apkPath)) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + int slash = name.indexOf('/', 4); + // A bare lib// directory entry carries no library. + if (!entry.isDirectory() && name.startsWith("lib/") && slash > 4) { + abis.add(name.substring(4, slash)); + } + } + } + return abis; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/sync/domain/ApkShareName.java b/controller/app/src/main/java/org/appdevforall/k2go/sync/domain/ApkShareName.java index 7c07f203f..39108347b 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/sync/domain/ApkShareName.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/sync/domain/ApkShareName.java @@ -4,12 +4,12 @@ * Author : AppDevForAll * Copyright : Copyright (c) 2026 AppDevForAll * Description : Domain rule: the coloquial file name the shared APK travels - * under when a peer downloads it over the Share tab. Stamps the + * under (Clone "Send app": QR download and share sheet). Stamps the * brand, the build version and the arch so the receiver can tell * exactly which build they were handed offline, instead of the old * "-Latest" (true but ambiguous). Pure, no android.*; the single - * source of truth for the download name so the HTTP header and the - * QR URL cannot drift apart. ADFA-4540. + * source of truth for the download name so the HTTP header, the + * QR URL and the share sheet cannot drift apart. ADFA-4540. * ============================================================================ */ package org.appdevforall.k2go.sync.domain; diff --git a/controller/app/src/test/java/org/appdevforall/k2go/sync/data/ApkAbiReaderTest.java b/controller/app/src/test/java/org/appdevforall/k2go/sync/data/ApkAbiReaderTest.java new file mode 100644 index 000000000..17d68b56c --- /dev/null +++ b/controller/app/src/test/java/org/appdevforall/k2go/sync/data/ApkAbiReaderTest.java @@ -0,0 +1,67 @@ +package org.appdevforall.k2go.sync.data; + +import static org.junit.Assert.assertEquals; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** The ABIs come from the APK's lib// folders only. Pure JVM. */ +public class ApkAbiReaderTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private String apk(String... entries) throws IOException { + File f = tmp.newFile(); + try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(f))) { + for (String e : entries) { + zip.putNextEntry(new ZipEntry(e)); + zip.closeEntry(); + } + } + return f.getPath(); + } + + @Test + public void universalApkListsEveryAbiOnce() throws IOException { + String path = apk("classes.dex", + "lib/arm64-v8a/libproot.so", "lib/arm64-v8a/libtermux.so", + "lib/armeabi-v7a/libproot.so", "lib/x86/libproot.so", "lib/x86_64/libproot.so"); + assertEquals(new LinkedHashSet<>(Arrays.asList("arm64-v8a", "armeabi-v7a", "x86", "x86_64")), + ApkAbiReader.abis(path)); + } + + @Test + public void singleAbiSplit() throws IOException { + assertEquals(Collections.singleton("arm64-v8a"), + ApkAbiReader.abis(apk("AndroidManifest.xml", "lib/arm64-v8a/libproot.so"))); + } + + @Test + public void baseWithoutNativeLibsIsEmpty() throws IOException { + // A Play config-split base.apk: native libs live in another split. + assertEquals(Collections.emptySet(), + ApkAbiReader.abis(apk("AndroidManifest.xml", "classes.dex", "assets/lib/x.so", "lib/"))); + } + + @Test + public void emptyAbiDirectoryDoesNotCount() throws IOException { + assertEquals(Collections.singleton("arm64-v8a"), + ApkAbiReader.abis(apk("lib/x86/", "lib/arm64-v8a/", "lib/arm64-v8a/libproot.so"))); + } + + @Test(expected = IOException.class) + public void unreadableFileThrows() throws IOException { + ApkAbiReader.abis(new File(tmp.getRoot(), "missing.apk").getPath()); + } +}