Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<abi>/` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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) { }
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<abi>/
* 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<String> abis(String apkPath) throws IOException {
Set<String> abis = new LinkedHashSet<>();
try (ZipFile zip = new ZipFile(apkPath)) {
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
int slash = name.indexOf('/', 4);
// A bare lib/<abi>/ directory entry carries no library.
if (!entry.isDirectory() && name.startsWith("lib/") && slash > 4) {
abis.add(name.substring(4, slash));
}
}
}
return abis;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<abi>/ 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());
}
}
Loading