diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..8955ac7 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,22 @@ +name: Android phone +on: + pull_request: + paths: ['android/**', 'ui/**', 'scripts/**', 'frame/**', 'apk-catalog/**', 'ios/scripts/make_frame_bundle.py', '.github/workflows/android.yml'] + push: + branches: [main, 'feat/phone-screenshot-autosync-108'] + paths: ['android/**', 'ui/**', 'scripts/**', 'frame/**', 'apk-catalog/**', 'ios/scripts/make_frame_bundle.py', '.github/workflows/android.yml'] +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: android-actions/setup-android@v3 + - uses: gradle/actions/setup-gradle@v4 + - run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0' + - run: ./android/gradlew -p android assembleDebug assembleDebugAndroidTest testDebugUnitTest lintDebug --no-daemon diff --git a/README.md b/README.md index a29639c..373e486 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,10 @@ If the app is blocked, see the first-launch notes below before proceeding. **iPhone and iPad:** the same features from your phone, with nothing to install on a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md). +**Android phones (development):** a native companion is in [`android/`](android). +It can download new screenshots during a user-started sync session, with Gallery +saving enabled by default. See [build instructions and current testing limits](docs/android-phone.md). + The app brings its own Python and `adb`; SSH is built into macOS and Windows. From 0.4 it updates itself: when a new version is published, a banner offers **Update and restart**. It sends anonymous usage statistics, which you can turn @@ -245,6 +249,7 @@ Frame's software fits together, all checked against a real headset and labelled | [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers | | [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes | | [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing | +| [Frame Control for Android phones](docs/android-phone.md) | Development build, pairing, screenshot downloads and foreground sync limits | | [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset | | [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel | | [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM | diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..eae9bff --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +**/build/ +local.properties +*.keystore +*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..341eb1c --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,35 @@ +plugins { id 'com.android.application' } + +android { + namespace 'com.saphid.framecontrol' + compileSdk 36 + defaultConfig { + applicationId 'com.saphid.framecontrol' + minSdk 29 + targetSdk 36 + versionCode 1 + versionName '0.1.0' + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + } + compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 } + sourceSets.main.assets.srcDir layout.buildDirectory.dir('generated/frameAssets').get().asFile +} + +def frameAssets = tasks.register('frameAssets', Exec) { + workingDir rootProject.projectDir.parentFile + commandLine 'python3', 'android/scripts/make_assets.py', layout.buildDirectory.dir('generated/frameAssets').get().asFile + inputs.files(fileTree(rootProject.file('../ui')), fileTree(rootProject.file('../scripts')), fileTree(rootProject.file('../frame')), fileTree(rootProject.file('../apk-catalog')) { exclude 'data/**' }) + inputs.file(rootProject.file('../ios/scripts/make_frame_bundle.py')) + inputs.file(rootProject.file('scripts/make_assets.py')) + outputs.dir layout.buildDirectory.dir('generated/frameAssets') +} +tasks.named('preBuild').configure { dependsOn frameAssets } + +dependencies { + implementation 'com.github.mwiede:jsch:2.28.8' + implementation 'com.google.code.gson:gson:2.13.2' + implementation 'androidx.core:core:1.17.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test:runner:1.7.0' + androidTestImplementation 'androidx.test.ext:junit:1.3.0' +} diff --git a/android/app/src/androidTest/java/com/saphid/framecontrol/ScreenshotDeviceTest.java b/android/app/src/androidTest/java/com/saphid/framecontrol/ScreenshotDeviceTest.java new file mode 100644 index 0000000..c365dc9 --- /dev/null +++ b/android/app/src/androidTest/java/com/saphid/framecontrol/ScreenshotDeviceTest.java @@ -0,0 +1,409 @@ +package com.saphid.framecontrol; + +import static org.junit.Assert.*; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.*; +import android.database.Cursor; +import android.graphics.*; +import android.net.Uri; +import android.provider.MediaStore; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.*; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class ScreenshotDeviceTest { + private Context context; + private final Set cleanup = new LinkedHashSet<>(); + + @Before + public void setup() { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @After + public void cleanup() { + for (Uri uri : cleanup) context.getContentResolver().delete(uri, null, null); + } + + private String name(String headset, SyncEngine.Shot shot) throws Exception { + String file = shot.id.substring(shot.id.lastIndexOf('/') + 1); + int dot = file.lastIndexOf('.'); + return file.substring(0, dot) + + "_" + + FrameConnection.hash(headset + "/" + shot.id).substring(0, 20) + + file.substring(dot); + } + + private Uri row(String headset, SyncEngine.Shot shot) throws Exception { + Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + @SuppressWarnings("deprecation") + Uri includingPending = MediaStore.setIncludePending(collection); + try (Cursor c = + context + .getContentResolver() + .query( + includingPending, + new String[] {"_id"}, + MediaStore.Images.Media.DISPLAY_NAME + + "=? AND " + + MediaStore.Images.Media.RELATIVE_PATH + + "=?", + new String[] {name(headset, shot), "Pictures/Frame Control/"}, + null)) { + if (c != null && c.moveToFirst()) { + Uri uri = ContentUris.withAppendedId(collection, c.getLong(0)); + cleanup.add(uri); + assertFalse("Duplicate Gallery row", c.moveToNext()); + return uri; + } + return null; + } + } + + @Test + public void actualGalleryOriginalAndAppOnlySurviveRelaunch() throws Exception { + String headset = "instrumented-" + UUID.randomUUID(); + File root = new File(context.getFilesDir(), "instrumented/" + headset); + Object gate = new Object(); + Bitmap bitmap = Bitmap.createBitmap(48, 32, Bitmap.Config.ARGB_8888); + bitmap.eraseColor(Color.rgb(25, 184, 143)); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes); + bitmap.recycle(); + byte[] png = bytes.toByteArray(); + SyncEngine.Transport transport = + new SyncEngine.Transport() { + public void download(SyncEngine.Shot shot, File target) throws Exception { + Files.write(target.toPath(), png); + } + + public boolean validImage(File f) { + Bitmap b = BitmapFactory.decodeFile(f.getPath()); + if (b == null) return false; + b.recycle(); + return true; + } + }; + GalleryExporter exporter = new GalleryExporter(context, headset, gate); + SyncEngine engine = new SyncEngine(root, true); + engine.reconcile(Collections.emptyList(), transport, exporter); + SyncEngine.Shot shot = + new SyncEngine.Shot("999999999991/108/20261024000000_1.png", png.length, 1792800000); + engine.reconcile(List.of(shot), transport, exporter); + assertNull(row(headset, shot)); + engine.reconcile(List.of(shot), transport, exporter); + Uri saved = row(headset, shot); + assertNotNull(saved); + try (InputStream in = context.getContentResolver().openInputStream(saved)) { + assertArrayEquals(png, read(in)); + } + assertEquals("saved", engine.items().get(0).destination); + engine = new SyncEngine(root, true); + engine.reconcile(List.of(shot), transport, exporter); + assertEquals(saved, row(headset, shot)); + engine.setGallery(false); + SyncEngine.Shot second = + new SyncEngine.Shot("999999999991/108/20261024000001_1.png", png.length, 1792800001); + engine.reconcile(List.of(shot, second), transport, exporter); + engine.reconcile(List.of(shot, second), transport, exporter); + engine.setGallery(true); + engine = new SyncEngine(root, true); + engine.reconcile(List.of(shot, second), transport, exporter); + assertNull(row(headset, second)); + assertTrue( + engine.items().stream() + .anyMatch( + i -> + i.shot.id.equals(second.id) + && i.destination.equals("appOnly") + && i.file.exists())); + } + + @Test + public void interruptedMediaStoreRowIsRewrittenWithoutDuplicate() throws Exception { + String headset = "pending-" + UUID.randomUUID(); + SyncEngine.Shot shot = + new SyncEngine.Shot("999999999992/108/20261024000002_1.png", 4, 1792800002); + ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.DISPLAY_NAME, name(headset, shot)); + values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/Frame Control/"); + values.put(MediaStore.Images.Media.MIME_TYPE, "image/png"); + values.put(MediaStore.Images.Media.IS_PENDING, 1); + Uri pending = + context + .getContentResolver() + .insert( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values); + assertNotNull(pending); + cleanup.add(pending); + File file = File.createTempFile("gallery-proof", ".png", context.getCacheDir()); + byte[] original = {1, 2, 3, 4}; + Files.write(file.toPath(), original); + GalleryExporter exporter = new GalleryExporter(context, headset, new Object()); + assertTrue(exporter.export(shot, file, () -> true)); + assertEquals(pending, row(headset, shot)); + assertTrue(exporter.export(shot, file, () -> true)); + assertEquals(pending, row(headset, shot)); + try (InputStream in = context.getContentResolver().openInputStream(pending)) { + assertArrayEquals(original, read(in)); + } + file.delete(); + } + + @Test + public void galleryOptOutLatchesPreviouslyPairedLibraries() throws Exception { + assertFalse("Instrumentation must own a stopped product", SyncService.running()); + File files = Files.createTempDirectory(context.getCacheDir().toPath(), "libraries-").toFile(); + String preferences = "library-test-" + UUID.randomUUID(); + Context isolated = + new ContextWrapper(context) { + @Override + public File getFilesDir() { + return files; + } + + @Override + public SharedPreferences getSharedPreferences(String name, int mode) { + return context.getSharedPreferences(preferences, mode); + } + }; + try { + SyncEngine.Transport transfer = + new SyncEngine.Transport() { + public void download(SyncEngine.Shot shot, File target) throws Exception { + Files.write(target.toPath(), new byte[] {1, 2, 3, 4}); + } + + public boolean validImage(File file) { + return true; + } + }; + SyncEngine.Exporter unavailable = (shot, file, eligible) -> false; + SyncEngine.Shot shot = new SyncEngine.Shot("123/108/20261024120000_1.png", 4, 100); + File a = SyncService.library(isolated, "headset-a"); + File b = SyncService.library(isolated, "headset-b"); + for (File root : List.of(a, b)) { + SyncEngine engine = new SyncEngine(root, true); + engine.reconcile(Collections.emptyList(), transfer, unavailable); + engine.reconcile(List.of(shot), transfer, unavailable); + engine.reconcile(List.of(shot), transfer, unavailable); + assertEquals("pending", engine.items().get(0).destination); + } + assertTrue( + SyncService.prefs(isolated) + .edit() + .putString("pin", "headset-b") + .putBoolean("gallery", true) + .commit()); + SyncService.gallery(isolated, false); + SyncService.gallery(isolated, true); + for (File root : List.of(a, b)) { + SyncEngine engine = new SyncEngine(root, true); + assertEquals("appOnly", engine.items().get(0).destination); + engine.reconcile( + List.of(shot), + transfer, + (s, f, eligible) -> { + fail("Opted-out items must never be exported after re-pairing"); + return false; + }); + } + } finally { + SyncService.prefs(isolated).edit().clear().commit(); + try (java.util.stream.Stream paths = Files.walk(files.toPath())) { + for (java.nio.file.Path path : + paths.sorted(Comparator.reverseOrder()).collect(java.util.stream.Collectors.toList())) + Files.delete(path); + } + } + } + + @Test + public void changingHeadsetsRebindsShareToTheSelectedOriginal() throws Exception { + assertFalse(SyncService.running()); + String aPin = "switch-a-" + UUID.randomUUID(), bPin = "switch-b-" + UUID.randomUUID(); + File a = SyncService.library(context, aPin), b = SyncService.library(context, bPin); + SharedPreferences prefs = SyncService.prefs(context); + String previousPin = prefs.getString("pin", null); + boolean previousGallery = prefs.getBoolean("gallery", true); + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + AtomicReference shared = new AtomicReference<>(); + Instrumentation.ActivityMonitor monitor = + new Instrumentation.ActivityMonitor() { + @Override + public Instrumentation.ActivityResult onStartActivity(Intent intent) { + if (!Intent.ACTION_CHOOSER.equals(intent.getAction())) return null; + shared.set(intent.getParcelableExtra(Intent.EXTRA_INTENT)); + return new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null); + } + }; + Activity activity = null; + try { + SyncEngine.Shot shot = new SyncEngine.Shot("123/108/20261024120000_1.png", 4, 100); + for (File root : List.of(a, b)) { + byte[] original = root.equals(a) ? new byte[] {1, 2, 3, 4} : new byte[] {5, 6, 7, 8}; + SyncEngine.Transport transfer = + new SyncEngine.Transport() { + public void download(SyncEngine.Shot s, File file) throws Exception { + Files.write(file.toPath(), original); + } + + public boolean validImage(File file) { + return true; + } + }; + SyncEngine engine = new SyncEngine(root, false); + engine.reconcile(Collections.emptyList(), transfer, (s, f, e) -> false); + engine.reconcile(List.of(shot), transfer, (s, f, e) -> false); + engine.reconcile(List.of(shot), transfer, (s, f, e) -> false); + } + assertTrue(prefs.edit().putString("pin", aPin).putBoolean("gallery", false).commit()); + activity = + instrumentation.startActivitySync( + new Intent(context, MainActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)); + instrumentation.addMonitor(monitor); + instrumentation.waitForIdleSync(); + Activity screen = activity; + Runnable share = + () -> { + Button button = shareButton(screen.findViewById(android.R.id.content)); + assertNotNull("The downloaded original must be reachable", button); + assertTrue(button.performClick()); + }; + instrumentation.runOnMainSync(share); + assertNotNull(shared.get()); + Uri first = shared.get().getParcelableExtra(Intent.EXTRA_STREAM); + try (InputStream in = context.getContentResolver().openInputStream(first)) { + assertArrayEquals(new byte[] {1, 2, 3, 4}, read(in)); + } + assertTrue(prefs.edit().putString("pin", bPin).commit()); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); + Uri selected; + do { + instrumentation.runOnMainSync(share); + selected = shared.get().getParcelableExtra(Intent.EXTRA_STREAM); + if (!first.equals(selected)) break; + Thread.sleep(100); + } while (System.nanoTime() < deadline); + assertNotEquals("Share must no longer target the previous headset", first, selected); + try (InputStream in = context.getContentResolver().openInputStream(selected)) { + assertArrayEquals(new byte[] {5, 6, 7, 8}, read(in)); + } + } finally { + instrumentation.removeMonitor(monitor); + Activity screen = activity; + if (screen != null) instrumentation.runOnMainSync(screen::finish); + SharedPreferences.Editor restore = prefs.edit().putBoolean("gallery", previousGallery); + if (previousPin == null) restore.remove("pin"); + else restore.putString("pin", previousPin); + assertTrue(restore.commit()); + for (File root : List.of(a, b)) { + if (!root.exists()) continue; + try (java.util.stream.Stream paths = Files.walk(root.toPath())) { + for (java.nio.file.Path path : + paths.sorted(Comparator.reverseOrder()).collect(java.util.stream.Collectors.toList())) + Files.delete(path); + } + } + } + } + + private static Button shareButton(View view) { + if (view instanceof Button && "Open / share original".contentEquals(((Button) view).getText())) + return (Button) view; + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + Button found = shareButton(group.getChildAt(i)); + if (found != null) return found; + } + } + return null; + } + + /** Optional real-headset provisioning. Only this test APK exposes the hook. */ + @Test + public void provisionRealHeadsetIfRequested() throws Exception { + android.os.Bundle args = InstrumentationRegistry.getArguments(); + Assume.assumeTrue("Optional real-headset provisioning", "true".equals(args.getString("live"))); + String host = args.getString("host"), pin = args.getString("pin"); + assertNotNull(host); + assertNotNull(pin); + assertEquals( + "Probe must return the expected host key before authentication", + pin, + FrameConnection.probe(host, 22, "steamos")); + String publicKey = new PhoneIdentity().authorizedKey(SyncService.install(context)); + Files.write( + new File(context.getFilesDir(), "live-test-public.pub").toPath(), + (publicKey + "\n").getBytes(StandardCharsets.US_ASCII)); + assertTrue( + SyncService.prefs(context) + .edit() + .putString("host", host) + .putString("user", "steamos") + .putInt("port", 22) + .putString("pin", pin) + .commit()); + } + + @Test + public void verifyRealHeadsetIfRequested() throws Exception { + android.os.Bundle args = InstrumentationRegistry.getArguments(); + String id = args.getString("verifyId"); + Assume.assumeNotNull(id); + String pin = SyncService.prefs(context).getString("pin", null); + assertNotNull(pin); + SyncEngine engine = + new SyncEngine( + SyncService.library(context, pin), + SyncService.prefs(context).getBoolean("gallery", true)); + SyncEngine.Item item = + engine.items().stream().filter(i -> i.shot.id.equals(id)).findFirst().orElseThrow(); + assertTrue(item.file.exists()); + String expected = args.getString("sha256"); + assertNotNull(expected); + assertEquals(expected, sha(Files.readAllBytes(item.file.toPath()))); + boolean gallery = "true".equals(args.getString("gallery")); + Uri saved = row(pin, item.shot); + cleanup.clear(); + if (gallery) { + assertEquals("saved", item.destination); + assertNotNull(saved); + try (InputStream in = context.getContentResolver().openInputStream(saved)) { + assertEquals(expected, sha(read(in))); + } + } else { + assertEquals("appOnly", item.destination); + assertNull(saved); + } + } + + private static String sha(byte[] bytes) throws Exception { + StringBuilder out = new StringBuilder(); + for (byte b : java.security.MessageDigest.getInstance("SHA-256").digest(bytes)) + out.append(String.format(java.util.Locale.ROOT, "%02x", b & 255)); + return out.toString(); + } + + private static byte[] read(InputStream input) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] b = new byte[4096]; + int n; + while ((n = input.read(b)) != -1) out.write(b, 0, n); + return out.toByteArray(); + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ff2f977 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/saphid/framecontrol/FrameConnection.java b/android/app/src/main/java/com/saphid/framecontrol/FrameConnection.java new file mode 100644 index 0000000..1895091 --- /dev/null +++ b/android/app/src/main/java/com/saphid/framecontrol/FrameConnection.java @@ -0,0 +1,414 @@ +package com.saphid.framecontrol; + +import android.content.Context; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.jcraft.jsch.*; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.*; +import java.util.regex.*; + +/** All headset traffic goes through one pinned, authenticated SSH connection. */ +final class FrameConnection implements AutoCloseable, SyncEngine.Transport { + private final Context context; + private Session session; + private ChannelExec server; + private PipedOutputStream serverInput; + private int localPort; + private String apiKey; + private static final String HOST_ALGORITHMS = "ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256"; + + FrameConnection(Context context) { + this.context = context.getApplicationContext(); + } + + static String fingerprint(String pin) throws Exception { + return "SHA256:" + + Base64.getEncoder() + .withoutPadding() + .encodeToString( + MessageDigest.getInstance("SHA-256").digest(Base64.getDecoder().decode(pin))); + } + + static String hash(String value) throws Exception { + byte[] bytes = + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(); + for (byte b : bytes) result.append(String.format(Locale.ROOT, "%02x", b & 255)); + return result.toString(); + } + + static String quote(String s) { + return "'" + s.replace("'", "'\\''") + "'"; + } + + private static Session session(JSch jsch, String host, int port, String user) throws Exception { + if (host.trim().isEmpty() + || host.length() > 253 + || host.contains("/") + || host.chars().anyMatch(Character::isWhitespace) + || port < 1 + || port > 65535 + || !user.matches("[a-zA-Z0-9_-]{1,32}")) + throw new IOException("Check the headset address, port and username."); + Session s = jsch.getSession(user, host, port); + s.setConfig("StrictHostKeyChecking", "yes"); + s.setConfig("server_host_key", HOST_ALGORITHMS); + s.setConfig("PreferredAuthentications", "publickey"); + s.setTimeout(20000); + s.setServerAliveInterval(15000); + s.setServerAliveCountMax(2); + return s; + } + + private static final class PinRepository implements HostKeyRepository { + String observed; + final String expected; + + PinRepository(String expected) { + this.expected = expected; + } + + @Override + public int check(String host, byte[] key) { + observed = Base64.getEncoder().encodeToString(key); + return observed.equals(expected) ? OK : CHANGED; + } + + @Override + public void add(HostKey k, UserInfo u) {} + + @Override + public void remove(String h, String t) {} + + @Override + public void remove(String h, String t, byte[] k) {} + + @Override + public String getKnownHostsRepositoryID() { + return "Frame Control pinned headset"; + } + + @Override + public HostKey[] getHostKey() { + return new HostKey[0]; + } + + @Override + public HostKey[] getHostKey(String h, String t) { + return new HostKey[0]; + } + } + + /** Intentionally refuses the key exchange before any authentication is sent. */ + static String probe(String host, int port, String user) throws Exception { + JSch jsch = new JSch(); + PinRepository pin = new PinRepository(null); + jsch.setHostKeyRepository(pin); + Session s = session(jsch, host, port, user); + try { + s.connect(10000); + } catch (JSchException e) { + if (pin.observed == null) throw e; + } finally { + s.disconnect(); + } + return pin.observed; + } + + static void pair(String host, int port, String user, String pin, String password, String install) + throws Exception { + JSch jsch = new JSch(); + jsch.setHostKeyRepository(new PinRepository(pin)); + Session s = session(jsch, host, port, user); + s.setConfig("PreferredAuthentications", "password,keyboard-interactive"); + s.setPassword(password); + s.setUserInfo( + new UserInfo() { + public String getPassphrase() { + return null; + } + + public String getPassword() { + return password; + } + + public boolean promptPassword(String x) { + return true; + } + + public boolean promptPassphrase(String x) { + return false; + } + + public boolean promptYesNo(String x) { + return false; + } + + public void showMessage(String x) {} + }); + try { + s.connect(15000); + String key = new PhoneIdentity().authorizedKey(install); + run( + s, + "umask 077; mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod" + + " 600 ~/.ssh/authorized_keys && (grep -qxF " + + quote(key) + + " ~/.ssh/authorized_keys || printf '%s\\n' " + + quote(key) + + " >> ~/.ssh/authorized_keys)", + 10000); + } finally { + s.disconnect(); + } + // Verify the new key before storing any pairing settings. + JSch verified = new JSch(); + verified.setHostKeyRepository(new PinRepository(pin)); + verified.addIdentity(new PhoneIdentity(), null); + Session check = session(verified, host, port, user); + try { + check.connect(15000); + } finally { + check.disconnect(); + } + } + + static String run(Session s, String command, int timeout) throws Exception { + ChannelExec channel = (ChannelExec) s.openChannel("exec"); + channel.setCommand(command); + channel.setInputStream(null); + channel.setErrStream( + new OutputStream() { + public void write(int b) {} + + public void write(byte[] b, int offset, int length) {} + }); + InputStream input = channel.getInputStream(); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeout); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try { + channel.connect(timeout); + while (true) { + while (input.available() > 0) { + byte[] b = new byte[Math.min(input.available(), 8192)]; + int n = input.read(b); + if (n > 0) output.write(b, 0, n); + if (output.size() > 2 * 1024 * 1024) + throw new IOException("Headset command returned too much data."); + } + if (channel.isClosed() && input.available() == 0) break; + if (System.nanoTime() > deadline) throw new IOException("The headset command timed out."); + Thread.sleep(25); + } + if (channel.getExitStatus() != 0) + throw new IOException("The headset command failed (" + channel.getExitStatus() + ")."); + return output.toString(StandardCharsets.UTF_8.name()); + } finally { + channel.disconnect(); + } + } + + void connect(String host, int port, String user, String pin, String install) throws Exception { + JSch jsch = new JSch(); + jsch.setHostKeyRepository(new PinRepository(pin)); + jsch.addIdentity(new PhoneIdentity(), null); + session = session(jsch, host, port, user); + session.connect(15000); + String version; + try (InputStream in = context.getAssets().open("frame-bundle.version")) { + version = new String(readBounded(in, 128), StandardCharsets.US_ASCII).trim(); + } + if (!version.matches("[0-9a-f]{16}")) throw new IOException("Invalid bundled server version."); + String dir = ".cache/frame-control/" + version; + String present = + run(session, "test -f " + dir + "/ui/server.py && printf ready || true", 10000); + if (!present.equals("ready")) { + run(session, "mkdir -p .cache/frame-control", 10000); + String temp = dir + ".android-" + UUID.randomUUID(); + ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp"); + try (InputStream in = context.getAssets().open("frame-bundle.tar.gz")) { + sftp.connect(10000); + sftp.put(in, temp + ".tar.gz"); + } finally { + sftp.disconnect(); + } + run( + session, + "mkdir " + + temp + + " && tar xzf " + + temp + + ".tar.gz -C " + + temp + + " && " + + "(test -f " + + dir + + "/ui/server.py && rm -rf " + + temp + + " || mv " + + temp + + " " + + dir + + ") && rm -f " + + temp + + ".tar.gz", + 60000); + } + apiKey = UUID.randomUUID().toString().replace("-", ""); + server = (ChannelExec) session.openChannel("exec"); + serverInput = new PipedOutputStream(); + server.setInputStream(new PipedInputStream(serverInput)); + server.setCommand( + "cd " + + dir + + " && FRAME_LOCAL=1 FRAME_UI_KEY=" + + apiKey + + " FRAME_DEVICE=" + + quote(host) + + " FRAME_CLIENT=" + + quote(install) + + " exec python3 -I -u -B \"$PWD/ui/server.py\" --port 0 --exit-on-eof 2>&1"); + InputStream input = server.getInputStream(); + server.connect(10000); + ByteArrayOutputStream startup = new ByteArrayOutputStream(); + Pattern portPattern = Pattern.compile("Frame Control on http://127\\.0\\.0\\.1:([0-9]+)\\s"); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(40); + int portFound = 0; + while (System.nanoTime() < deadline) { + while (input.available() > 0) { + startup.write(input.read()); + if (startup.size() > 65536) throw new IOException("The headset server did not start."); + } + Matcher m = portPattern.matcher(startup.toString(StandardCharsets.UTF_8.name())); + if (m.find()) { + portFound = Integer.parseInt(m.group(1)); + break; + } + if (server.isClosed()) + throw new IOException("The headset server stopped before it was ready."); + Thread.sleep(50); + } + if (portFound < 1 || portFound > 65535) + throw new IOException("The headset server did not answer in time."); + localPort = session.setPortForwardingL("127.0.0.1", 0, "127.0.0.1", portFound); + // Drain the session-bound server output; never log session keys or user data. + Thread drain = + new Thread( + () -> { + try { + byte[] b = new byte[4096]; + while (input.read(b) != -1) {} + } catch (IOException ignored) { + } + }, + "frame-server-output"); + drain.setDaemon(true); + drain.start(); + } + + String page() { + return "http://127.0.0.1:" + localPort + "/?key=" + apiKey; + } + + boolean connected() { + return session != null && session.isConnected() && server != null && !server.isClosed(); + } + + private HttpURLConnection request(String path) throws Exception { + HttpURLConnection c = + (HttpURLConnection) new URL("http://127.0.0.1:" + localPort + path).openConnection(); + c.setConnectTimeout(10000); + c.setReadTimeout(20000); + c.setInstanceFollowRedirects(false); + c.setRequestProperty("X-Frame-UI", apiKey); + return c; + } + + List shots() throws Exception { + HttpURLConnection c = request("/api/shots"); + try (InputStream in = c.getInputStream()) { + byte[] body = readBounded(in, 2 * 1024 * 1024); + JsonArray array = + JsonParser.parseString(new String(body, StandardCharsets.UTF_8)) + .getAsJsonObject() + .getAsJsonArray("shots"); + List shots = new ArrayList<>(); + for (JsonElement e : array) { + var s = e.getAsJsonObject(); + shots.add( + new SyncEngine.Shot( + s.get("id").getAsString(), s.get("size").getAsLong(), s.get("time").getAsDouble())); + } + return shots; + } finally { + c.disconnect(); + } + } + + private static byte[] readBounded(InputStream input, int limit) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] b = new byte[8192]; + int n; + while ((n = input.read(b)) != -1) { + if (out.size() + n > limit) throw new IOException("The headset response is too large."); + out.write(b, 0, n); + } + return out.toByteArray(); + } + + @Override + public void download(SyncEngine.Shot shot, File destination) throws Exception { + HttpURLConnection c = + request("/api/shots/image?id=" + URLEncoder.encode(shot.id, StandardCharsets.UTF_8.name())); + try (InputStream in = c.getInputStream(); + FileOutputStream out = new FileOutputStream(destination)) { + byte[] b = new byte[65536]; + long count = 0; + int n; + while ((n = in.read(b)) != -1) { + count += n; + if (count > shot.size) throw new IOException("Screenshot changed while downloading."); + out.write(b, 0, n); + } + out.getFD().sync(); + } finally { + c.disconnect(); + } + } + + @Override + public boolean validImage(File file) { + android.graphics.BitmapFactory.Options options = new android.graphics.BitmapFactory.Options(); + options.inJustDecodeBounds = true; + android.graphics.BitmapFactory.decodeFile(file.getPath(), options); + if (options.outWidth <= 0 + || options.outHeight <= 0 + || options.outWidth > 16384 + || options.outHeight > 16384 + || (long) options.outWidth * options.outHeight > 60_000_000) return false; + options.inJustDecodeBounds = false; + options.inSampleSize = Math.max(1, Math.max(options.outWidth, options.outHeight) / 2048); + android.graphics.Bitmap b = android.graphics.BitmapFactory.decodeFile(file.getPath(), options); + if (b == null) return false; + b.recycle(); + return true; + } + + @Override + public void close() { + try { + if (serverInput != null) serverInput.close(); + } catch (IOException ignored) { + } + if (server != null) server.disconnect(); + if (session != null) session.disconnect(); + } +} diff --git a/android/app/src/main/java/com/saphid/framecontrol/GalleryExporter.java b/android/app/src/main/java/com/saphid/framecontrol/GalleryExporter.java new file mode 100644 index 0000000..b3d6e0f --- /dev/null +++ b/android/app/src/main/java/com/saphid/framecontrol/GalleryExporter.java @@ -0,0 +1,96 @@ +package com.saphid.framecontrol; + +import android.content.*; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore; +import java.io.*; +import java.util.function.BooleanSupplier; + +/** Deterministic, app-owned MediaStore rows make interrupted exports retryable. */ +final class GalleryExporter implements SyncEngine.Exporter { + private final Context context; + private final String headset; + private final Object settingsLock; + + GalleryExporter(Context context, String headset, Object settingsLock) { + this.context = context.getApplicationContext(); + this.headset = headset; + this.settingsLock = settingsLock; + } + + @Override + public boolean export(SyncEngine.Shot shot, File file, BooleanSupplier eligible) + throws Exception { + String name = shot.id.substring(shot.id.lastIndexOf('/') + 1); + int dot = name.lastIndexOf('.'); + name = + name.substring(0, dot) + + "_" + + FrameConnection.hash(headset + "/" + shot.id).substring(0, 20) + + name.substring(dot); + String folder = "Pictures/Frame Control/"; + ContentResolver resolver = context.getContentResolver(); + Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + // API 29 excludes even app-owned pending rows unless explicitly included. + @SuppressWarnings("deprecation") + Uri recoveryCollection = MediaStore.setIncludePending(collection); + Uri row = null; + boolean pending = true; + try (Cursor cursor = + resolver.query( + recoveryCollection, + new String[] {"_id", MediaStore.Images.Media.IS_PENDING}, + MediaStore.Images.Media.DISPLAY_NAME + + "=? AND " + + MediaStore.Images.Media.RELATIVE_PATH + + "=? AND " + + MediaStore.Images.Media.OWNER_PACKAGE_NAME + + "=?", + new String[] {name, folder, context.getPackageName()}, + null)) { + if (cursor != null && cursor.moveToFirst()) { + row = ContentUris.withAppendedId(collection, cursor.getLong(0)); + pending = cursor.getInt(1) != 0; + } + } + // A completed row is the commit receipt, including a crash before JSON save. + if (row != null && !pending) return true; + if (!eligible.getAsBoolean()) return false; + if (row == null) { + ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.DISPLAY_NAME, name); + values.put(MediaStore.Images.Media.RELATIVE_PATH, folder); + values.put( + MediaStore.Images.Media.MIME_TYPE, shot.id.endsWith(".png") ? "image/png" : "image/jpeg"); + values.put(MediaStore.Images.Media.IS_PENDING, 1); + values.put(MediaStore.Images.Media.DATE_TAKEN, (long) (shot.time * 1000)); + row = resolver.insert(collection, values); + if (row == null) throw new IOException("Gallery could not create the screenshot."); + } + try { + try (InputStream in = new FileInputStream(file); + OutputStream out = resolver.openOutputStream(row, "wt")) { + if (out == null) throw new IOException("Gallery could not open the screenshot."); + byte[] b = new byte[65536]; + int n; + while ((n = in.read(b)) != -1) out.write(b, 0, n); + } + synchronized (settingsLock) { + if (!eligible.getAsBoolean()) { + resolver.delete(row, null, null); + return false; + } + ContentValues done = new ContentValues(); + done.put(MediaStore.Images.Media.IS_PENDING, 0); + if (resolver.update(row, done, null, null) != 1) + throw new IOException("Gallery could not finish saving the screenshot."); + } + return true; + } catch (Exception e) { + // A pending row is retained and rewritten on retry. A completed row is + // discovered before any future insert, even after process death. + throw e; + } + } +} diff --git a/android/app/src/main/java/com/saphid/framecontrol/MainActivity.java b/android/app/src/main/java/com/saphid/framecontrol/MainActivity.java new file mode 100644 index 0000000..aa7a893 --- /dev/null +++ b/android/app/src/main/java/com/saphid/framecontrol/MainActivity.java @@ -0,0 +1,512 @@ +package com.saphid.framecontrol; + +import android.Manifest; +import android.app.*; +import android.content.*; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.net.Uri; +import android.os.*; +import android.text.InputType; +import android.view.*; +import android.webkit.*; +import android.widget.*; +import androidx.core.content.FileProvider; +import java.util.*; +import java.util.concurrent.*; + +public final class MainActivity extends Activity { + private final ExecutorService network = Executors.newSingleThreadExecutor(); + private final Handler handler = new Handler(Looper.getMainLooper()); + private LinearLayout root, downloads; + private FrameLayout content; + private ScrollView companion; + private EditText host, port, user; + private TextView status, empty; + private Switch gallery; + private Button pair, connect, stop; + private WebView web; + private String loadedPage, librarySignature = ""; + private int downloadLimit = 100; + private boolean busy, visible, controls; + private ValueCallback fileSelection; + private static final int INK = Color.rgb(232, 240, 238), + MUTED = Color.rgb(164, 183, 179), + ACCENT = Color.rgb(124, 216, 195); + private final Runnable refresh = + new Runnable() { + public void run() { + if (!visible) return; + update(); + handler.postDelayed(this, 1500); + } + }; + + private int dp(int n) { + return Math.round(getResources().getDisplayMetrics().density * n); + } + + private TextView text(String value, int size, int color) { + TextView v = new TextView(this); + v.setText(value); + v.setTextSize(size); + v.setTextColor(color); + v.setPadding(0, dp(6), 0, dp(6)); + return v; + } + + private Button button(String label, Runnable action) { + Button b = new Button(this); + b.setText(label); + b.setAllCaps(false); + b.setOnClickListener(v -> action.run()); + return b; + } + + private EditText field(String label, String value, int type) { + companionBody.addView(text(label, 13, MUTED)); + EditText v = new EditText(this); + v.setSingleLine(true); + v.setText(value); + v.setHint(label); + v.setContentDescription(label); + v.setInputType(type); + v.setTextColor(INK); + v.setSaveEnabled(false); + companionBody.addView(v); + return v; + } + + private LinearLayout companionBody; + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(Color.rgb(17, 24, 25)); + root.setPadding(dp(20), dp(12), dp(20), dp(12)); + root.setOnApplyWindowInsetsListener( + (v, insets) -> { + if (Build.VERSION.SDK_INT >= 30) { + android.graphics.Insets i = + insets.getInsets( + WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()); + v.setPadding(dp(20) + i.left, dp(12) + i.top, dp(20) + i.right, dp(12) + i.bottom); + } else + v.setPadding( + dp(20) + insets.getSystemWindowInsetLeft(), + dp(12) + insets.getSystemWindowInsetTop(), + dp(20) + insets.getSystemWindowInsetRight(), + dp(12) + insets.getSystemWindowInsetBottom()); + return insets; + }); + root.addView(text("FRAME CONTROL", 13, ACCENT)); + root.addView(text("Your headset, closer.", 27, INK)); + status = text(SyncService.state(), 14, MUTED); + status.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); + root.addView(status); + LinearLayout tabs = new LinearLayout(this); + tabs.addView( + button("Screenshots", () -> show(false)), new LinearLayout.LayoutParams(0, dp(48), 1)); + tabs.addView( + button("Headset controls", () -> show(true)), new LinearLayout.LayoutParams(0, dp(48), 1)); + root.addView(tabs); + content = new FrameLayout(this); + root.addView(content, new LinearLayout.LayoutParams(-1, 0, 1)); + companion = new ScrollView(this); + companion.setFillViewport(true); + companionBody = new LinearLayout(this); + companionBody.setOrientation(LinearLayout.VERTICAL); + companion.addView(companionBody); + SharedPreferences p = SyncService.prefs(this); + companionBody.addView(text("Connect your Steam Frame", 20, INK)); + companionBody.addView( + text( + "Enable Developer Mode and connect to the same network, or use your headset’s Tailscale" + + " address.", + 14, + MUTED)); + host = + field( + "Headset address", + p.getString("host", "frame.local"), + InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + port = field("SSH port", Integer.toString(p.getInt("port", 22)), InputType.TYPE_CLASS_NUMBER); + user = + field( + "SSH username", + p.getString("user", "steamos"), + InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD); + pair = button("Pair headset", this::probe); + companionBody.addView(pair); + LinearLayout actions = new LinearLayout(this); + connect = button("Start syncing", this::startSync); + stop = button("Stop", () -> stopService(new Intent(this, SyncService.class))); + actions.addView(connect, new LinearLayout.LayoutParams(0, dp(52), 1)); + actions.addView(stop, new LinearLayout.LayoutParams(0, dp(52), 1)); + companionBody.addView(actions); + companionBody.addView(text("Screenshot downloads", 20, INK)); + gallery = new Switch(this); + gallery.setText("Save screenshots to Gallery"); + gallery.setTextColor(INK); + gallery.setTextSize(16); + gallery.setMinHeight(dp(56)); + gallery.setChecked(p.getBoolean("gallery", true)); + gallery.setOnCheckedChangeListener( + (b, value) -> { + try { + SyncService.gallery(this, value); + librarySignature = ""; + } catch (Exception e) { + gallery.setOnCheckedChangeListener(null); + gallery.setChecked(!value); + error(SyncService.message(e)); + recreate(); + } + }); + companionBody.addView(gallery); + companionBody.addView( + text( + "New captures download automatically while sync is running. A notification keeps the" + + " connection visible. Stop syncing to disconnect. Android may delay transfers in" + + " battery-saving modes.", + 14, + MUTED)); + companionBody.addView( + text( + "Turning Gallery off keeps new downloads in this app. Turning it on again won’t export" + + " earlier app-only downloads.", + 14, + MUTED)); + empty = + text( + "Pair and start syncing. Existing headset screenshots stay on the headset; new captures" + + " will appear here.", + 14, + MUTED); + companionBody.addView(empty); + downloads = new LinearLayout(this); + downloads.setOrientation(LinearLayout.VERTICAL); + companionBody.addView(downloads); + show(false); + setContentView(root); + } + + private void show(boolean useControls) { + controls = useControls; + content.removeAllViews(); + if (!controls) { + content.addView(companion, new FrameLayout.LayoutParams(-1, -1)); + return; + } + String page = SyncService.page(); + if (page == null) { + loadedPage = null; + if (web != null) web.loadUrl("about:blank"); + content.addView(text("Start syncing to open your headset’s controls.", 17, MUTED)); + return; + } + if (web == null) { + web = new WebView(this); + web.setBackgroundColor(Color.rgb(17, 24, 25)); + WebSettings settings = web.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + settings.setSaveFormData(false); + web.setWebViewClient( + new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + Uri url = request.getUrl(); + Uri origin = Uri.parse(loadedPage == null ? "" : loadedPage); + if ("http".equals(url.getScheme()) + && "127.0.0.1".equals(url.getHost()) + && url.getPort() == origin.getPort()) return false; + if (request.hasGesture() + && ("https".equals(url.getScheme()) || "http".equals(url.getScheme()))) { + try { + startActivity(new Intent(Intent.ACTION_VIEW, url)); + } catch (ActivityNotFoundException ignored) { + } + } + return true; + } + }); + web.setWebChromeClient( + new WebChromeClient() { + @Override + public boolean onShowFileChooser( + WebView w, ValueCallback callback, FileChooserParams params) { + if (fileSelection != null) fileSelection.onReceiveValue(null); + fileSelection = callback; + Intent choose = + new Intent(Intent.ACTION_OPEN_DOCUMENT) + .setType("*/*") + .addCategory(Intent.CATEGORY_OPENABLE); + choose.putExtra( + Intent.EXTRA_ALLOW_MULTIPLE, + params.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE); + try { + startActivityForResult(choose, 110); + } catch (ActivityNotFoundException e) { + fileSelection.onReceiveValue(null); + fileSelection = null; + return false; + } + return true; + } + }); + web.setDownloadListener( + (url, ua, disposition, mime, length) -> { + show(false); + Toast.makeText( + this, + "New screenshot originals are in Screenshot downloads.", + Toast.LENGTH_LONG) + .show(); + }); + } + content.addView(web, new FrameLayout.LayoutParams(-1, -1)); + if (!page.equals(loadedPage)) { + loadedPage = page; + web.loadUrl(page); + } + } + + @Override + protected void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + if (request == 110 && fileSelection != null) { + fileSelection.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(result, data)); + fileSelection = null; + } + } + + private void update() { + if (!busy) status.setText(SyncService.state()); + boolean paired = SyncService.prefs(this).contains("pin"); + boolean running = SyncService.running(); + connect.setEnabled(paired && !busy && !running); + pair.setEnabled(!busy && !running); + stop.setEnabled(!busy && running); + host.setEnabled(!running && !busy); + port.setEnabled(!running && !busy); + user.setEnabled(!running && !busy); + if (controls && !Objects.equals(loadedPage, SyncService.page())) show(true); + try { + List items = SyncService.items(this); + StringBuilder signature = new StringBuilder(); + for (var item : items) + signature + .append(item.file.getAbsolutePath()) + .append('|') + .append(item.destination) + .append('|') + .append(item.file.exists()) + .append('\n'); + if (signature.toString().equals(librarySignature)) return; + librarySignature = signature.toString(); + downloads.removeAllViews(); + empty.setVisibility(items.isEmpty() ? View.VISIBLE : View.GONE); + int shown = 0; + for (SyncEngine.Item item : items) { + if (shown++ >= downloadLimit) { + downloads.addView( + button( + "Show 100 more downloads", + () -> { + downloadLimit += 100; + librarySignature = ""; + update(); + })); + break; + } + String label = item.shot.id.substring(item.shot.id.lastIndexOf('/') + 1); + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.VERTICAL); + card.setPadding(0, dp(12), 0, dp(12)); + card.addView(text(label, 16, INK)); + String where = + item.destination.equals("saved") + ? "Saved to Gallery" + : item.destination.equals("appOnly") ? "In Frame Control" : "Gallery save pending"; + if (!item.file.exists()) where = "Download pending"; + card.addView(text(where + " · Game " + item.shot.id.split("/")[1], 13, MUTED)); + if (item.file.exists()) card.addView(button("Open / share original", () -> share(item))); + downloads.addView(card); + } + } catch (Exception e) { + empty.setVisibility(View.VISIBLE); + empty.setText("Could not read screenshot downloads: " + SyncService.message(e)); + } + } + + private void share(SyncEngine.Item item) { + Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".files", item.file); + Intent send = + new Intent(Intent.ACTION_SEND) + .setType(item.shot.id.endsWith(".png") ? "image/png" : "image/jpeg") + .putExtra(Intent.EXTRA_STREAM, uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(send, "Screenshot original")); + } + + private void startSync() { + if (Build.VERSION.SDK_INT >= 33 + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[] {Manifest.permission.POST_NOTIFICATIONS}, 108); + return; + } + startForegroundService(new Intent(this, SyncService.class)); + } + + @Override + public void onRequestPermissionsResult(int request, String[] permissions, int[] grants) { + super.onRequestPermissionsResult(request, permissions, grants); + if (request == 108) { + if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) + startForegroundService(new Intent(this, SyncService.class)); + else + error( + "Allow notifications in Android Settings to start a visible screenshot sync session."); + } + } + + private void probe() { + final String address = host.getText().toString().trim(), + username = user.getText().toString().trim(); + final int sshPort; + try { + sshPort = Integer.parseInt(port.getText().toString()); + } catch (NumberFormatException e) { + error("Enter an SSH port from 1 to 65535."); + return; + } + stopService(new Intent(this, SyncService.class)); + busy = true; + pair.setEnabled(false); + status.setText("Checking headset identity…"); + network.execute( + () -> { + try { + String pin = FrameConnection.probe(address, sshPort, username); + String fingerprint = FrameConnection.fingerprint(pin); + runOnUiThread( + () -> { + if (isFinishing() || isDestroyed()) return; + busy = false; + new AlertDialog.Builder(this) + .setTitle("Trust this headset?") + .setMessage( + address + + ":" + + sshPort + + "\n\n" + + fingerprint + + "\n\n" + + "Compare this SSH fingerprint with your headset before entering its" + + " Developer Mode password.") + .setNegativeButton("Cancel", null) + .setPositiveButton( + "Trust and pair", (d, w) -> password(address, sshPort, username, pin)) + .show(); + }); + } catch (Exception e) { + runOnUiThread( + () -> { + busy = false; + error(SyncService.message(e)); + }); + } + }); + } + + private void password(String address, int sshPort, String username, String pin) { + EditText password = new EditText(this); + password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + password.setHint("Developer Mode password"); + password.setSaveEnabled(false); + new AlertDialog.Builder(this) + .setTitle("Pair with " + address) + .setMessage( + "Used once to authorize this phone’s protected SSH key. The password is not saved.") + .setView(password) + .setNegativeButton("Cancel", (d, w) -> password.setText("")) + .setPositiveButton( + "Pair", + (d, w) -> { + String secret = password.getText().toString(); + password.setText(""); + busy = true; + status.setText("Pairing this phone…"); + network.execute( + () -> { + try { + FrameConnection.pair( + address, sshPort, username, pin, secret, SyncService.install(this)); + if (!SyncService.prefs(this) + .edit() + .putString("host", address) + .putInt("port", sshPort) + .putString("user", username) + .putString("pin", pin) + .commit()) throw new java.io.IOException("Could not save pairing."); + runOnUiThread( + () -> { + busy = false; + status.setText("Paired. Start syncing when you’re ready."); + update(); + }); + } catch (Exception e) { + runOnUiThread( + () -> { + busy = false; + error(SyncService.message(e)); + }); + } + }); + }) + .show(); + } + + private void error(String message) { + if (isFinishing() || isDestroyed()) return; + new AlertDialog.Builder(this) + .setTitle("Frame Control") + .setMessage(message) + .setPositiveButton("OK", null) + .show(); + } + + @Override + protected void onResume() { + super.onResume(); + visible = true; + if (web != null) web.onResume(); + handler.post(refresh); + } + + @Override + protected void onPause() { + visible = false; + handler.removeCallbacks(refresh); + if (web != null) web.onPause(); + super.onPause(); + } + + @Override + protected void onDestroy() { + network.shutdownNow(); + if (fileSelection != null) fileSelection.onReceiveValue(null); + if (web != null) { + web.destroy(); + web = null; + } + super.onDestroy(); + } +} diff --git a/android/app/src/main/java/com/saphid/framecontrol/PhoneIdentity.java b/android/app/src/main/java/com/saphid/framecontrol/PhoneIdentity.java new file mode 100644 index 0000000..9062d66 --- /dev/null +++ b/android/app/src/main/java/com/saphid/framecontrol/PhoneIdentity.java @@ -0,0 +1,115 @@ +package com.saphid.framecontrol; + +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import com.jcraft.jsch.Identity; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; + +/** The SSH private key is a non-exportable Android Keystore signing key. */ +final class PhoneIdentity implements Identity { + private static final String ALIAS = "frame-control-ssh-v1"; + private final PrivateKey key; + private final byte[] publicBlob; + + PhoneIdentity() throws Exception { + KeyStore store = KeyStore.getInstance("AndroidKeyStore"); + store.load(null); + if (!store.containsAlias(ALIAS)) { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", "AndroidKeyStore"); + generator.initialize( + new KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_SIGN) + .setKeySize(3072) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + .build()); + generator.generateKeyPair(); + } + key = (PrivateKey) store.getKey(ALIAS, null); + RSAPublicKey publicKey = (RSAPublicKey) store.getCertificate(ALIAS).getPublicKey(); + publicBlob = + blob( + "ssh-rsa".getBytes(StandardCharsets.US_ASCII), + integer(publicKey.getPublicExponent()), + integer(publicKey.getModulus())); + } + + private static byte[] integer(BigInteger value) { + return value.toByteArray(); + } + + private static byte[] blob(byte[]... strings) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + for (byte[] s : strings) { + out.writeInt(s.length); + out.write(s); + } + return bytes.toByteArray(); + } + + String authorizedKey(String install) { + return "ssh-rsa " + + Base64.getEncoder().encodeToString(publicBlob) + + " frame-control-android-" + + install; + } + + @Override + public byte[] getPublicKeyBlob() { + return publicBlob.clone(); + } + + @Override + public byte[] getSignature(byte[] data) { + return getSignature(data, "rsa-sha2-512"); + } + + @Override + public byte[] getSignature(byte[] data, String algorithm) { + try { + if (!algorithm.equals("rsa-sha2-256") && !algorithm.equals("rsa-sha2-512")) return null; + Signature signer = + Signature.getInstance( + algorithm.equals("rsa-sha2-512") ? "SHA512withRSA" : "SHA256withRSA"); + signer.initSign(key); + signer.update(data); + return blob(algorithm.getBytes(StandardCharsets.US_ASCII), signer.sign()); + } catch (Exception e) { + return null; + } + } + + @Override + public boolean setPassphrase(byte[] ignored) { + return true; + } + + @Override + public String getAlgName() { + return "ssh-rsa"; + } + + @Override + public String getName() { + return ALIAS; + } + + @Override + public boolean isEncrypted() { + return false; + } + + @Override + public void clear() { + /* Keystore owns the key; disconnect never deletes it. */ + } +} diff --git a/android/app/src/main/java/com/saphid/framecontrol/SyncEngine.java b/android/app/src/main/java/com/saphid/framecontrol/SyncEngine.java new file mode 100644 index 0000000..fd03669 --- /dev/null +++ b/android/app/src/main/java/com/saphid/framecontrol/SyncEngine.java @@ -0,0 +1,570 @@ +package com.saphid.framecontrol; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.Strictness; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BooleanSupplier; +import java.util.regex.Pattern; + +/** One reusable engine per root. The caller supplies an account/headset-specific root. */ +public final class SyncEngine { + private static final long MAX_SIZE = 30L * 1024 * 1024; + private static final Pattern ID = Pattern.compile( + "^[0-9]{1,12}/[0-9]{1,20}/[0-9]{14}_[0-9]{1,4}\\.(jpg|png)$"); + private static final Gson GSON = new GsonBuilder().registerTypeAdapter(Shot.class, + new TypeAdapter() { + @Override public void write(JsonWriter writer, Shot shot) throws IOException { + writer.beginObject(); + writer.name("id").value(shot.id); + writer.name("size").value(shot.size); + writer.name("time").value(shot.time); + writer.endObject(); + } + + @Override public Shot read(JsonReader reader) throws IOException { + String id = null; + Long size = null; + Double time = null; + Set fields = new HashSet<>(); + reader.beginObject(); + while (reader.hasNext()) { + String field = reader.nextName(); + if (!fields.add(field)) throw new IOException("Duplicate screenshot field"); + switch (field) { + case "id": + if (reader.peek() != JsonToken.STRING) throw new IOException("Invalid id"); + id = reader.nextString(); + break; + case "size": + if (reader.peek() != JsonToken.NUMBER) throw new IOException("Invalid size"); + size = reader.nextLong(); + break; + case "time": + if (reader.peek() != JsonToken.NUMBER) throw new IOException("Invalid time"); + time = reader.nextDouble(); + break; + default: throw new IOException("Unknown screenshot field"); + } + } + reader.endObject(); + if (id == null || size == null || time == null) throw new IOException("Incomplete shot"); + return new Shot(id, size, time); + } + }).create(); + + public static final class Shot { + public final String id; + public final long size; + public final double time; + + public Shot(String id, long size, double time) { + this.id = id; + this.size = size; + this.time = time; + } + } + + public interface Transport { + void download(Shot shot, File destination) throws Exception; + boolean validImage(File f) throws Exception; + } + + public interface Exporter { + boolean export(Shot shot, File original, BooleanSupplier eligible) throws Exception; + } + + public static final class Item { + public final Shot shot; + /** Future original path; may not exist while the shot is observing or queued. */ + public final File file; + public final String destination; + + private Item(Shot shot, File file, String destination) { + this.shot = shot; + this.file = file; + this.destination = destination; + } + } + + // Boxed fields make missing journal fields distinguishable from legitimate zero values. + private static final class Ledger { + Integer version; + Boolean initialized; + Boolean gallery; + Map records; + } + + private static final class Receipt { + Shot shot; + String state; + Integer observations; + String destination; + + Receipt(Shot shot, String state, int observations, String destination) { + this.shot = shot; + this.state = state; + this.observations = observations; + this.destination = destination; + } + } + + /** Only external callbacks and rejected transfer contents are safe to retry per item. */ + private static final class ItemFailure extends IOException { + private static final long serialVersionUID = 1L; + + ItemFailure(String message, Exception cause) { + super(message, cause); + } + } + + private final Object lock = new Object(); + private final Path root; + private final Path originals; + private final Path journal; + private Ledger ledger; + private long generation; + private long active; + + public SyncEngine(File root, boolean gallery) throws Exception { + if (root == null || Files.isSymbolicLink(root.toPath())) { + throw new IOException("Unsafe sync root"); + } + this.root = root.getCanonicalFile().toPath(); + List missingDirectories = new ArrayList<>(); + for (Path path = this.root; path != null && !Files.exists(path); path = path.getParent()) { + missingDirectories.add(path); + } + Files.createDirectories(this.root); + for (Path directory : missingDirectories) syncDirectory(directory.getParent()); + originals = this.root.resolve("originals"); + journal = this.root.resolve("receipts.json"); + checkPath(originals); + checkPath(journal); + if (Files.exists(originals, LinkOption.NOFOLLOW_LINKS) + && !Files.isDirectory(originals, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid originals directory"); + } + if (Files.exists(journal, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(journal, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid sync journal"); + } + // Never recover a damaged or incomplete journal by establishing another baseline. + try (JsonReader reader = new JsonReader(new InputStreamReader( + Files.newInputStream(journal), StandardCharsets.UTF_8))) { + reader.setStrictness(Strictness.STRICT); + ledger = GSON.fromJson(reader, Ledger.class); + if (reader.peek() != JsonToken.END_DOCUMENT) { + throw new IOException("Trailing sync journal data"); + } + } catch (RuntimeException e) { + throw new IOException("Corrupt sync journal", e); + } + validateLedger(); + setGallery(gallery); + } else { + if (Files.exists(originals, LinkOption.NOFOLLOW_LINKS)) { + try (java.util.stream.Stream paths = Files.walk(originals)) { + if (paths.anyMatch(p -> !Files.isDirectory(p, LinkOption.NOFOLLOW_LINKS))) { + throw new IOException("Originals exist without a sync journal"); + } + } + } + ledger = emptyLedger(); + ledger.gallery = gallery; + persist(ledger); + } + } + + public void setGallery(boolean enabled) throws Exception { + synchronized (lock) { + if (ledger.gallery == enabled) return; + Ledger next = copy(); + next.gallery = enabled; + if (!enabled) { + for (Receipt receipt : next.records.values()) { + if ("pending".equals(receipt.destination)) receipt.destination = "appOnly"; + } + } + commit(next); + } + } + + /** A second live reconcile is rejected. stop() cancels its generation and permits a new run. */ + public void reconcile(List shots, Transport transport, Exporter exporter) throws Exception { + final long run; + synchronized (lock) { + if (active != 0) throw new IllegalStateException("Sync already in progress"); + run = ++generation; + active = run; + } + try { + Map listing = listing(shots); + synchronized (lock) { + if (active != run) return; + Ledger next = copy(); + if (!next.initialized) { + for (Shot shot : listing.values()) { + next.records.put(shot.id, new Receipt(shot, "baseline", 0, null)); + } + next.initialized = true; + commit(next); + return; + } + // Missing observations break consecutiveness, including across restarts. + for (Receipt receipt : next.records.values()) { + if (!listing.containsKey(receipt.shot.id) + && ("observing".equals(receipt.state) || "queued".equals(receipt.state))) { + receipt.observations = 0; + } + } + for (Shot shot : listing.values()) { + Receipt receipt = next.records.get(shot.id); + if (receipt == null) { + // Latch eligibility at discovery, before waiting for a stable second sample. + next.records.put(shot.id, new Receipt(shot, "observing", 1, + next.gallery ? "pending" : "appOnly")); + } else if ("observing".equals(receipt.state) || "queued".equals(receipt.state)) { + receipt.observations = same(receipt.shot, shot) + ? Math.min(2, receipt.observations + 1) : 1; + receipt.shot = shot; + if (receipt.observations == 2 && "observing".equals(receipt.state)) { + receipt.state = "queued"; + } + } + } + commit(next); // Queue and its disposition reach disk before any download. + } + + List ids; + synchronized (lock) { + if (active != run) return; + ids = new ArrayList<>(ledger.records.keySet()); + } + ItemFailure failure = null; + for (String id : ids) { + Shot download = null; + synchronized (lock) { + if (active != run) return; + Receipt receipt = ledger.records.get(id); + if ("queued".equals(receipt.state) && receipt.observations == 2) { + download = receipt.shot; + } + } + try { + if (download != null) download(run, download, transport); + export(run, id, exporter); + } catch (ItemFailure e) { + if (failure == null) failure = e; + else failure.addSuppressed(e); + } + } + // Surface retryable errors to the service after independent items have progressed. + // Untagged local I/O/journal failures escape immediately and halt the run. + if (failure != null) throw failure; + } catch (Exception e) { + synchronized (lock) { + if (active != run) return; // A late failed callback belongs to a cancelled run. + } + throw e; + } finally { + synchronized (lock) { + if (active == run) active = 0; + } + } + } + + /** Snapshot of all post-baseline receipts, including downloads still pending. */ + public List items() { + synchronized (lock) { + List result = new ArrayList<>(); + for (Receipt receipt : ledger.records.values()) { + if (!"baseline".equals(receipt.state)) { + result.add(new Item(receipt.shot, originals.resolve(receipt.shot.id).toFile(), + receipt.destination)); + } + } + result.sort(Comparator.comparingDouble((Item item) -> item.shot.time).reversed() + .thenComparing(item -> item.shot.id)); + return Collections.unmodifiableList(result); + } + } + + public void stop() { + synchronized (lock) { + ++generation; + active = 0; + } + } + + private void download(long run, Shot shot, Transport transport) throws Exception { + Path temporary; + synchronized (lock) { + if (active != run) return; + temporary = Files.createTempFile(root, ".download-", ".part"); + } + try { + try { + transport.download(shot, temporary.toFile()); + } catch (InterruptedException e) { + throw e; + } catch (Exception e) { + throw new ItemFailure("Download failed: " + shot.id + ": " + e.getMessage(), e); + } + synchronized (lock) { + if (active != run) return; + } + checkTransfer(temporary, shot); + boolean valid; + try { + valid = transport.validImage(temporary.toFile()); + } catch (InterruptedException e) { + throw e; + } catch (Exception e) { + throw new ItemFailure("Image validation failed: " + shot.id + ": " + e.getMessage(), e); + } + if (!valid) throw new ItemFailure("Invalid image: " + shot.id, null); + checkTransfer(temporary, shot); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + channel.force(true); + } + synchronized (lock) { + if (active != run) return; + Path target = originals.resolve(shot.id); + createParents(target.getParent()); + checkPath(target); + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + syncDirectory(target.getParent()); + Ledger next = copy(); + // Read the current destination: setGallery may have changed it during the transfer. + next.records.get(shot.id).state = "downloaded"; + commit(next); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + private void export(long run, String id, Exporter exporter) throws Exception { + Shot shot; + File original; + synchronized (lock) { + if (!eligible(run, id)) return; + Receipt receipt = ledger.records.get(id); + shot = receipt.shot; + Path path = originals.resolve(id); + checkDownload(path, shot); + original = path.toFile(); + } + boolean saved; + try { + saved = exporter.export(shot, original, () -> { + synchronized (lock) { + return eligible(run, id); + } + }); + } catch (InterruptedException e) { + throw e; + } catch (Exception e) { + throw new ItemFailure("Gallery export failed: " + shot.id + ": " + e.getMessage(), e); + } + synchronized (lock) { + if (active != run || !saved) return; + Ledger next = copy(); + // A true result means the exporter actually saved (or recovered) the MediaStore item. + next.records.get(id).destination = "saved"; + commit(next); + } + } + + private boolean eligible(long run, String id) { + Receipt receipt = ledger.records.get(id); + return active == run && ledger.gallery && receipt != null + && "downloaded".equals(receipt.state) && "pending".equals(receipt.destination); + } + + private static Map listing(List shots) throws IOException { + if (shots == null) throw new IOException("Missing screenshot listing"); + Map result = new LinkedHashMap<>(); + for (Shot shot : shots) { + validateShot(shot); + Shot previous = result.put(shot.id, shot); + if (previous != null && !same(previous, shot)) { + throw new IOException("Conflicting screenshot identity: " + shot.id); + } + } + return result; + } + + private static void validateShot(Shot shot) throws IOException { + if (shot == null || shot.id == null || !ID.matcher(shot.id).matches() + || shot.size <= 0 || shot.size > MAX_SIZE || !Double.isFinite(shot.time)) { + throw new IOException("Invalid screenshot metadata"); + } + } + + private static boolean same(Shot a, Shot b) { + return a.size == b.size && Double.compare(a.time, b.time) == 0; + } + + private void validateLedger() throws IOException { + if (ledger == null || ledger.version == null || ledger.version != 1 + || ledger.initialized == null || ledger.gallery == null || ledger.records == null + || (!ledger.initialized && !ledger.records.isEmpty())) { + throw new IOException("Invalid sync journal header"); + } + for (Map.Entry entry : ledger.records.entrySet()) { + Receipt receipt = entry.getValue(); + if (receipt == null) throw new IOException("Invalid sync receipt"); + validateShot(receipt.shot); + if (!entry.getKey().equals(receipt.shot.id) || receipt.observations == null + || receipt.observations < 0 || receipt.observations > 2) { + throw new IOException("Invalid sync receipt identity"); + } + boolean baseline = "baseline".equals(receipt.state); + boolean observing = "observing".equals(receipt.state); + boolean queued = "queued".equals(receipt.state); + boolean downloaded = "downloaded".equals(receipt.state); + if ((!baseline && !observing && !queued && !downloaded) + || (baseline && receipt.destination != null) + || (baseline && receipt.observations != 0) + || (observing && receipt.observations > 1) + || (downloaded && receipt.observations != 2) + || ((observing || queued || downloaded) && !"pending".equals(receipt.destination) + && !"appOnly".equals(receipt.destination) && !"saved".equals(receipt.destination)) + || ((observing || queued) && "saved".equals(receipt.destination)) + || (!ledger.gallery && "pending".equals(receipt.destination))) { + throw new IOException("Invalid sync receipt state"); + } + Path path = originals.resolve(receipt.shot.id); + checkPath(path); + if (downloaded) checkDownload(path, receipt.shot); + } + } + + private void checkDownload(Path path, Shot shot) throws IOException { + checkPath(path); + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) != shot.size) { + throw new IOException("Incomplete original: " + shot.id); + } + } + + private void checkTransfer(Path path, Shot shot) throws IOException { + checkPath(path); // Unsafe paths and local filesystem errors remain fatal. + BasicFileAttributes attributes; + try { + attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException e) { + throw new ItemFailure("Missing downloaded image: " + shot.id, e); + } + if (!attributes.isRegularFile() || attributes.size() != shot.size) { + throw new ItemFailure("Incomplete downloaded image: " + shot.id, null); + } + } + + private void checkPath(Path path) throws IOException { + if (!path.normalize().startsWith(root)) throw new IOException("Path escaped sync root"); + Path current = root; + if (Files.isSymbolicLink(current)) throw new IOException("Symlink in sync root"); + for (Path component : root.relativize(path)) { + current = current.resolve(component); + if (Files.isSymbolicLink(current)) throw new IOException("Symlink in sync path"); + } + } + + private void createParents(Path directory) throws IOException { + checkPath(directory); + Path current = root; + for (Path component : root.relativize(directory)) { + Path parent = current; + current = current.resolve(component); + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectory(current); + syncDirectory(parent); + } else if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid originals directory"); + } + } + } + + private Ledger copy() { + Ledger next = emptyLedger(); + next.initialized = ledger.initialized; + next.gallery = ledger.gallery; + for (Map.Entry entry : ledger.records.entrySet()) { + Receipt receipt = entry.getValue(); + next.records.put(entry.getKey(), new Receipt(receipt.shot, receipt.state, + receipt.observations, receipt.destination)); + } + return next; + } + + private static Ledger emptyLedger() { + Ledger result = new Ledger(); + result.version = 1; + result.initialized = false; + result.records = new LinkedHashMap<>(); + return result; + } + + private void commit(Ledger next) throws IOException { + persist(next); + ledger = next; + } + + private void persist(Ledger next) throws IOException { + checkPath(journal); + Path temporary = Files.createTempFile(root, ".receipts-", ".tmp"); + try { + try (FileOutputStream output = new FileOutputStream(temporary.toFile()); + Writer writer = new OutputStreamWriter(output, StandardCharsets.UTF_8)) { + GSON.toJson(next, writer); + writer.flush(); + output.getFD().sync(); + } + Files.move(temporary, journal, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + syncDirectory(root); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void syncDirectory(Path directory) { + // Some Android filesystems reject directory channels. File contents were already fsynced; + // the rename is atomic either way. Do not report rollback after a committed rename. + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (IOException | UnsupportedOperationException ignored) { + // Best available portable Java directory durability. + } + } +} diff --git a/android/app/src/main/java/com/saphid/framecontrol/SyncService.java b/android/app/src/main/java/com/saphid/framecontrol/SyncService.java new file mode 100644 index 0000000..d6f04d7 --- /dev/null +++ b/android/app/src/main/java/com/saphid/framecontrol/SyncService.java @@ -0,0 +1,234 @@ +package com.saphid.framecontrol; + +import android.app.*; +import android.content.*; +import android.content.pm.ServiceInfo; +import android.os.*; +import java.io.File; +import java.util.*; +import java.util.concurrent.*; + +/** Explicit user-started connected-device session, with a visible Stop action. */ +public final class SyncService extends Service { + static final String CHANNEL = "headset-sync"; + static final Object SETTINGS_LOCK = new Object(); + private static volatile SyncService instance; + private static volatile String state = "Disconnected"; + private static volatile String page; + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + private volatile boolean stopping; + private volatile FrameConnection connection; + private volatile SyncEngine engine; + + static SharedPreferences prefs(Context c) { + return c.getSharedPreferences("frame", MODE_PRIVATE); + } + + static String state() { + return state; + } + + static String page() { + return page; + } + + static boolean running() { + SyncService service = instance; + return service != null && !service.stopping; + } + + static String install(Context c) { + SharedPreferences p = prefs(c); + String id = p.getString("install", null); + if (id == null) { + id = UUID.randomUUID().toString(); + if (!p.edit().putString("install", id).commit()) + throw new IllegalStateException("Could not save this phone's identity."); + } + return id; + } + + static File library(Context c, String pin) throws Exception { + return new File(c.getFilesDir(), "screenshots/" + FrameConnection.hash(pin)); + } + + static void gallery(Context c, boolean value) throws Exception { + synchronized (SETTINGS_LOCK) { + SyncService service = instance; + // Journal first: a failed disk write must not appear as a saved setting. + String pin = prefs(c).getString("pin", null); + SyncEngine live = service == null ? null : service.engine; + if (live != null) live.setGallery(value); + if (!value) { + // The setting is global. Latch the opt-out in every retained library, + // including headsets that are not currently paired or connected. + File libraries = new File(c.getFilesDir(), "screenshots"); + if (libraries.exists()) { + File[] roots = libraries.listFiles(); + if (roots == null) throw new java.io.IOException("Could not read screenshot libraries."); + File activeRoot = live != null && pin != null ? library(c, pin) : null; + for (File root : roots) { + if (!root.getName().matches("[0-9a-f]{64}")) continue; + if (activeRoot != null && root.equals(activeRoot)) continue; + new SyncEngine(root, false).setGallery(false); + } + } + } else if (live == null && pin != null) { + new SyncEngine(library(c, pin), true).setGallery(true); + } + if (!prefs(c).edit().putBoolean("gallery", value).commit()) + throw new java.io.IOException("Could not save the Gallery setting."); + } + } + + static List items(Context c) throws Exception { + SyncService service = instance; + if (service != null && service.engine != null) return service.engine.items(); + String pin = prefs(c).getString("pin", null); + if (pin == null) return Collections.emptyList(); + synchronized (SETTINGS_LOCK) { + return new SyncEngine(library(c, pin), prefs(c).getBoolean("gallery", true)).items(); + } + } + + @Override + public void onCreate() { + super.onCreate(); + instance = this; + stopping = false; + NotificationManager manager = getSystemService(NotificationManager.class); + manager.createNotificationChannel( + new NotificationChannel( + CHANNEL, "Headset screenshot sync", NotificationManager.IMPORTANCE_LOW)); + } + + private Notification notification(String message) { + PendingIntent open = + PendingIntent.getActivity( + this, + 0, + new Intent(this, MainActivity.class), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent stop = + PendingIntent.getService( + this, + 1, + new Intent(this, SyncService.class).setAction("STOP"), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + return new Notification.Builder(this, CHANNEL) + .setSmallIcon(com.saphid.framecontrol.R.drawable.ic_frame) + .setContentTitle("Frame Control") + .setContentText(message) + .setContentIntent(open) + .setOngoing(true) + .addAction(new Notification.Action.Builder(null, "Stop syncing", stop).build()) + .build(); + } + + private void status(String text) { + state = text; + getSystemService(NotificationManager.class).notify(108, notification(text)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && "STOP".equals(intent.getAction())) { + stopSelf(); + return START_NOT_STICKY; + } + if (connection != null || stopping) return START_NOT_STICKY; + state = "Connecting to headset"; + if (Build.VERSION.SDK_INT >= 34) + startForeground( + 108, notification(state), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE); + else startForeground(108, notification(state)); + connection = new FrameConnection(this); + worker.execute(this::runSession); + return START_NOT_STICKY; + } + + private void runSession() { + try { + SharedPreferences p = prefs(this); + String pin = p.getString("pin", null); + if (pin == null) throw new java.io.IOException("Pair with your headset first."); + synchronized (SETTINGS_LOCK) { + engine = new SyncEngine(library(this, pin), p.getBoolean("gallery", true)); + } + while (!stopping) { + try { + FrameConnection active = new FrameConnection(this); + connection = active; + status("Connecting to headset"); + active.connect( + p.getString("host", ""), + p.getInt("port", 22), + p.getString("user", "steamos"), + pin, + install(this)); + if (stopping) { + active.close(); + break; + } + page = active.page(); + status("Syncing new screenshots"); + GalleryExporter exporter = new GalleryExporter(this, pin, SETTINGS_LOCK); + while (!stopping && active.connected()) { + try { + engine.reconcile(active.shots(), active, exporter); + status("Syncing new screenshots"); + } catch (Exception e) { + status("Screenshot pending · " + message(e)); + } + Thread.sleep(4000); + } + if (!stopping) throw new java.io.IOException("Connection lost. Reconnecting…"); + } catch (Exception e) { + page = null; + if (connection != null) connection.close(); + if (stopping) break; + status("Waiting for headset · " + message(e)); + Thread.sleep(10000); + } + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + state = message(e); + } finally { + page = null; + if (connection != null) connection.close(); + stopSelf(); + } + } + + static String message(Exception e) { + String text = e.getMessage(); + if (text == null || text.trim().isEmpty()) return "Try reconnecting."; + if (text.contains("HostKey") + || text.contains("HostKey has been changed") + || text.contains("reject HostKey")) return "Headset identity changed. Stop and pair again."; + if (text.contains("Auth fail")) return "Headset login refused. Pair again."; + return text.length() > 180 ? text.substring(0, 180) : text; + } + + @Override + public void onDestroy() { + stopping = true; + page = null; + if (engine != null) engine.stop(); + if (connection != null) connection.close(); + worker.shutdownNow(); + instance = null; + if (state.equals("Syncing new screenshots") + || state.startsWith("Waiting") + || state.startsWith("Connecting")) state = "Disconnected"; + stopForeground(STOP_FOREGROUND_REMOVE); + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/android/app/src/main/res/drawable/ic_frame.xml b/android/app/src/main/res/drawable/ic_frame.xml new file mode 100644 index 0000000..88ecd5d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_frame.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..8611cc4 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/xml/data_extraction.xml b/android/app/src/main/res/xml/data_extraction.xml new file mode 100644 index 0000000..173e172 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..2105314 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/xml/network_security.xml b/android/app/src/main/res/xml/network_security.xml new file mode 100644 index 0000000..59eb51b --- /dev/null +++ b/android/app/src/main/res/xml/network_security.xml @@ -0,0 +1,4 @@ + + + 127.0.0.1localhost + diff --git a/android/app/src/test/java/com/saphid/framecontrol/SyncEngineTest.java b/android/app/src/test/java/com/saphid/framecontrol/SyncEngineTest.java new file mode 100644 index 0000000..2fc6697 --- /dev/null +++ b/android/app/src/test/java/com/saphid/framecontrol/SyncEngineTest.java @@ -0,0 +1,789 @@ +package com.saphid.framecontrol; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +public class SyncEngineTest { + @Rule public TemporaryFolder temporary = new TemporaryFolder(); + + private static final String FIRST = "123/456/20261024120000_1.jpg"; + private static final String SECOND = "123/456/20261024120001_2.png"; + private static final SyncEngine.Shot A = shot(FIRST); + private static final SyncEngine.Shot B = shot(SECOND); + + private static SyncEngine.Shot shot(String id) { return new SyncEngine.Shot(id, 4, 100.5); } + + private static class Transfer implements SyncEngine.Transport { + final List downloads = new ArrayList<>(); + boolean valid = true; + boolean truncated; + + @Override public void download(SyncEngine.Shot shot, File destination) throws Exception { + downloads.add(shot.id); + try (RandomAccessFile file = new RandomAccessFile(destination, "rw")) { + file.setLength(truncated ? shot.size - 1 : shot.size); + } + } + + @Override public boolean validImage(File file) throws Exception { return valid; } + } + + private static class Gallery implements SyncEngine.Exporter { + int attempts; + int saves; + boolean deny; + boolean fail; + + @Override public boolean export(SyncEngine.Shot shot, File original, + java.util.function.BooleanSupplier eligible) throws Exception { + attempts++; + assertTrue(original.isFile()); + if (fail) throw new IOException("Gallery unavailable"); + if (deny || !eligible.getAsBoolean()) return false; + saves++; + return true; + } + } + + private interface Checked { void run() throws Exception; } + + private static IOException fails(Checked operation) throws Exception { + try { + operation.run(); + } catch (IOException expected) { + return expected; + } + throw new AssertionError("Expected operation to reject invalid or unavailable state"); + } + + private static void reconcile(SyncEngine engine, Transfer transfer, Gallery gallery, + SyncEngine.Shot... shots) throws Exception { + engine.reconcile(Arrays.asList(shots), transfer, gallery); + } + + private static void observed(SyncEngine engine, Transfer transfer, Gallery gallery, + SyncEngine.Shot shot) throws Exception { + reconcile(engine, transfer, gallery); + reconcile(engine, transfer, gallery, shot); + } + + private static void assertDownloadPending(SyncEngine engine, String destination) { + assertEquals(1, engine.items().size()); + assertEquals(destination, engine.items().get(0).destination); + assertFalse(engine.items().get(0).file.exists()); + } + + private static SyncEngine.Item item(SyncEngine engine, String id) { + return engine.items().stream().filter(i -> i.shot.id.equals(id)).findFirst().get(); + } + + private static void await(CountDownLatch latch) throws InterruptedException { + assertTrue("Timed out waiting for callback", latch.await(5, TimeUnit.SECONDS)); + } + + private static final class Async implements AutoCloseable { + private final AtomicReference failure = new AtomicReference<>(); + private final Thread thread; + + Async(Checked operation) { + thread = new Thread(() -> { + try { operation.run(); } catch (Throwable e) { failure.set(e); } + }, "sync-core-test"); + thread.start(); + } + + void finish() throws Exception { + thread.join(5000); + assertFalse("Reconcile did not finish", thread.isAlive()); + if (failure.get() != null) throw new AssertionError("Background reconcile failed", failure.get()); + } + + @Override public void close() throws Exception { + if (thread.isAlive()) thread.interrupt(); + thread.join(5000); + assertFalse("Leaked reconcile thread", thread.isAlive()); + } + } + + @Test public void firstListingIsDurableBaselineAndNeverDownloadsHistory() throws Exception { + File root = temporary.newFolder(); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + SyncEngine engine = new SyncEngine(root, true); + reconcile(engine, transfer, gallery, A); + assertTrue(new File(root, "receipts.json").isFile()); + engine = new SyncEngine(root, true); + reconcile(engine, transfer, gallery, A); + reconcile(engine, transfer, gallery, new SyncEngine.Shot(FIRST, 7, 999)); + assertTrue(transfer.downloads.isEmpty()); + assertTrue(engine.items().isEmpty()); + } + + @Test public void emptyBaselineRequiresTwoIdenticalNewObservations() throws Exception { + SyncEngine engine = new SyncEngine(temporary.newFolder(), true); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + assertTrue(transfer.downloads.isEmpty()); + assertDownloadPending(engine, "pending"); + reconcile(engine, transfer, gallery, A); + assertEquals(Collections.singletonList(FIRST), transfer.downloads); + assertEquals(1, gallery.saves); + assertEquals("saved", engine.items().get(0).destination); + assertEquals(4, engine.items().get(0).file.length()); + } + + @Test public void sizeAndMtimeChangesResetStabilityAndMissingPollBreaksIt() throws Exception { + SyncEngine engine = new SyncEngine(temporary.newFolder(), false); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + SyncEngine.Shot size = new SyncEngine.Shot(FIRST, 5, A.time); + SyncEngine.Shot time = new SyncEngine.Shot(FIRST, 5, A.time + 1); + reconcile(engine, transfer, gallery, size); + reconcile(engine, transfer, gallery, time); + reconcile(engine, transfer, gallery); + reconcile(engine, transfer, gallery, time); + assertTrue(transfer.downloads.isEmpty()); + reconcile(engine, transfer, gallery, time); + assertEquals(1, transfer.downloads.size()); + assertEquals(5, engine.items().get(0).file.length()); + } + + @Test public void identityIncludesAccountAndAppAndDuplicateRowsAreOnlyOneObservation() throws Exception { + SyncEngine engine = new SyncEngine(temporary.newFolder(), false); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + reconcile(engine, transfer, gallery, A); + SyncEngine.Shot otherAccount = shot("124/456/20261024120000_1.jpg"); + SyncEngine.Shot otherApp = shot("123/457/20261024120000_1.jpg"); + reconcile(engine, transfer, gallery, A, otherAccount, otherAccount, otherApp); + assertTrue(transfer.downloads.isEmpty()); + reconcile(engine, transfer, gallery, otherAccount, otherApp); + assertEquals(2, transfer.downloads.size()); + assertEquals(2, engine.items().size()); + reconcile(engine, transfer, gallery, otherAccount, otherApp); + assertEquals(2, transfer.downloads.size()); + } + + @Test public void truncatedAndInvalidImagesStayQueuedUntilValidRetry() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, true); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + transfer.truncated = true; + fails(() -> reconcile(engine, transfer, gallery, A)); + assertDownloadPending(engine, "pending"); + assertFalse(new File(root, "originals/" + FIRST).exists()); + transfer.truncated = false; transfer.valid = false; + fails(() -> reconcile(engine, transfer, gallery, A)); + assertDownloadPending(engine, "pending"); + assertEquals(0, gallery.attempts); + transfer.valid = true; + reconcile(engine, transfer, gallery, A); + assertEquals(3, transfer.downloads.size()); + assertEquals("saved", engine.items().get(0).destination); + assertEquals(0, root.listFiles((dir, name) -> name.startsWith(".download-")).length); + } + + @Test public void deniedExportRetriesWithoutDownloadingAndSavedReceiptDeduplicates() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, true); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + gallery.deny = true; + reconcile(engine, transfer, gallery, A); + assertEquals("pending", engine.items().get(0).destination); + engine = new SyncEngine(root, true); + gallery.deny = false; + reconcile(engine, transfer, gallery); // Export retry does not need the remote file still listed. + assertEquals(1, transfer.downloads.size()); + assertEquals(1, gallery.saves); + reconcile(new SyncEngine(root, true), transfer, gallery, A); + assertEquals(2, gallery.attempts); + } + + @Test public void thrownExportRetainsPendingReceiptAcrossRelaunch() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, true); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + gallery.fail = true; + fails(() -> reconcile(engine, transfer, gallery, A)); + assertEquals("pending", engine.items().get(0).destination); + gallery.fail = false; + reconcile(new SyncEngine(root, true), transfer, gallery); + assertEquals(1, gallery.saves); + assertEquals(1, transfer.downloads.size()); + } + + @Test public void queuedShotAndStabilitySurviveRelaunch() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, false); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + transfer.truncated = true; + fails(() -> reconcile(engine, transfer, gallery, A)); + SyncEngine relaunched = new SyncEngine(root, true); + transfer.truncated = false; + reconcile(relaunched, transfer, gallery, A); + assertEquals("appOnly", relaunched.items().get(0).destination); + assertEquals(0, gallery.attempts); + } + + @Test public void firstObservationSurvivesRelaunch() throws Exception { + File root = temporary.newFolder(); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(new SyncEngine(root, false), transfer, gallery, A); + SyncEngine engine = new SyncEngine(root, false); + reconcile(engine, transfer, gallery, A); + assertEquals(1, engine.items().size()); + } + + @Test public void offThenOnNeverRetroExportsDownloadedOrQueuedItems() throws Exception { + SyncEngine engine = new SyncEngine(temporary.newFolder(), true); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + gallery.deny = true; + reconcile(engine, transfer, gallery, A); + engine.setGallery(false); + engine.setGallery(true); + gallery.deny = false; + reconcile(engine, transfer, gallery, A, B); + assertEquals("appOnly", engine.items().get(0).destination); + reconcile(engine, transfer, gallery, A, B); + assertEquals(1, gallery.saves); // Only the later B item. + assertEquals("appOnly", engine.items().stream().filter(i -> i.shot.id.equals(FIRST)) + .findFirst().get().destination); + } + + @Test public void switchingOffWhileDownloadBlockedChangesItsDisposition() throws Exception { + SyncEngine engine = new SyncEngine(temporary.newFolder(), true); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + Transfer transfer = new Transfer() { + @Override public void download(SyncEngine.Shot shot, File file) throws Exception { + entered.countDown(); await(release); super.download(shot, file); + } + }; + Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + try (Async async = new Async(() -> reconcile(engine, transfer, gallery, A))) { + try { + await(entered); + engine.setGallery(false); // Must finish while network is blocked. + engine.setGallery(true); + } finally { release.countDown(); } + async.finish(); + } + assertEquals("appOnly", engine.items().get(0).destination); + assertEquals(0, gallery.attempts); + } + + @Test public void switchingOffWhileExporterBlockedInvalidatesEligibility() throws Exception { + SyncEngine engine = new SyncEngine(temporary.newFolder(), true); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + Gallery gallery = new Gallery() { + @Override public boolean export(SyncEngine.Shot shot, File original, + java.util.function.BooleanSupplier eligible) throws Exception { + assertTrue(eligible.getAsBoolean()); + entered.countDown(); await(release); + assertFalse(eligible.getAsBoolean()); + return false; + } + }; + Transfer transfer = new Transfer(); + observed(engine, transfer, gallery, A); + try (Async async = new Async(() -> reconcile(engine, transfer, gallery, A))) { + try { await(entered); engine.setGallery(false); engine.setGallery(true); } + finally { release.countDown(); } + async.finish(); + } + assertEquals("appOnly", engine.items().get(0).destination); + } + + @Test public void lateDownloadAfterStopCannotOverwriteFreshRunOrJournal() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, true); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + Transfer slow = new Transfer() { + @Override public void download(SyncEngine.Shot shot, File file) throws Exception { + entered.countDown(); await(release); + Files.write(file.toPath(), new byte[]{9, 9, 9, 9}); + } + }; + Transfer fresh = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, fresh, gallery, A); + try (Async async = new Async(() -> reconcile(engine, slow, gallery, A))) { + byte[] journal; + try { + await(entered); engine.stop(); engine.setGallery(false); + reconcile(engine, fresh, gallery, A); + journal = Files.readAllBytes(new File(root, "receipts.json").toPath()); + } finally { release.countDown(); } + async.finish(); + assertArrayEquals(journal, Files.readAllBytes(new File(root, "receipts.json").toPath())); + } + assertArrayEquals(new byte[4], Files.readAllBytes(engine.items().get(0).file.toPath())); + assertEquals("appOnly", new SyncEngine(root, true).items().get(0).destination); + } + + @Test public void lateExportAfterStopDoesNotMarkFreshAppOnlyReceiptSaved() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, true); + Transfer transfer = new Transfer(); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + Gallery delayed = new Gallery() { + @Override public boolean export(SyncEngine.Shot shot, File original, + java.util.function.BooleanSupplier eligible) throws Exception { + entered.countDown(); await(release); + assertFalse(eligible.getAsBoolean()); + return true; // Model a pre-stop external save whose callback arrived late. + } + }; + observed(engine, transfer, delayed, A); + try (Async async = new Async(() -> reconcile(engine, transfer, delayed, A))) { + byte[] journal; + try { + await(entered); engine.stop(); engine.setGallery(false); + reconcile(engine, transfer, new Gallery(), A); + journal = Files.readAllBytes(new File(root, "receipts.json").toPath()); + } finally { release.countDown(); } + async.finish(); + assertArrayEquals(journal, Files.readAllBytes(new File(root, "receipts.json").toPath())); + } + assertEquals("appOnly", engine.items().get(0).destination); + } + + @Test public void concurrentReconcileIsRejectedWithoutBlockingOnDownload() throws Exception { + SyncEngine engine = new SyncEngine(temporary.newFolder(), false); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + Transfer slow = new Transfer() { + @Override public void download(SyncEngine.Shot shot, File file) throws Exception { + entered.countDown(); await(release); super.download(shot, file); + } + }; + Gallery gallery = new Gallery(); observed(engine, slow, gallery, A); + try (Async async = new Async(() -> reconcile(engine, slow, gallery, A))) { + try { + await(entered); + try { reconcile(engine, new Transfer(), gallery, A); fail("Concurrent run accepted"); } + catch (IllegalStateException expected) { assertDownloadPending(engine, "appOnly"); } + } finally { release.countDown(); } + async.finish(); + } + } + + @Test public void corruptedAndIncompleteJournalsFailClosed() throws Exception { + for (String content : new String[]{"", "{", "null", "{}", "{\"gallery\":true}", + "{\"version\":2,\"initialized\":true,\"gallery\":true,\"records\":{}}", + "{\"version\":1,\"initialized\":true,\"gallery\":true,\"records\":{}} trailing"}) { + File root = temporary.newFolder(); + File journal = new File(root, "receipts.json"); + Files.write(journal.toPath(), content.getBytes(StandardCharsets.UTF_8)); + fails(() -> new SyncEngine(root, true)); + assertEquals(content, new String(Files.readAllBytes(journal.toPath()), StandardCharsets.UTF_8)); + } + } + + @Test public void damagedReceiptAndMissingOriginalFailClosed() throws Exception { + File root = temporary.newFolder(); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + SyncEngine engine = new SyncEngine(root, false); + observed(engine, transfer, gallery, A); reconcile(engine, transfer, gallery, A); + File journal = new File(root, "receipts.json"); + String json = new String(Files.readAllBytes(journal.toPath()), StandardCharsets.UTF_8); + for (String damaged : new String[]{json.replace("downloaded", "unknown"), + json.replace("\"time\":100.5", "\"otherTime\":100.5"), + json.replace("\"size\":4", "\"size\":0"), + json.replace("\"time\":100.5", "\"time\":1e999")}) { + Files.write(journal.toPath(), damaged.getBytes(StandardCharsets.UTF_8)); + fails(() -> new SyncEngine(root, false)); + } + Files.write(journal.toPath(), json.getBytes(StandardCharsets.UTF_8)); + Files.delete(engine.items().get(0).file.toPath()); + fails(() -> new SyncEngine(root, false)); + Files.delete(journal.toPath()); + Files.write(new File(root, "originals/orphan").toPath(), new byte[]{1}); + fails(() -> new SyncEngine(root, false)); + } + + @Test public void invalidListingCannotEstablishBaselineOrPartiallyAdvanceObservations() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, false); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + fails(() -> reconcile(engine, transfer, gallery, A, shot("../bad"))); + reconcile(engine, transfer, gallery, A); // A is still baseline, not newly discovered. + reconcile(engine, transfer, gallery, A, B); + fails(() -> reconcile(engine, transfer, gallery, B, shot("../bad"))); + assertTrue(transfer.downloads.isEmpty()); + reconcile(engine, transfer, gallery, B); + assertEquals(Collections.singletonList(SECOND), transfer.downloads); + } + + @Test public void metadataBoundsAndPathTraversalAreRejected() throws Exception { + SyncEngine engine = new SyncEngine(temporary.newFolder(), false); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + for (String id : new String[]{"../" + FIRST, "/" + FIRST, "123/../" + FIRST, + "123\\456\\20261024120000_1.jpg", "123/456/20261024120000_1.JPG", + "1234567890123/456/20261024120000_1.jpg", "123/123456789012345678901/20261024120000_1.jpg", + "123/456/2026102412000_1.jpg", "123/456/20261024120000_12345.jpg", + FIRST + "\n", FIRST + "/extra", "123/456/20261024120000_1.gif"}) { + fails(() -> reconcile(engine, transfer, gallery, shot(id))); + } + for (long size : new long[]{0, -1, 30L * 1024 * 1024 + 1, Long.MAX_VALUE}) { + fails(() -> reconcile(engine, transfer, gallery, new SyncEngine.Shot(FIRST, size, 1))); + } + for (double time : new double[]{Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + fails(() -> reconcile(engine, transfer, gallery, new SyncEngine.Shot(FIRST, 1, time))); + } + fails(() -> engine.reconcile(null, transfer, gallery)); + fails(() -> reconcile(engine, transfer, gallery, (SyncEngine.Shot) null)); + fails(() -> reconcile(engine, transfer, gallery, A, new SyncEngine.Shot(FIRST, 5, 1))); + assertTrue(transfer.downloads.isEmpty()); + } + + @Test public void exactMaximumMetadataAndThirtyMebibyteOriginalAreAccepted() throws Exception { + SyncEngine engine = new SyncEngine(temporary.newFolder(), false); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + SyncEngine.Shot maximum = new SyncEngine.Shot( + "123456789012/12345678901234567890/20261024123456_1234.png", 30L * 1024 * 1024, + Double.MAX_VALUE); + observed(engine, transfer, gallery, maximum); + reconcile(engine, transfer, gallery, maximum); + assertEquals(maximum.size, engine.items().get(0).file.length()); + assertEquals("appOnly", engine.items().get(0).destination); + } + + @Test public void symbolicLinksCannotEscapeOriginalsOrReplaceJournal() throws Exception { + File root = temporary.newFolder(), outside = temporary.newFolder(); + Files.createSymbolicLink(new File(root, "originals").toPath(), outside.toPath()); + fails(() -> new SyncEngine(root, true)); + Files.delete(new File(root, "originals").toPath()); + File target = new File(outside, "receipts"); Files.write(target.toPath(), new byte[]{1}); + Files.createSymbolicLink(new File(root, "receipts.json").toPath(), target.toPath()); + fails(() -> new SyncEngine(root, true)); + assertArrayEquals(new byte[]{1}, Files.readAllBytes(target.toPath())); + } + + @Test public void nestedSymlinkIntroducedDuringDownloadCannotEscapeRoot() throws Exception { + File root = temporary.newFolder(), outside = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, false); + Transfer transfer = new Transfer() { + @Override public void download(SyncEngine.Shot shot, File file) throws Exception { + super.download(shot, file); + Files.createDirectory(new File(root, "originals").toPath()); + Files.createSymbolicLink(new File(root, "originals/123").toPath(), outside.toPath()); + } + }; + Gallery gallery = new Gallery(); observed(engine, transfer, gallery, A); + fails(() -> reconcile(engine, transfer, gallery, A)); + assertDownloadPending(engine, "appOnly"); + assertEquals(0, outside.list().length); + } + + @Test public void failedPreferenceWriteRollsBackGalleryAndPendingDisposition() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, true); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); gallery.deny = true; + reconcile(engine, transfer, gallery, A); + File journal = new File(root, "receipts.json"); byte[] saved = Files.readAllBytes(journal.toPath()); + Files.delete(journal.toPath()); Files.createDirectory(journal.toPath()); + fails(() -> engine.setGallery(false)); + assertEquals("pending", engine.items().get(0).destination); + Files.delete(journal.toPath()); Files.write(journal.toPath(), saved); + gallery.deny = false; reconcile(engine, transfer, gallery, A); + assertEquals(1, gallery.saves); + assertEquals("saved", engine.items().get(0).destination); + } + + @Test public void failedQueueWriteDoesNotStartDownloadOrChangeMemory() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, false); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + File journal = new File(root, "receipts.json"); byte[] saved = Files.readAllBytes(journal.toPath()); + Files.delete(journal.toPath()); Files.createDirectory(journal.toPath()); + fails(() -> reconcile(engine, transfer, gallery, A)); + assertTrue(transfer.downloads.isEmpty()); + assertDownloadPending(engine, "appOnly"); + Files.delete(journal.toPath()); Files.write(journal.toPath(), saved); + reconcile(engine, transfer, gallery, A); + assertEquals(1, engine.items().size()); + } + + @Test public void exportedImageWithFailedReceiptWriteRecoversThroughIdempotentExporter() throws Exception { + File root = temporary.newFolder(); + File journal = new File(root, "receipts.json"); + SyncEngine engine = new SyncEngine(root, true); + Transfer transfer = new Transfer(); + AtomicReference pendingJournal = new AtomicReference<>(); + Gallery gallery = new Gallery() { + private boolean exists; + + @Override public boolean export(SyncEngine.Shot shot, File file, + java.util.function.BooleanSupplier eligible) throws Exception { + attempts++; + if (exists) return true; // Recover the previous external save without another insert. + assertTrue(eligible.getAsBoolean()); + exists = true; saves++; + pendingJournal.set(Files.readAllBytes(journal.toPath())); + Files.delete(journal.toPath()); Files.createDirectory(journal.toPath()); + return true; + } + }; + observed(engine, transfer, gallery, A); + fails(() -> reconcile(engine, transfer, gallery, A)); + assertEquals("pending", engine.items().get(0).destination); + Files.delete(journal.toPath()); Files.write(journal.toPath(), pendingJournal.get()); + SyncEngine relaunched = new SyncEngine(root, true); + reconcile(relaunched, transfer, gallery); + assertEquals("saved", relaunched.items().get(0).destination); + assertEquals(1, gallery.saves); + assertEquals(2, gallery.attempts); + assertEquals(1, transfer.downloads.size()); + } + + @Test public void failedBaselineWriteCannotTurnHistoryIntoNewScreenshots() throws Exception { + File root = temporary.newFolder(); + File journal = new File(root, "receipts.json"); + SyncEngine engine = new SyncEngine(root, false); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + byte[] initialJournal = Files.readAllBytes(journal.toPath()); + Files.delete(journal.toPath()); Files.createDirectory(journal.toPath()); + fails(() -> reconcile(engine, transfer, gallery, A)); + Files.delete(journal.toPath()); Files.write(journal.toPath(), initialJournal); + reconcile(engine, transfer, gallery, A); + reconcile(engine, transfer, gallery, A); + assertTrue(engine.items().isEmpty()); + assertTrue(transfer.downloads.isEmpty()); + } + + @Test public void firstObservedOffStaysAppOnlyWhenEnabledBeforeStabilityIncludingRelaunch() throws Exception { + for (boolean restart : new boolean[]{false, true}) { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, false); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + assertDownloadPending(engine, "appOnly"); + engine.setGallery(true); + if (restart) engine = new SyncEngine(root, true); + reconcile(engine, transfer, gallery, A); + assertTrue(engine.items().get(0).file.isFile()); + assertEquals("appOnly", engine.items().get(0).destination); + assertEquals(0, gallery.attempts); + reconcile(new SyncEngine(root, true), transfer, gallery, A); + assertEquals(0, gallery.attempts); + } + } + + @Test public void observingOnOffOnStaysAppOnlyBeforeStabilityIncludingRelaunch() throws Exception { + for (boolean restart : new boolean[]{false, true}) { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, true); + Transfer transfer = new Transfer(); Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + assertDownloadPending(engine, "pending"); + engine.setGallery(false); + assertDownloadPending(engine, "appOnly"); + if (restart) engine = new SyncEngine(root, false); + engine.setGallery(true); + if (restart) engine = new SyncEngine(root, true); + // An intervening metadata change must preserve the first observation's disposition. + SyncEngine.Shot changed = new SyncEngine.Shot(FIRST, 5, A.time + 1); + reconcile(engine, transfer, gallery, changed); + assertDownloadPending(engine, "appOnly"); + reconcile(engine, transfer, gallery, changed); + assertTrue(engine.items().get(0).file.isFile()); + assertEquals("appOnly", engine.items().get(0).destination); + assertEquals(0, gallery.attempts); + } + } + + @Test public void permanentGalleryFailureAllowsLaterOriginalAndExportInSamePoll() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, true); + Transfer transfer = new Transfer(); + IOException unavailable = new IOException("permanent Gallery failure"); + Gallery gallery = new Gallery() { + @Override public boolean export(SyncEngine.Shot shot, File file, + java.util.function.BooleanSupplier eligible) throws Exception { + if (shot.id.equals(FIRST)) throw unavailable; + return super.export(shot, file, eligible); + } + }; + observed(engine, transfer, gallery, A); + assertSame(unavailable, fails(() -> reconcile(engine, transfer, gallery, A)).getCause()); + fails(() -> reconcile(engine, transfer, gallery, A, B)); + assertFalse(item(engine, SECOND).file.exists()); // B has only one observation. + assertSame(unavailable, fails(() -> reconcile(engine, transfer, gallery, A, B)).getCause()); + assertEquals(Arrays.asList(FIRST, SECOND), transfer.downloads); + assertTrue(item(engine, SECOND).file.isFile()); + assertEquals("saved", item(engine, SECOND).destination); + assertEquals("pending", item(engine, FIRST).destination); + fails(() -> reconcile(engine, transfer, gallery, A, B)); + assertEquals(2, transfer.downloads.size()); + assertEquals(1, gallery.saves); + SyncEngine relaunched = new SyncEngine(root, true); + assertEquals("pending", item(relaunched, FIRST).destination); + assertEquals("saved", item(relaunched, SECOND).destination); + } + + @Test public void permanentTransferFailureAllowsLaterOriginalInSamePollAndStaysQueued() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, false); + IOException unavailable = new IOException("permanent transfer failure"); + Transfer transfer = new Transfer() { + @Override public void download(SyncEngine.Shot shot, File file) throws Exception { + if (shot.id.equals(FIRST)) { downloads.add(shot.id); throw unavailable; } + super.download(shot, file); + } + }; + Gallery gallery = new Gallery(); + observed(engine, transfer, gallery, A); + fails(() -> reconcile(engine, transfer, gallery, A)); + fails(() -> reconcile(engine, transfer, gallery, A, B)); + assertSame(unavailable, fails(() -> reconcile(engine, transfer, gallery, A, B)).getCause()); + assertFalse(item(engine, FIRST).file.exists()); + assertTrue(item(engine, SECOND).file.isFile()); + assertEquals("appOnly", item(engine, SECOND).destination); + assertEquals(Arrays.asList(FIRST, FIRST, FIRST, SECOND), transfer.downloads); + SyncEngine relaunched = new SyncEngine(root, false); + fails(() -> reconcile(relaunched, transfer, gallery, A, B)); + assertEquals(1, Collections.frequency(transfer.downloads, SECOND)); + assertFalse(item(relaunched, FIRST).file.exists()); + assertTrue(item(relaunched, SECOND).file.isFile()); + } + + @Test public void permanentTruncatedInvalidOrUndecodableImageDoesNotStarveLaterOriginal() throws Exception { + for (String mode : Arrays.asList("truncated", "invalid", "decoder")) { + SyncEngine engine = new SyncEngine(temporary.newFolder(), false); + Transfer transfer = new Transfer() { + private boolean first; + + @Override public void download(SyncEngine.Shot shot, File file) throws Exception { + super.download(shot, file); + first = shot.id.equals(FIRST); + if (first && mode.equals("truncated")) { + try (RandomAccessFile contents = new RandomAccessFile(file, "rw")) { + contents.setLength(shot.size - 1); + } + } + } + + @Override public boolean validImage(File file) throws Exception { + if (first && mode.equals("decoder")) throw new IOException("decoder failure"); + return !first; + } + }; + Gallery gallery = new Gallery(); + reconcile(engine, transfer, gallery); + reconcile(engine, transfer, gallery, A, B); + IOException error = fails(() -> reconcile(engine, transfer, gallery, A, B)); + assertTrue(error.getMessage().contains(FIRST)); + assertEquals(Arrays.asList(FIRST, SECOND), transfer.downloads); + assertFalse(item(engine, FIRST).file.exists()); + assertTrue(item(engine, SECOND).file.isFile()); + fails(() -> reconcile(engine, transfer, gallery, A, B)); + assertEquals(1, Collections.frequency(transfer.downloads, SECOND)); + assertEquals(2, Collections.frequency(transfer.downloads, FIRST)); + } + } + + @Test public void fatalJournalCommitAfterRetryableFailureHaltsLaterDownloadsUntilRecovered() throws Exception { + File root = temporary.newFolder(); + File journal = new File(root, "receipts.json"); + SyncEngine engine = new SyncEngine(root, true); + Transfer transfer = new Transfer(); + SyncEngine.Shot third = shot("123/456/20261024120002_3.jpg"); + IOException unavailable = new IOException("permanent Gallery failure"); + AtomicReference pendingJournal = new AtomicReference<>(); + Gallery gallery = new Gallery() { + @Override public boolean export(SyncEngine.Shot shot, File file, + java.util.function.BooleanSupplier eligible) throws Exception { + if (shot.id.equals(FIRST)) throw unavailable; + if (shot.id.equals(SECOND) && pendingJournal.get() == null) { + assertTrue(eligible.getAsBoolean()); + pendingJournal.set(Files.readAllBytes(journal.toPath())); + Files.delete(journal.toPath()); Files.createDirectory(journal.toPath()); + } + return true; + } + }; + reconcile(engine, transfer, gallery); + reconcile(engine, transfer, gallery, A, B, third); + IOException fatal = fails(() -> reconcile(engine, transfer, gallery, A, B, third)); + assertNotSame(unavailable, fatal.getCause()); // Journal failure supersedes deferred Gallery failure. + assertTrue(journal.isDirectory()); + assertEquals(Arrays.asList(FIRST, SECOND), transfer.downloads); + assertEquals("pending", item(engine, SECOND).destination); + assertFalse(item(engine, third.id).file.exists()); + Files.delete(journal.toPath()); Files.write(journal.toPath(), pendingJournal.get()); + SyncEngine relaunched = new SyncEngine(root, true); + assertSame(unavailable, fails(() -> reconcile(relaunched, transfer, gallery, A, B, third)).getCause()); + assertEquals(Arrays.asList(FIRST, SECOND, third.id), transfer.downloads); + assertEquals("saved", item(relaunched, SECOND).destination); + assertEquals("saved", item(relaunched, third.id).destination); + assertEquals("pending", item(relaunched, FIRST).destination); + } + + @Test public void fatalOriginalInstallationFailureHaltsLaterDownloads() throws Exception { + File root = temporary.newFolder(); + SyncEngine engine = new SyncEngine(root, false); + Transfer transfer = new Transfer() { + @Override public void download(SyncEngine.Shot shot, File file) throws Exception { + super.download(shot, file); + if (shot.id.equals(FIRST)) Files.write(new File(root, "originals").toPath(), new byte[]{1}); + } + }; + Gallery gallery = new Gallery(); + reconcile(engine, transfer, gallery); + reconcile(engine, transfer, gallery, A, B); + IOException fatal = fails(() -> reconcile(engine, transfer, gallery, A, B)); + assertEquals("Invalid originals directory", fatal.getMessage()); + assertEquals(Collections.singletonList(FIRST), transfer.downloads); + assertFalse(item(engine, FIRST).file.exists()); + assertFalse(item(engine, SECOND).file.exists()); + } + + @Test public void lateRetryableFailureAfterStopCannotChangeFreshJournalOrSurfaceAnError() throws Exception { + File root = temporary.newFolder(); + File journal = new File(root, "receipts.json"); + SyncEngine engine = new SyncEngine(root, false); + Transfer fresh = new Transfer(); Gallery gallery = new Gallery(); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + Transfer slow = new Transfer() { + @Override public void download(SyncEngine.Shot shot, File file) throws Exception { + entered.countDown(); await(release); + throw new IOException("late transfer failure"); + } + }; + reconcile(engine, fresh, gallery); + reconcile(engine, fresh, gallery, A, B); + try (Async async = new Async(() -> reconcile(engine, slow, gallery, A, B))) { + byte[] saved; + try { + await(entered); engine.stop(); + reconcile(engine, fresh, gallery, A, B); + saved = Files.readAllBytes(journal.toPath()); + } finally { release.countDown(); } + async.finish(); + assertArrayEquals(saved, Files.readAllBytes(journal.toPath())); + } + assertEquals(Arrays.asList(FIRST, SECOND), fresh.downloads); + assertTrue(item(engine, FIRST).file.isFile()); + assertTrue(item(engine, SECOND).file.isFile()); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..e767b62 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1 @@ +plugins { id 'com.android.application' version '9.1.0' apply false } diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..1edd5f5 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..fcb845c --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/scripts/make_assets.py b/android/scripts/make_assets.py new file mode 100644 index 0000000..2256d57 --- /dev/null +++ b/android/scripts/make_assets.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Use the same reproducible headset bundle as the iPhone app.""" +import pathlib +import subprocess +import sys + +root = pathlib.Path(__file__).resolve().parents[2] +out = pathlib.Path(sys.argv[1]).resolve() +out.mkdir(parents=True, exist_ok=True) +version = subprocess.check_output( + [sys.executable, str(root / 'ios/scripts/make_frame_bundle.py'), + str(out / 'frame-bundle.tar.gz')], cwd=root, text=True).strip().splitlines()[-1] +(out / 'frame-bundle.version').write_text(version + '\n') diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..82045b8 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,4 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } } +rootProject.name = 'FrameControl' +include ':app' diff --git a/docs/android-phone.md b/docs/android-phone.md new file mode 100644 index 0000000..8f937f0 --- /dev/null +++ b/docs/android-phone.md @@ -0,0 +1,85 @@ +# Frame Control for Android phones + +The native Android companion lives in `android/`. It requires Android 10 or +later and a Steam Frame with Developer Mode and SSH enabled. This is a new +implementation, separate from the headset's `frame/android` Lepton tools. + +## Pair and sync + +1. Enter the headset address, SSH port and username (normally `steamos`). +2. Choose **Pair headset**, compare the displayed SSH fingerprint with the + headset's identity, and accept it. Enter the Developer Mode password once. + The app installs this phone's SSH public key and verifies key login before + storing the pairing. The private key stays in Android Keystore; the + password is not saved. A changed headset host key is refused. +3. Choose **Start syncing** and allow notifications. The persistent notification + shows the active connected-device session and offers **Stop syncing**. +4. Take a new screenshot. Its original downloads into Frame Control. **Save + screenshots to Gallery** is enabled by default and adds it to + `Pictures/Frame Control`. Android 10+ lets an app create its own Gallery + items without broad photo-reading permission. + +The first successful listing establishes a history baseline. Existing screenshots +are not imported automatically. The app waits for two equal size/time listings, +checks the original's byte count and image decode, and saves it atomically. +Identity includes the paired headset, Steam account, game and filename. + +Turning Gallery off keeps automatic downloads inside Frame Control. Pending +exports become app-only; enabling it again does not export those earlier items. +Previously saved Gallery items stay there. Original files remain available from +**Open / share original** when disconnected. The app keeps each headset's +library separately. + +Gallery export uses a deterministic app-owned MediaStore row. A pending row is +rewritten after interruption, and a completed row is recognized before inserting +another. A failed export leaves the app original available for retry. Preferences +and the final Gallery commit are serialized so a disabled setting is respected. + +**Headset controls** opens the shared Frame Control web interface through a +loopback-only SSH tunnel. The server has a fresh session key and listens only on +the headset's loopback interface. The tunnel and server end with the phone's +SSH session. This initial Android shell does not implement all iPhone-specific +web bridges; native screenshot originals use the downloads screen. + +## Background behavior and release boundary + +Sync is an explicit, user-started Android `connectedDevice` foreground service. +It can continue after leaving the app while its notification is present and +reconnects when the headset returns. It is not a Firebase push receiver. +Android battery-saving/Doze, loss of network, force-stop or process termination +can delay or end the session. The app does not request battery-optimization +exemption, use unrelated background modes, or restart itself after force-stop. +Restart sync from the app if the session ends. + +The full cross-platform request still requires actual locked-phone background +proof. iOS APNs delivery is a separate unimplemented dependency. An emulator +test is not evidence that either physical platform meets that requirement. +Do not publish this as a verified always-on screenshot feature. + +## Build and verify + +Use JDK 17 and an Android SDK containing platform 36/build-tools 36.0.0: + +```sh +export JAVA_HOME=/path/to/jdk17 +export ANDROID_HOME=/path/to/android-sdk +./android/gradlew -p android assembleDebug testDebugUnitTest lintDebug +``` + +The Gradle wrapper pins version 9.4.1 and its distribution checksum. The build +generates the same reproducible headset bundle as iOS, including the existing +SHA-256-checked KDE Connect package fetch. Generated assets and build outputs +are ignored. No signing key, headset password or machine-specific SDK path is +committed. `assembleDebug` produces `android/app/build/outputs/apk/debug/app-debug.apk`. +It uses a development signing identity, not a configured Play release key. + +`assembleDebugAndroidTest` builds instrumentation for real MediaStore round-trip, +retry recovery and app-only behavior. Optional real-headset hooks exist only in +the separate test APK (`ScreenshotDeviceTest`), not in the product app. They +provision a public test key and permit original/Gallery hash readback without +adding a product authentication bypass. Remove the temporary headset key and +fixtures after live tests. + +No Play app creation, store upload, invitation or public release is part of a +debug build. Preserve a dedicated release signing identity and verify physical +devices before a production release. diff --git a/docs/evidence/android-phone-screenshot-sync-2026-10-24.md b/docs/evidence/android-phone-screenshot-sync-2026-10-24.md new file mode 100644 index 0000000..afff86d --- /dev/null +++ b/docs/evidence/android-phone-screenshot-sync-2026-10-24.md @@ -0,0 +1,168 @@ +# Android phone screenshot sync evidence — 2026-10-24 + +Work: [#108](https://github.com/saphid/frame-control/issues/108). The user +authorized a new Android companion after the earlier APK could not be located. +This is a development build, not a production or Play release. + +## Implemented + +Native Android 10+ companion in `android/`, with pinned SSH transport and a +non-exportable Android Keystore phone key. Pairing checks the host fingerprint +before accepting a password and verifies key login before saving the pairing. +The shared headset server runs through a session-bound loopback tunnel. + +All retained libraries receive Gallery opt-out, including previously paired +headsets. Paging keeps older offline originals reachable beyond the first 100. +A failed image or Gallery export retries independently without starving later +downloads; local persistence errors still halt immediately. + +The user starts a connected-device foreground service with an ongoing Stop +notification. New screenshot originals download automatically. Gallery is ON +by default; turning it off latches pending and newly discovered items to +app-only, including across relaunch and later re-enabling. Historical captures +are baselined instead of imported. Receipts, original files, and MediaStore +recovery support interrupted downloads/exports without duplicate Gallery rows. + +See [usage and background limits](../android-phone.md). + +## Build and automated checks + +- JDK 17, Gradle 9.4.1, AGP 9.1.0; compile/target API 36, minimum API 29. +- `assembleDebug testDebugUnitTest assembleDebugAndroidTest lintDebug` exited + zero after review fixes: `/tmp/frame-108-android-build-final-r5.log`. +- 34 JVM sync-engine tests, zero failures/errors/skips. Tests cover baseline, + stability, identity, metadata limits, destination latching, retries, atomic + receipts, corrupt state, unsafe paths, late cancellation callbacks, per-item + failure isolation and fatal persistence errors. +- API 36 arm64 emulator MediaStore tests passed: original byte round-trip, + app-only across relaunch, interrupted pending row recovery and retry dedup. + The final rebuilt APK passed **five selected native checks, no skips**: + three MediaStore/multiple-library cases plus the actual headset fixture + Gallery hash (`/tmp/frame-108-android-final-device-r4.log`, four tests), then + app-only original/no-Gallery readback + (`/tmp/frame-108-android-final-apponly-device-r4.log`, one test). +- Existing server suite: 1,333 tests, 11 skips, exit zero, + `/tmp/frame-108-android-server-tests.log`. Website: 29 passed, zero skips, + `/tmp/frame-108-android-site-tests.log`. +- Lint has no errors. Warnings include dependency/target freshness, English UI + literals, backup-version compatibility, and the shared controls' JavaScript. + +## Actual headset → Android emulator → Gallery + +Target: task-owned headless API 36 emulator `emulator-5588`, with the actual +Steam Frame reached over SSH. Initial baseline contained nine historical +captures and imported none. An emulator-generated public test key was installed +temporarily through the existing trusted SSH alias; no password was exposed. +The unauthenticated native host-key probe matched the trusted headset key. + +Synthetic fixture IDs use a separate test account/game namespace; this verifies +new-file delivery from the real headset, not the hardware capture shortcut. + +| Check | Result | +| --- | --- | +| Gallery default ON | `999999999990/108/20261024100101_1.png` automatically downloaded and receipt became `saved` | +| Source / app / Gallery SHA-256 | `c9411d718c8a3477b7c7a6eed5ab827570605fce977bdeef5aaf11d29ba88316` | +| MediaStore dedup | Exactly one matching Gallery row; native test reads its original bytes | +| Shared headset controls | Actual authenticated WebView loaded the headset app library | +| Gallery OFF, app on Home | Second fixture automatically downloaded in 4.293 seconds (one synthetic trial) | +| App-only source / app SHA-256 | `6a1513b2dc3459b457f7dd86adcde7298d99dccc31c73904d7c6698703edb961` | +| App-only independent readback | One selected native test passed: correct app original and no matching Gallery row | +| Re-enable and restart | App-only receipt and absent Gallery row preserved; first image remains saved, no duplicate | +| Open/share original | Native chooser opened for the app-only original and was canceled; nothing sent | +| Final r5 APK, Gallery ON while Home | Third new fixture `999999999990/108/20261024101413_1.png` reached Gallery in 5.288 seconds (one synthetic trial); same source/app/Gallery hash as the first fixture, one matching row | + +Background readback: `/tmp/frame-108-android-background-device-r1.log`. +Home was foreground with the app service active; the screen stayed on. This +is not a locked/Doze or long-duration physical-phone test. + +The final r5 background Gallery readback passed separately (one selected native +test, no skip): `/tmp/frame-108-android-final-background-gallery-r5.log`. + +Sanitized [machine readback](android-phone-screenshot-sync-2026-10-24/live-proof.json) +is committed; local full readback: `/tmp/frame-108-android-live-proof.json`. Fixture source +metadata: `/tmp/frame-108-android-fixtures.json`. The optional provisioning test +passed separately. Earlier full-class instrumentation runs included two +optional skipped hooks; those are not counted as four executed scenarios. + +![Native saved receipt with Gallery enabled](android-phone-screenshot-sync-2026-10-24/gallery-on.png) + +![Gallery re-enabled without backfilling app-only downloads](android-phone-screenshot-sync-2026-10-24/reenabled.png) + +![Native connection and Gallery default](android-phone-screenshot-sync-2026-10-24/setup.png) + +![Shared controls loaded from the real headset](android-phone-screenshot-sync-2026-10-24/controls.png) + +## Verification boundaries + +No physical Android phone, Doze reliability, hardware screenshot latency, +password-pairing UI, Play signing identity or production upload has been +verified. Android sync uses an explicit foreground session, not Firebase. +The iOS foreground implementation has separate +[evidence](phone-screenshot-sync-2026-10-24.md); APNs, its delivery service, +notification extension and locked-phone Photos export remain unimplemented or +unverified. The user's full-feature tested-and-working release condition is +not satisfied. + +T3's device tools incorrectly inferred the Android SDK from the platform-tools +Cask and could not open this emulator. The Astra worker used the app-owned +agent-device launcher with a task-local session/config and the correct SDK. +No global SDK/T3 settings or physical devices were changed. + +UI verification: provider `codex`, model `gpt-6-astra`, completed task +`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-ui-astra-20261024-r1`. +Report: `/tmp/frame-108-android-ui-report.md`. + +## Review and final artifacts + +The APK is `android/app/build/outputs/apk/debug/app-debug.apk`, signed with the +development identity. No store upload or release has been performed. + +Final r6 APK SHA-256: `e6a99a35154c21293975ea165eb967d8cc7ad84565f063255c3848ada1169f6e`. + +Preferred different-model review (`claudeAgent` / `claude-opus-5-5`) failed +before work with `Claude API rate limit reached. Try again later.` Task: +`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-review-opus-20261024-r1`. +Implementation and independent fresh fallback reviews used Sol; they are not +different-model evidence. + +Provider `codex_secondary`, model `gpt-6.1-sol`, high, completed round 1: +`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-review-sol-20261024-r1`. +All four findings were accepted and fixed: failed-item starvation, API-29 +pending-row recovery query, opt-out for other retained headset libraries, and +inaccessible originals beyond the latest 100. Report: +`/tmp/frame-108-android-review-sol-r1.md`. No CODING_STANDARDS.md was found in +the task or primary worktree; CONTRIBUTING.md was included in review context. + +Provider `codex_secondary`, model `gpt-6.1-sol`, high, completed round 2: +`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-review-sol-20261024-r2`. +It cleared all four prior fixes and independently reran 34 JVM tests, then found +a library-switch cache identity issue: matching receipt fields could retain a +Share action bound to the previous headset. The refresh signature now includes +the absolute original path, including the headset library root. + +The final r6 build (`assembleDebug assembleDebugAndroidTest lintDebug`) exited +zero: `/tmp/frame-108-android-build-r6.log`. A new native regression launches +the real activity, switches between two libraries with matching receipt fields +but different original bytes, and verifies that Share now selects B’s bytes. +The chooser is intercepted; nothing is sent externally. This regression and +the three existing MediaStore/multiple-library cases passed together: **four +selected tests, no skips**, `/tmp/frame-108-android-final-device-r6.log`. Core +code is unchanged from the 34 independently passing JVM tests. The r6 change +does not alter the r5 transfer/storage path exercised against the real headset. + +Provider `codex_secondary`, model `gpt-6.1-sol`, high, completed round 3: +`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-review-sol-20261024-r3`. +**No actionable findings** in the frozen 25-file executable target (`android/` +and `.github/workflows/android.yml`), with the remaining share-binding finding +resolved. All executable fingerprints stayed unchanged during review. Report: +`/tmp/frame-108-android-review-sol-r3.md`. The review inspected the passing r6 +native/build logs and unchanged core covered by round 2’s independent 34-test +run; it did not claim new device runs or release validation. + +Cleanup removed the one exact temporary public key and all three exact synthetic +headset fixture paths, verified absent. Other authorized keys and real captures +were preserved. Readback: `/tmp/frame-108-android-cleanup.json`. The task-owned +`emulator-5588` was shut down after the live tests, then restarted only for +the r6 share-binding regression and shut down again; +no other device was stopped. Build artifacts, the AVD and local test logs remain +available for follow-up verification. diff --git a/docs/evidence/android-phone-screenshot-sync-2026-10-24/cleanup.json b/docs/evidence/android-phone-screenshot-sync-2026-10-24/cleanup.json new file mode 100644 index 0000000..e03777d --- /dev/null +++ b/docs/evidence/android-phone-screenshot-sync-2026-10-24/cleanup.json @@ -0,0 +1 @@ +{"removedTestKeys": 1, "removedFixtures": ["999999999990/108/20261024100101_1.png", "999999999990/108/20261024100102_1.png", "999999999990/108/20261024101413_1.png"], "testKeyAbsent": true, "allFixturesAbsent": true} diff --git a/docs/evidence/android-phone-screenshot-sync-2026-10-24/controls.png b/docs/evidence/android-phone-screenshot-sync-2026-10-24/controls.png new file mode 100644 index 0000000..fdb486a Binary files /dev/null and b/docs/evidence/android-phone-screenshot-sync-2026-10-24/controls.png differ diff --git a/docs/evidence/android-phone-screenshot-sync-2026-10-24/gallery-off-setup.png b/docs/evidence/android-phone-screenshot-sync-2026-10-24/gallery-off-setup.png new file mode 100644 index 0000000..8248545 Binary files /dev/null and b/docs/evidence/android-phone-screenshot-sync-2026-10-24/gallery-off-setup.png differ diff --git a/docs/evidence/android-phone-screenshot-sync-2026-10-24/gallery-off.png b/docs/evidence/android-phone-screenshot-sync-2026-10-24/gallery-off.png new file mode 100644 index 0000000..71fbf4c Binary files /dev/null and b/docs/evidence/android-phone-screenshot-sync-2026-10-24/gallery-off.png differ diff --git a/docs/evidence/android-phone-screenshot-sync-2026-10-24/gallery-on.png b/docs/evidence/android-phone-screenshot-sync-2026-10-24/gallery-on.png new file mode 100644 index 0000000..676ddbe Binary files /dev/null and b/docs/evidence/android-phone-screenshot-sync-2026-10-24/gallery-on.png differ diff --git a/docs/evidence/android-phone-screenshot-sync-2026-10-24/live-proof.json b/docs/evidence/android-phone-screenshot-sync-2026-10-24/live-proof.json new file mode 100644 index 0000000..59ceaba --- /dev/null +++ b/docs/evidence/android-phone-screenshot-sync-2026-10-24/live-proof.json @@ -0,0 +1,57 @@ +{ + "gallery": { + "id": "999999999990/108/20261024100101_1.png", + "sha256": "c9411d718c8a3477b7c7a6eed5ab827570605fce977bdeef5aaf11d29ba88316", + "bytes": 3620, + "createdAt": 1792836168.6100879, + "receipt": { + "destination": "saved", + "observations": 2, + "shot": { + "id": "999999999990/108/20261024100101_1.png", + "size": 3620, + "time": 1792836168.6217859 + }, + "state": "downloaded" + }, + "readbackAt": 1792836209.819923 + }, + "appOnly": { + "id": "999999999990/108/20261024100102_1.png", + "sha256": "6a1513b2dc3459b457f7dd86adcde7298d99dccc31c73904d7c6698703edb961", + "bytes": 3623, + "createdAt": 1792836368.616906, + "background": "Home foreground; connectedDevice service active", + "receipt": { + "destination": "appOnly", + "observations": 2, + "shot": { + "id": "999999999990/108/20261024100102_1.png", + "size": 3623, + "time": 1792836368.6332257 + }, + "state": "downloaded" + }, + "readbackAt": 1792836372.909552, + "elapsedSeconds": 4.293 + }, + "finalBackgroundGallery": { + "id": "999999999990/108/20261024101413_1.png", + "sha256": "c9411d718c8a3477b7c7a6eed5ab827570605fce977bdeef5aaf11d29ba88316", + "bytes": 3620, + "createdAt": 1792836871.5926661, + "background": "Home foreground; connectedDevice service active; final r5 APK", + "receipt": { + "destination": "saved", + "observations": 2, + "shot": { + "id": "999999999990/108/20261024101413_1.png", + "size": 3620, + "time": 1792836871.6072419 + }, + "state": "downloaded" + }, + "readbackAt": 1792836876.8808918, + "elapsedSeconds": 5.288 + } +} diff --git a/docs/evidence/android-phone-screenshot-sync-2026-10-24/reenabled.png b/docs/evidence/android-phone-screenshot-sync-2026-10-24/reenabled.png new file mode 100644 index 0000000..7a65e4a Binary files /dev/null and b/docs/evidence/android-phone-screenshot-sync-2026-10-24/reenabled.png differ diff --git a/docs/evidence/android-phone-screenshot-sync-2026-10-24/setup.png b/docs/evidence/android-phone-screenshot-sync-2026-10-24/setup.png new file mode 100644 index 0000000..6783ccd Binary files /dev/null and b/docs/evidence/android-phone-screenshot-sync-2026-10-24/setup.png differ diff --git a/docs/evidence/phone-screenshot-sync-2026-10-24.md b/docs/evidence/phone-screenshot-sync-2026-10-24.md index acb6331..316448e 100644 --- a/docs/evidence/phone-screenshot-sync-2026-10-24.md +++ b/docs/evidence/phone-screenshot-sync-2026-10-24.md @@ -111,8 +111,9 @@ Visual inspection used provider `codex`, `gpt-6-astra`, completed task location was found in the bounded checks. Existing App Store Connect signing access is not an APNs provider key. - Physical locked/background iPhone download and Photos export remain unproven. -- No Android phone project was found in the inspected repository/main/iPhone - branch. Its location or scope for a new companion remains unresolved. - Android emulator tooling is unavailable in this environment. +- A new native Android companion was subsequently authorized and implemented. + See [Android phone](../android-phone.md) for its explicit foreground-service + behavior and [Android evidence](android-phone-screenshot-sync-2026-10-24.md) + for the build and emulator checks. This does not supply physical-device proof. - No TestFlight upload, store submission or release was performed. The user's release condition requires the full behavior to be tested and working.