mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 12:02:05 +01:00
Add Android phone companion with automatic screenshot downloads
This commit is contained in:
1 parent
ce27c0e8f1
commit
ae9df35442
37 files changed
+3930
-3
No files matched your search
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
*.keystore
|
||||
*.jks
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
plugins { id 'com.android.application' version '9.1.0' apply false }
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
+248
@@ -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" "$@"
|
||||
Vendored
+93
@@ -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
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
Reference in new issue
Block a user