Add Android phone companion with automatic screenshot downloads

This commit is contained in:
saphid committed 2026-10-24 21:22:57 +11:00
1 parent ce27c0e8f1
commit ae9df35442
37 files changed
+3930 -3

No files matched your search

+22
View File
@@ -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
+5
View File
@@ -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 |
+5
View File
@@ -0,0 +1,5 @@
.gradle/
**/build/
local.properties
*.keystore
*.jks
+35
View File
@@ -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'
}
@@ -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<Uri> 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<java.nio.file.Path> 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<Intent> 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<java.nio.file.Path> 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();
}
}
+19
View File
@@ -0,0 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<application android:label="Frame Control" android:theme="@style/AppTheme"
android:icon="@drawable/ic_frame" android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction"
android:networkSecurityConfig="@xml/network_security" android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter>
</activity>
<service android:name=".SyncService" android:exported="false" android:foregroundServiceType="connectedDevice" />
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.files" android:exported="false" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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<SyncEngine.Shot> 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<SyncEngine.Shot> 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();
}
}
@@ -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;
}
}
}
@@ -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<Uri[]> 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<Uri[]> 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<SyncEngine.Item> 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();
}
}
@@ -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. */
}
}
@@ -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<Shot>() {
@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<String> 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<String, Receipt> 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<Path> 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<Path> 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<Shot> 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<String, Shot> 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<String> 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<Item> items() {
synchronized (lock) {
List<Item> 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<String, Shot> listing(List<Shot> shots) throws IOException {
if (shots == null) throw new IOException("Missing screenshot listing");
Map<String, Shot> 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<String, Receipt> 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<String, Receipt> 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.
}
}
}
@@ -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<SyncEngine.Item> 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;
}
}
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#7CD8C3" android:pathData="M8,12L40,12L44,30L34,36L26,28L22,28L14,36L4,30ZM11,17L9,27L14,29L20,23L28,23L34,29L39,27L37,17Z" />
</vector>
@@ -0,0 +1,9 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#7CD8C3</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowBackground">#111819</item>
<item name="android:navigationBarColor">#111819</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<data-extraction-rules>
<cloud-backup><exclude domain="root" path="." /><exclude domain="file" path="." /><exclude domain="database" path="." /><exclude domain="sharedpref" path="." /><exclude domain="external" path="." /></cloud-backup>
<device-transfer><exclude domain="root" path="." /><exclude domain="file" path="." /><exclude domain="database" path="." /><exclude domain="sharedpref" path="." /><exclude domain="external" path="." /></device-transfer>
</data-extraction-rules>
@@ -0,0 +1 @@
<paths><files-path name="screenshots" path="screenshots/" /></paths>
@@ -0,0 +1,4 @@
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true"><domain includeSubdomains="false">127.0.0.1</domain><domain includeSubdomains="false">localhost</domain></domain-config>
</network-security-config>
@@ -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<String> 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<Throwable> 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<byte[]> 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<byte[]> 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());
}
}
+1
View File
@@ -0,0 +1 @@
plugins { id 'com.android.application' version '9.1.0' apply false }
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
Binary file not shown.
+8
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
+93
View File
@@ -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
+13
View File
@@ -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')
+4
View File
@@ -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'
+85
View File
@@ -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.
@@ -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.
@@ -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}
Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

@@ -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.