diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..9001e51 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,136 @@ +name: Android sync +on: [push] +jobs: + e2e: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + api-level: [30, 35] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Install Ant + run: sudo apt-get update && sudo apt-get install -y ant + + - name: Build host Peergos fat jar + run: cd web-ui && ant dist + + - name: Enable KVM + run: | + if [ ! -e /dev/kvm ]; then + echo "::error::/dev/kvm not present on this runner" + exit 1 + fi + sudo chmod 666 /dev/kvm + ls -la /dev/kvm + + - name: Start Peergos server on host + run: | + export PEERGOS_HOST_DIR=$(mktemp -d) + nohup java -jar web-ui/server/Peergos.jar pki-init \ + -port 8000 \ + -proxy-target /ip4/127.0.0.1/tcp/5556 \ + -gateway-port 8090 \ + -ipfs-api-address /ip4/127.0.0.1/tcp/5001 \ + -ipfs-gateway-address /ip4/127.0.0.1/tcp/8080 \ + -ipfs-swarm-port 4001 \ + -ipfs.metrics.port 6001 \ + -useIPFS false \ + -listen-host 0.0.0.0 \ + -admin-usernames peergos \ + -logToConsole true \ + -enable-gc false \ + -default-quota 10737418240 \ + -quota-upload-limit-seconds 1 \ + max-users 10000 \ + max-daily-signups 20000 \ + PEERGOS_PATH "$PEERGOS_HOST_DIR" \ + peergos.password testpassword \ + pki.keygen.password testpkipassword \ + pki.keyfile.password testpassword > peergos-host.log 2>&1 & + echo $! > peergos-host.pid + echo "Waiting for server to come up..." + for i in $(seq 1 180); do + if ! kill -0 $(cat peergos-host.pid) 2>/dev/null; then + echo "::error::peergos server exited early" + tail -50 peergos-host.log + exit 1 + fi + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 http://localhost:8000/ || echo 000) + if [ "$code" != "000" ]; then + echo "Server ready (HTTP $code) after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::peergos server did not respond on :8000 within 180s" + tail -80 peergos-host.log + exit 1 + + - uses: gradle/actions/setup-gradle@v4 + + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: ${{ matrix.api-level == 30 && 'google_apis' || 'default' }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -partition-size 8192 + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run instrumented tests + uses: reactivecircus/android-emulator-runner@v2 + timeout-minutes: 60 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: ${{ matrix.api-level == 30 && 'google_apis' || 'default' }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -partition-size 8192 + disable-animations: true + script: | + adb logcat -c + adb logcat -v threadtime > logcat.txt & + LOGCAT_PID=$! + set +e + ./gradlew connectedDebugAndroidTest --info -Pandroid.testInstrumentationRunnerArguments.peergosHost=10.0.2.2 -Pandroid.testInstrumentationRunnerArguments.peergosPort=8000 + STATUS=$? + kill $LOGCAT_PID 2>/dev/null || true + exit $STATUS + + - name: Stop Peergos server + if: always() + run: | + if [ -f peergos-host.pid ]; then + kill $(cat peergos-host.pid) 2>/dev/null || true + fi + + - if: always() + uses: actions/upload-artifact@v4 + with: + name: android-test-reports-api${{ matrix.api-level }} + path: | + app/build/reports/androidTests/connected/** + app/build/outputs/androidTest-results/connected/** + logcat.txt + peergos-host.log diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..333786d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "web-ui"] + path = web-ui + url = https://github.com/peergos/web-ui diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a52dcc6..3693f35 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { applicationId = "peergos.android" minSdk = 30 targetSdk = 35 - versionCode = 51 - versionName = "1.29.2" + versionCode = 55 + versionName = "1.29.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { @@ -41,6 +41,20 @@ android { version = "3.22.1" } } + packaging { + resources { + excludes += setOf( + "META-INF/*.SF", + "META-INF/*.DSA", + "META-INF/*.RSA", + "META-INF/NOTICE*", + "META-INF/LICENSE*", + "META-INF/DEPENDENCIES", + "META-INF/INDEX.LIST", + "META-INF/MANIFEST.MF" + ) + } + } } dependencies { @@ -60,6 +74,9 @@ dependencies { implementation("androidx.core:core-ktx:1.16.0") implementation("com.yubico.yubikit:android:3.0.1") implementation("com.yubico.yubikit:fido:3.0.1") + implementation("androidx.security:security-crypto:1.1.0") { + exclude(group = "com.google.code.gson", module = "gson") + } testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/app/libs/Peergos.jar b/app/libs/Peergos.jar index db4a69e..8844850 100644 Binary files a/app/libs/Peergos.jar and b/app/libs/Peergos.jar differ diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..6caab19 --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/androidTest/java/peergos/android/AndroidTruncateTest.java b/app/src/androidTest/java/peergos/android/AndroidTruncateTest.java new file mode 100644 index 0000000..9a13777 --- /dev/null +++ b/app/src/androidTest/java/peergos/android/AndroidTruncateTest.java @@ -0,0 +1,78 @@ +package peergos.android; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.provider.DocumentsContract; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.ByteArrayInputStream; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Random; + +import peergos.server.Main; +import peergos.shared.Crypto; +import peergos.shared.user.fs.ResumeUploadProps; + +/** + * Regression test for AndroidSyncFileSystem.truncate. + * + * truncate opens the file descriptor to shrink it; the mode string must be a real + * ParcelFileDescriptor mode. A bare "t" is rejected by ParcelFileDescriptor.parseMode + * (a mode must start with r/w), so truncate throws and shrink-syncs to an Android + * device fail — the local file keeps its stale trailing bytes. This drives truncate + * through the same SAF path the sync uses (DocumentFile over TestDocumentsProvider). + */ +@RunWith(AndroidJUnit4.class) +public class AndroidTruncateTest { + + private Context ctx; + private Crypto crypto; + private AndroidSyncFileSystem fs; + + @Before + public void setUp() { + ctx = InstrumentationRegistry.getInstrumentation().getTargetContext(); + crypto = Main.initCrypto(new ScryptAndroid()); + + // Wipe the provider's root (count=0 seeds nothing). + Uri authorityUri = Uri.parse("content://" + TestDocumentsProvider.AUTHORITY); + Bundle seed = new Bundle(); + seed.putInt("count", 0); + seed.putLong("seed", 1); + seed.putLong("minSize", 0); + seed.putLong("maxSize", 0); + ctx.getContentResolver().call(authorityUri, TestDocumentsProvider.METHOD_RESET_AND_SEED, null, seed); + + Uri treeUri = DocumentsContract.buildTreeDocumentUri( + TestDocumentsProvider.AUTHORITY, TestDocumentsProvider.ROOT_ID); + fs = new AndroidSyncFileSystem(treeUri, ctx, crypto); + } + + @Test + public void truncateShrinksFile() throws Exception { + int size = 200_000; + byte[] data = new byte[size]; + new Random(42).nextBytes(data); + + Path p = fs.resolve("doc.bin"); + fs.setBytes(p, 0, + new AndroidAsyncReader(new ByteArrayInputStream(data), () -> new ByteArrayInputStream(data)), + size, Optional.empty(), Optional.empty(), Optional.empty(), + ResumeUploadProps.random(crypto), () -> false, s -> {}); + Assert.assertEquals(size, fs.size(p)); + + long newSize = size / 2; + fs.truncate(p, newSize); // throws on the current code (invalid mode "t") + + Assert.assertEquals(newSize, fs.size(p)); + } +} diff --git a/app/src/androidTest/java/peergos/android/SyncEndToEndTest.java b/app/src/androidTest/java/peergos/android/SyncEndToEndTest.java new file mode 100644 index 0000000..2a438f3 --- /dev/null +++ b/app/src/androidTest/java/peergos/android/SyncEndToEndTest.java @@ -0,0 +1,161 @@ +package peergos.android; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.provider.DocumentsContract; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.net.URL; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import peergos.server.Main; +import peergos.server.sync.DirectorySync; +import peergos.server.sync.JdbcTreeState; +import peergos.server.sync.PeergosSyncFS; +import peergos.shared.Crypto; +import peergos.shared.NetworkAccess; +import peergos.shared.corenode.CoreNode; +import peergos.shared.crypto.hash.PublicKeyHash; +import peergos.shared.storage.ContentAddressedStorage; +import peergos.shared.user.HttpPoster; +import peergos.shared.user.LinkProperties; +import peergos.shared.user.UserContext; +import peergos.shared.user.fs.FileProperties; +import peergos.shared.user.fs.FileWrapper; +import peergos.shared.user.fs.ThumbnailGenerator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** End-to-end sync test against a Peergos server running on the host. + * The emulator reaches the host via 10.0.2.2; the server itself runs on a + * normal JVM so we sidestep Android-incompatible bits of peergos.server + * (java.net.http, sun.net.httpserver, sqlite-jdbc Linux natives). + * Host/port come from instrumentation args (set by the workflow). */ +@RunWith(AndroidJUnit4.class) +public class SyncEndToEndTest { + + private static Crypto crypto; + private static NetworkAccess network; + + @BeforeClass + public static void connectToHostServer() throws Exception { + Bundle args = InstrumentationRegistry.getArguments(); + String host = args.getString("peergosHost", "10.0.2.2"); + int port = Integer.parseInt(args.getString("peergosPort", "8000")); + URL serverUrl = new URL("http://" + host + ":" + port); + + crypto = Main.initCrypto(new ScryptAndroid()); + ThumbnailGenerator.setInstance(new AndroidImageThumbnailer()); + HttpPoster poster = new AndroidPoster(serverUrl, false, + Optional.empty(), Optional.of("peergos-android-test")); + ContentAddressedStorage localDht = NetworkAccess.buildLocalDht(poster, true, crypto.hasher); + CoreNode core = NetworkAccess.buildDirectCorenode(poster); + network = NetworkAccess.buildViaPeergosInstance(poster, poster, localDht, + 5_000, crypto.hasher, false).join(); + } + + @Test + public void serverIsReachable() { + assertNotNull(network.dhtClient.id().join()); + } + + @Test + public void signUpRoundTrip() { + String username = freshUsername(); + UserContext ctx = UserContext.signUp(username, "test-password", "", network, crypto).join(); + assertNotNull(ctx); + assertEquals(username, ctx.username); + } + + /** Push a directory of random-bytes "images" through DirectorySync to a + * fresh remote dir, then assert every file made it across. + * File count is configurable via the syncFileCount instrumentation arg + * (default 1000); size is uniform in [2 MiB, 5 MiB]. */ + @Test + public void largeDirSyncToPeergos() throws Exception { + Bundle args = InstrumentationRegistry.getArguments(); + int count = Integer.parseInt(args.getString("syncFileCount", "100")); + long minSize = 2L * 1024 * 1024; + long maxSize = 5L * 1024 * 1024; + long seed = 42L; + + Context appCtx = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + Uri authorityUri = Uri.parse("content://" + TestDocumentsProvider.AUTHORITY); + Bundle seedArgs = new Bundle(); + seedArgs.putInt("count", count); + seedArgs.putLong("seed", seed); + seedArgs.putLong("minSize", minSize); + seedArgs.putLong("maxSize", maxSize); + appCtx.getContentResolver().call(authorityUri, + TestDocumentsProvider.METHOD_RESET_AND_SEED, null, seedArgs); + + String username = freshUsername(); + UserContext userCtx = UserContext.signUp(username, "test-password", "", network, crypto).join(); + String syncFolderName = "synced-images"; + userCtx.getUserRoot().join() + .mkdir(syncFolderName, network, false, userCtx.mirrorBatId(), crypto).join(); + String peergosPath = "/" + username + "/" + syncFolderName; + + LinkProperties link = DirectorySync.init(userCtx, peergosPath); + String cap = link.toLinkString(userCtx.signer.publicKeyHash); + PeergosSyncFS remote = DirectorySync.buildRemote(cap, network, crypto); + + Uri treeUri = DocumentsContract.buildTreeDocumentUri( + TestDocumentsProvider.AUTHORITY, TestDocumentsProvider.ROOT_ID); + AndroidSyncFileSystem local = new AndroidSyncFileSystem(treeUri, appCtx, crypto); + + java.nio.file.Path syncStateDir = Files.createTempDirectory(appCtx.getCacheDir().toPath(), "sync-state"); + JdbcTreeState state = new JdbcTreeState(":memory:"); + PublicKeyHash owner = network.coreNode.getPublicKeyHash(username).join().get(); + + DirectorySync.syncDir(local, remote, false, false, owner, network, state, + 32, 5, syncStateDir, crypto, () -> false, DirectorySync::log); + + UserContext freshCtx = UserContext.signIn(username, "test-password", req -> { + throw new IllegalStateException("MFA not expected"); + }, network, crypto).join(); + FileWrapper remoteDir = freshCtx.getByPath(peergosPath).join().get(); + Set children = remoteDir.getChildren(crypto.hasher, network).join(); + + Set remoteNames = new HashSet<>(); + for (FileWrapper c : children) remoteNames.add(c.getName()); + assertEquals(count, remoteNames.size()); + + int withThumb = 0, withHash = 0; + for (FileWrapper c : children) { + FileProperties p = c.getFileProperties(); + System.out.println("CHILD " + c.getName() + " size=" + p.size + + " thumb=" + p.thumbnail.isPresent() + + " hash=" + p.treeHash.isPresent() + + " mime=" + p.mimeType); + if (p.thumbnail.isPresent()) withThumb++; + if (p.treeHash.isPresent()) withHash++; + } + FileWrapper sameCtx = userCtx.getByPath(peergosPath).join().get(); + for (FileWrapper c : sameCtx.getChildren(crypto.hasher, network).join()) { + FileProperties p = c.getFileProperties(); + System.out.println("SAMECTX " + c.getName() + " hash=" + p.treeHash.isPresent()); + } + assertTrue("expected thumbnails on synced images (got " + withThumb + "/" + count + ")", + withThumb >= 1); + assertTrue("expected tree hashes on synced files (got " + withHash + "/" + count + ")", + withHash >= 1); + } + + private static String freshUsername() { + return "androidtest" + (System.currentTimeMillis() % 1_000_000); + } +} diff --git a/app/src/androidTest/java/peergos/android/TestDocumentsProvider.java b/app/src/androidTest/java/peergos/android/TestDocumentsProvider.java new file mode 100644 index 0000000..564e163 --- /dev/null +++ b/app/src/androidTest/java/peergos/android/TestDocumentsProvider.java @@ -0,0 +1,308 @@ +package peergos.android; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Objects; + +/** A plain ContentProvider (deliberately NOT a DocumentsProvider, because + * android.provider.DocumentsProvider.attachInfo on API 33+ rejects any + * exported provider that isn't system-privileged via MANAGE_DOCUMENTS). + * We hand-implement the DocumentsContract URI shapes and call() methods + * that androidx.documentfile and ContentResolver use, so AndroidSyncFileSystem + * can talk to us through DocumentFile.fromTreeUri exactly as it would a real + * SAF provider. Backed by a directory in this provider's own process's + * cacheDir; the test seeds files via the METHOD_RESET_AND_SEED call so all + * file IO happens in the provider's process (static state doesn't cross + * the process boundary). */ +public class TestDocumentsProvider extends ContentProvider { + + public static final String AUTHORITY = "peergos.android.test.documents"; + public static final String ROOT_ID = "root"; + + private static final int MATCH_TREE = 1; // content://AUTH/tree/{treeId} + private static final int MATCH_DOCUMENT = 2; // .../tree/{treeId}/document/{docId} + private static final int MATCH_CHILDREN = 3; // .../tree/{treeId}/document/{docId}/children + + private static final UriMatcher MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + static { + MATCHER.addURI(AUTHORITY, "tree/*", MATCH_TREE); + MATCHER.addURI(AUTHORITY, "tree/*/document/*", MATCH_DOCUMENT); + MATCHER.addURI(AUTHORITY, "tree/*/document/*/children", MATCH_CHILDREN); + } + + private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + }; + + // DocumentsContract.METHOD_* and EXTRA_URI are @hide in the SDK but the + // wire strings are stable (used by androidx.documentfile, the SAF client). + private static final String METHOD_CREATE_DOCUMENT = "android:createDocument"; + private static final String METHOD_DELETE_DOCUMENT = "android:deleteDocument"; + private static final String METHOD_RENAME_DOCUMENT = "android:renameDocument"; + private static final String EXTRA_URI = "uri"; + + /** Test-only: wipe the root and (optionally) seed it with N random files + * in the provider's own process. extras: count (int), seedLow (long, low + * 32 bits of the seed), minSize (long), maxSize (long). */ + public static final String METHOD_RESET_AND_SEED = "test:resetAndSeed"; + + private File rootDir; + + @Override + public boolean onCreate() { + rootDir = new File(getContext().getCacheDir(), "test-sync-root"); + rootDir.mkdirs(); + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + try { + switch (MATCHER.match(uri)) { + case MATCH_TREE: { + String docId = DocumentsContract.getTreeDocumentId(uri); + return queryOne(docId, projection); + } + case MATCH_DOCUMENT: { + String docId = DocumentsContract.getDocumentId(uri); + return queryOne(docId, projection); + } + case MATCH_CHILDREN: { + String parentId = DocumentsContract.getDocumentId(uri); + return queryChildren(parentId, projection); + } + } + } catch (FileNotFoundException e) { + return null; + } + return null; + } + + private Cursor queryOne(String docId, String[] projection) throws FileNotFoundException { + MatrixCursor c = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + addRow(c, fileFor(docId), docId); + return c; + } + + private Cursor queryChildren(String parentId, String[] projection) throws FileNotFoundException { + File parent = fileFor(parentId); + if (!parent.isDirectory()) throw new FileNotFoundException(parentId + " not a directory"); + MatrixCursor c = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + File[] kids = parent.listFiles(); + if (kids == null) return c; + for (File k : kids) addRow(c, k, docIdFor(k)); + return c; + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + if (MATCHER.match(uri) != MATCH_DOCUMENT) + throw new FileNotFoundException("Not a document URI: " + uri); + String docId = DocumentsContract.getDocumentId(uri); + return ParcelFileDescriptor.open(fileFor(docId), ParcelFileDescriptor.parseMode(mode)); + } + + @Override + public String getType(Uri uri) { + try { + switch (MATCHER.match(uri)) { + case MATCH_TREE: + return mimeOf(fileFor(DocumentsContract.getTreeDocumentId(uri))); + case MATCH_DOCUMENT: + return mimeOf(fileFor(DocumentsContract.getDocumentId(uri))); + case MATCH_CHILDREN: + return DocumentsContract.Document.MIME_TYPE_DIR; + } + } catch (FileNotFoundException e) { + return null; + } + return null; + } + + @Override + public Bundle call(String method, String arg, Bundle extras) { + try { + if (METHOD_CREATE_DOCUMENT.equals(method)) { + Uri parentUri = extras.getParcelable(EXTRA_URI); + String parentId = DocumentsContract.getDocumentId(parentUri); + String mime = extras.getString(DocumentsContract.Document.COLUMN_MIME_TYPE); + String name = extras.getString(DocumentsContract.Document.COLUMN_DISPLAY_NAME); + String childId = createChild(parentId, mime, name); + Bundle out = new Bundle(); + out.putParcelable(EXTRA_URI, + DocumentsContract.buildDocumentUriUsingTree(parentUri, childId)); + return out; + } + if (METHOD_DELETE_DOCUMENT.equals(method)) { + Uri docUri = extras.getParcelable(EXTRA_URI); + String docId = DocumentsContract.getDocumentId(docUri); + deleteRecursive(fileFor(docId)); + return null; + } + if (METHOD_RENAME_DOCUMENT.equals(method)) { + Uri docUri = extras.getParcelable(EXTRA_URI); + String docId = DocumentsContract.getDocumentId(docUri); + String name = extras.getString(DocumentsContract.Document.COLUMN_DISPLAY_NAME); + String newId = rename(docId, name); + Bundle out = new Bundle(); + out.putParcelable(EXTRA_URI, + DocumentsContract.buildDocumentUriUsingTree(docUri, newId)); + return out; + } + if (METHOD_RESET_AND_SEED.equals(method)) { + int count = extras.getInt("count"); + long seed = extras.getLong("seed"); + long minSize = extras.getLong("minSize"); + long maxSize = extras.getLong("maxSize"); + resetAndSeed(count, seed, minSize, maxSize); + return null; + } + } catch (Exception e) { + throw new RuntimeException(e); + } + return super.call(method, arg, extras); + } + + private void resetAndSeed(int count, long seed, long minSize, long maxSize) throws IOException { + deleteRecursive(rootDir); + rootDir.mkdirs(); + java.util.Random rng = new java.util.Random(seed); + for (int i = 0; i < count; i++) { + File f = new File(rootDir, String.format("img-%05d.jpg", i)); + writeJpeg(f, rng, minSize, maxSize); + } + } + + /** Produces a real JPEG (decodable by BitmapFactory, exercised by the + * thumbnailer). Noise pixels barely compress, so JPEG bytes scale roughly + * linearly with pixel count; we pick dimensions to land near a target + * inside [minSize, maxSize]. If the encoder still overshoots, we drop + * quality; if it undershoots, we append random padding bytes + * (BitmapFactory ignores trailing bytes after the JPEG EOI marker). */ + private static void writeJpeg(File dst, java.util.Random rng, long minSize, long maxSize) throws IOException { + long targetSize = minSize + (long) (rng.nextDouble() * (maxSize - minSize)); + int dim = (int) Math.round(Math.sqrt(targetSize / 1.4)); + android.graphics.Bitmap bmp = android.graphics.Bitmap.createBitmap( + dim, dim, android.graphics.Bitmap.Config.ARGB_8888); + try { + int[] pixels = new int[dim * dim]; + for (int p = 0; p < pixels.length; p++) pixels[p] = rng.nextInt(); + bmp.setPixels(pixels, 0, dim, 0, 0, dim, dim); + + java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream(); + int quality = 95; + bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, quality, buf); + while (buf.size() > maxSize && quality > 50) { + quality -= 10; + buf.reset(); + bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, quality, buf); + } + try (java.io.FileOutputStream out = new java.io.FileOutputStream(dst)) { + buf.writeTo(out); + long pad = minSize - buf.size(); + if (pad > 0) { + byte[] padBuf = new byte[64 * 1024]; + while (pad > 0) { + int n = (int) Math.min(padBuf.length, pad); + rng.nextBytes(padBuf); + out.write(padBuf, 0, n); + pad -= n; + } + } + } + } finally { + bmp.recycle(); + } + } + + private String createChild(String parentId, String mime, String displayName) throws IOException { + File parent = fileFor(parentId); + File child = new File(parent, displayName); + if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mime)) { + if (!child.mkdir() && !child.isDirectory()) throw new IOException("mkdir failed: " + child); + } else { + if (!child.createNewFile() && !child.exists()) throw new IOException("createNewFile failed: " + child); + } + return docIdFor(child); + } + + private String rename(String docId, String displayName) throws IOException { + File src = fileFor(docId); + File dst = new File(src.getParentFile(), displayName); + if (!src.renameTo(dst)) throw new IOException("rename failed: " + src + " -> " + dst); + return docIdFor(dst); + } + + private void addRow(MatrixCursor c, File f, String docId) { + int flags = 0; + if (f.isDirectory()) + flags |= DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE; + else + flags |= DocumentsContract.Document.FLAG_SUPPORTS_WRITE; + flags |= DocumentsContract.Document.FLAG_SUPPORTS_DELETE + | DocumentsContract.Document.FLAG_SUPPORTS_RENAME; + + MatrixCursor.RowBuilder row = c.newRow(); + row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, docId); + row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, ROOT_ID.equals(docId) ? "root" : f.getName()); + row.add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeOf(f)); + row.add(DocumentsContract.Document.COLUMN_SIZE, f.length()); + row.add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, f.lastModified()); + row.add(DocumentsContract.Document.COLUMN_FLAGS, flags); + } + + private File fileFor(String documentId) throws FileNotFoundException { + if (ROOT_ID.equals(documentId)) return rootDir; + if (!documentId.startsWith(ROOT_ID + "/")) + throw new FileNotFoundException("doc outside tree: " + documentId); + File f = new File(rootDir, documentId.substring(ROOT_ID.length() + 1)); + if (!f.exists()) throw new FileNotFoundException(documentId); + return f; + } + + private String docIdFor(File f) { + if (f.equals(rootDir)) return ROOT_ID; + String rel = rootDir.toPath().relativize(f.toPath()).toString().replace(File.separatorChar, '/'); + return ROOT_ID + "/" + rel; + } + + private static String mimeOf(File f) { + if (f.isDirectory()) return DocumentsContract.Document.MIME_TYPE_DIR; + String name = f.getName(); + int dot = name.lastIndexOf('.'); + if (dot < 0) return "application/octet-stream"; + String ext = name.substring(dot + 1).toLowerCase(); + String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext); + return mime != null ? mime : "application/octet-stream"; + } + + private static void deleteRecursive(File f) { + if (!f.exists()) return; + if (f.isDirectory()) + for (File c : Objects.requireNonNull(f.listFiles())) + deleteRecursive(c); + f.delete(); + } + + // ContentProvider abstract methods we don't use (tree URIs go through call()) + @Override public Uri insert(Uri uri, ContentValues values) { return null; } + @Override public int delete(Uri uri, String selection, String[] selectionArgs) { return 0; } + @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { return 0; } +} diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..3981768 --- /dev/null +++ b/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + localhost + 10.0.2.2 + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8318596..7171ea9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,9 +6,11 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/peergos/android/AndroidAsyncReader.java b/app/src/main/java/peergos/android/AndroidAsyncReader.java index 011ecaf..468f75a 100644 --- a/app/src/main/java/peergos/android/AndroidAsyncReader.java +++ b/app/src/main/java/peergos/android/AndroidAsyncReader.java @@ -24,9 +24,15 @@ public AndroidAsyncReader(InputStream fin, Supplier resetter) { @Override public CompletableFuture readIntoArray(byte[] bytes, int offset, int len) { try { - int read = fin.read(bytes, offset, len); - globalOffset.addAndGet(read); - return Futures.of(read); + int total = 0; + while (total < len) { + int read = fin.read(bytes, offset + total, len - total); + if (read <= 0) // EOF + break; + total += read; + } + globalOffset.addAndGet(total); + return Futures.of(total); } catch (IOException e) { return Futures.errored(e); } @@ -36,7 +42,20 @@ public CompletableFuture readIntoArray(byte[] bytes, int offset, int le public CompletableFuture seek(long offset) { if (offset >= globalOffset.get()) { try { - fin.skip(offset - globalOffset.get()); + long toSkip = offset - globalOffset.get(); + while (toSkip > 0) { + long skipped = fin.skip(toSkip); + if (skipped <= 0) { + // skip() can under-skip (or return 0) for pipe / content-provider backed + // streams; fall back to reading and discarding so we always advance fully. + int chunk = (int) Math.min(toSkip, 1 << 16); + int read = fin.read(new byte[chunk], 0, chunk); + if (read < 0) + throw new IOException("Reached EOF while seeking to " + offset); + skipped = read; + } + toSkip -= skipped; + } globalOffset.set(offset); return Futures.of(this); } catch (IOException e) { diff --git a/app/src/main/java/peergos/android/AndroidPoster.java b/app/src/main/java/peergos/android/AndroidPoster.java index 8170ff1..700d94e 100644 --- a/app/src/main/java/peergos/android/AndroidPoster.java +++ b/app/src/main/java/peergos/android/AndroidPoster.java @@ -74,16 +74,17 @@ private CompletableFuture post(String url, byte[] payload, boolean unzip } if (basicAuth.isPresent()) conn.setRequestProperty("Authorization", basicAuth.get()); - DataOutputStream dout = new DataOutputStream(conn.getOutputStream()); - - dout.write(payload); - dout.flush(); + try (DataOutputStream dout = new DataOutputStream(conn.getOutputStream())) { + dout.write(payload); + dout.flush(); + } String contentEncoding = conn.getContentEncoding(); boolean isGzipped = "gzip".equals(contentEncoding); - DataInputStream din = new DataInputStream(isGzipped && unzip ? new GZIPInputStream(conn.getInputStream()) : conn.getInputStream()); - byte[] resp = Serialize.readFully(din); - din.close(); + byte[] resp; + try (DataInputStream din = new DataInputStream(isGzipped && unzip ? new GZIPInputStream(conn.getInputStream()) : conn.getInputStream())) { + resp = Serialize.readFully(din); + } res.complete(resp); } catch (SocketTimeoutException e) { res.completeExceptionally(new SocketTimeoutException("Socket timeout on: " + url)); @@ -139,18 +140,18 @@ public CompletableFuture put(String url, byte[] body, Map res = new CompletableFuture<>(); if (conn != null) { - try { - InputStream err = conn.getErrorStream(); + try (InputStream err = conn.getErrorStream()) { res.completeExceptionally(new IOException("HTTP " + conn.getResponseCode() + ": " + conn.getResponseMessage())); } catch (IOException f) { res.completeExceptionally(f); @@ -196,8 +197,9 @@ private CompletableFuture publicGet(String url, Map head String contentEncoding = conn.getContentEncoding(); boolean isGzipped = "gzip".equals(contentEncoding); - DataInputStream din = new DataInputStream(isGzipped ? new GZIPInputStream(conn.getInputStream()) : conn.getInputStream()); - return Serialize.readFully(din); + try (DataInputStream din = new DataInputStream(isGzipped ? new GZIPInputStream(conn.getInputStream()) : conn.getInputStream())) { + return Serialize.readFully(din); + } } catch (SocketTimeoutException e) { throw new RuntimeException("Timeout retrieving: " + url, e); } catch (IOException e) { diff --git a/app/src/main/java/peergos/android/AndroidSecretStore.java b/app/src/main/java/peergos/android/AndroidSecretStore.java new file mode 100644 index 0000000..9b5562f --- /dev/null +++ b/app/src/main/java/peergos/android/AndroidSecretStore.java @@ -0,0 +1,57 @@ +package peergos.android; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKey; + +import java.util.Optional; + +import peergos.server.util.secrets.SecretStore; + +/** Android-backed {@link SecretStore}. Values are AES-GCM encrypted with a key + * held in the Android Keystore (hardware-backed on devices with a TEE). + * + *

Must be constructed and passed explicitly to {@code MountConfigHandler} on + * Android — never call {@link SecretStore#detect()} from Android code paths, + * it'd pick the Linux JSON fallback (Android reports {@code os.name=Linux}) + * and write the password in plaintext to app-private storage. */ +public class AndroidSecretStore implements SecretStore { + + private final SharedPreferences prefs; + + public AndroidSecretStore(Context ctx) throws Exception { + MasterKey master = new MasterKey.Builder(ctx) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(); + this.prefs = EncryptedSharedPreferences.create( + ctx, "peergos-secrets", master, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM); + } + + private static String key(String service, String account) { + return service + "|" + account; + } + + @Override + public void put(String service, String account, String value) { + prefs.edit().putString(key(service, account), value).apply(); + } + + @Override + public Optional get(String service, String account) { + return Optional.ofNullable(prefs.getString(key(service, account), null)); + } + + @Override + public void delete(String service, String account) { + prefs.edit().remove(key(service, account)).apply(); + } + + @Override + public boolean isAvailable() { + return true; + } +} diff --git a/app/src/main/java/peergos/android/AndroidSyncFileSystem.java b/app/src/main/java/peergos/android/AndroidSyncFileSystem.java index 75db91f..c8dd61e 100644 --- a/app/src/main/java/peergos/android/AndroidSyncFileSystem.java +++ b/app/src/main/java/peergos/android/AndroidSyncFileSystem.java @@ -183,7 +183,7 @@ public void truncate(Path p, long size) throws IOException { long current = f.length(); if (current < size) return; - try (ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(f.getUri(), "t"); + try (ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(f.getUri(), "rw"); FileOutputStream fout = new FileOutputStream(pfd.getFileDescriptor())) { fout.getChannel().truncate(size); } @@ -272,9 +272,7 @@ private InputStream getInputStream(DocumentFile file) { @Override public AsyncReader getBytes(Path p, long fileOffset) throws IOException { DocumentFile file = getByPath(p).orElseThrow(() -> new FileNotFoundException("Absent file: " + p)); - InputStream fin = context.getContentResolver().openInputStream(file.getUri()); - fin.skip(fileOffset); - return new AndroidAsyncReader(fin, () -> getInputStream(file)); + return new AndroidAsyncReader(getInputStream(file), () -> getInputStream(file)).seek(fileOffset).join(); } @Override @@ -354,7 +352,7 @@ public Optional getThumbnail(Path p) { DocumentFile file = existing.get(); String type = file.getType(); - if (type == null || ! type.startsWith("video")) + if (type == null || (! type.startsWith("video") && ! type.startsWith("image"))) return Optional.empty(); Bitmap image = null; @@ -372,7 +370,18 @@ public Optional getThumbnail(Path p) { } byte[] webpBytes = compressToWebp(image); - return Optional.of(new Thumbnail("image/webp", webpBytes)); + try { + return Optional.of(new Thumbnail("image/webp", webpBytes)); + } catch (IllegalStateException tooBig) { + // Noise pixels at 400x400 lossy-100 can blow past the 100 KiB + // Thumbnail cap; AndroidImageThumbnailer uses the same fallback. + Bitmap small = Bitmap.createScaledBitmap(image, 200, 200, true); + try { + return Optional.of(new Thumbnail("image/webp", compressToWebp(small))); + } finally { + small.recycle(); + } + } } catch (IOException e) { e.printStackTrace(); return Optional.empty(); @@ -393,7 +402,10 @@ public static List hashChunks(InputStream fin, long size) { chunkHash.update(buf, 0, thisChunk); chunkHashes.add(chunkHash.digest()); chunkHash = MessageDigest.getInstance("SHA-256"); - chunkOffset = 0; + int leftover = read - thisChunk; + if (leftover > 0) + chunkHash.update(buf, thisChunk, leftover); + chunkOffset = leftover; } else chunkHash.update(buf, 0, read); i += read; @@ -470,23 +482,29 @@ private void filesCountRecurse(Path p, DocumentFile dir, AtomicLong count) { } @Override - public Optional applyToSubtree(Consumer onFile, Consumer onDir) throws IOException { + public Optional applyToSubtree(Consumer onFile, Consumer onDir, boolean parallel) throws IOException { DocumentFile root = getByPath(Paths.get("")).orElseThrow(() -> new IllegalStateException("Absent sync root!")); if (root == null) throw new IllegalStateException("Couldn't retrieve local directory!"); - applyToSubtree(Paths.get(""), root, onFile, onDir); + applyToSubtree(Paths.get(""), root, onFile, onDir, parallel); return Optional.empty(); } - public void applyToSubtree(Path p, DocumentFile dir, Consumer onFile, Consumer onDir) { + public void applyToSubtree(Path p, DocumentFile dir, Consumer onFile, Consumer onDir, boolean parallel) { DocumentFile[] kids = dir.listFiles(); - Arrays.stream(kids).parallel().forEach(kid -> { + // Sequential during the first sync for this pair — parallel siblings race on + // mkdir/cryptree updates for a shared parent and stall progress with CAS + // exceptions. Switch to parallel once an initial sync has completed. + java.util.stream.Stream stream = parallel + ? Arrays.stream(kids).parallel() + : Arrays.stream(kids); + stream.forEach(kid -> { FileProps props = new FileProps(p.resolve(kid.getName()).toString(), kid.lastModified() / 1000 * 1000, kid.length(), Optional.empty()); if (kid.isFile()) { onFile.accept(props); } else { onDir.accept(props); - applyToSubtree(p.resolve(kid.getName()), kid, onFile, onDir); + applyToSubtree(p.resolve(kid.getName()), kid, onFile, onDir, parallel); } }); } diff --git a/app/src/main/java/peergos/android/DocumentsProviderBackend.java b/app/src/main/java/peergos/android/DocumentsProviderBackend.java new file mode 100644 index 0000000..14cbbf8 --- /dev/null +++ b/app/src/main/java/peergos/android/DocumentsProviderBackend.java @@ -0,0 +1,50 @@ +package peergos.android; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.provider.DocumentsContract; + +import java.nio.file.Path; +import java.util.Optional; + +import peergos.server.mount.MountBackend; +import peergos.server.webdav.MountConfig; +import peergos.shared.user.UserContext; + +public class DocumentsProviderBackend implements MountBackend { + + public static final String AUTHORITY = "peergos.android.documents"; + + private final Context appContext; + private volatile boolean active = false; + + public DocumentsProviderBackend(Context appContext) { + this.appContext = appContext; + } + + @Override + public void enable(MountConfig config, UserContext context, Path peergosDir) { + PeergosSession.publish(context, context.network, context.crypto); + active = true; + notifyRoots(); + } + + @Override + public void disable() { + PeergosSession.clear(); + active = false; + notifyRoots(); + } + + @Override + public Optional activeMountPoint() { + return active ? Optional.of("Files app") : Optional.empty(); + } + + private void notifyRoots() { + ContentResolver cr = appContext.getContentResolver(); + Uri roots = DocumentsContract.buildRootsUri(AUTHORITY); + cr.notifyChange(roots, null); + } +} diff --git a/app/src/main/java/peergos/android/MainActivity.java b/app/src/main/java/peergos/android/MainActivity.java index f73ee61..2d919b7 100644 --- a/app/src/main/java/peergos/android/MainActivity.java +++ b/app/src/main/java/peergos/android/MainActivity.java @@ -8,9 +8,11 @@ import android.app.NotificationManager; import android.app.ProgressDialog; import android.content.BroadcastReceiver; +import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.provider.DocumentsContract; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.media.ThumbnailUtils; @@ -18,6 +20,11 @@ import android.os.Build; import android.os.Bundle; import android.os.Environment; +import android.os.PowerManager; +import android.content.ContentValues; +import android.content.SharedPreferences; +import android.provider.MediaStore; +import android.provider.Settings; import android.os.storage.StorageManager; import android.util.Size; import android.view.ViewGroup; @@ -42,7 +49,6 @@ import com.yubico.yubikit.android.transport.usb.DeviceFilter; import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice; import com.yubico.yubikit.core.YubiKeyDevice; -import com.yubico.yubikit.core.application.CommandException; import com.yubico.yubikit.core.fido.FidoConnection; import com.yubico.yubikit.core.smartcard.SmartCardConnection; import com.yubico.yubikit.fido.Cbor; @@ -66,6 +72,7 @@ import androidx.lifecycle.ProcessLifecycleOwner; import androidx.work.Constraints; import androidx.work.Data; +import androidx.work.ExistingPeriodicWorkPolicy; import androidx.work.NetworkType; import androidx.work.OneTimeWorkRequest; import androidx.work.PeriodicWorkRequest; @@ -77,7 +84,9 @@ import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; +import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.MalformedURLException; import java.net.URI; @@ -97,7 +106,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; @@ -117,9 +125,11 @@ import peergos.server.sql.SqlSupplier; import peergos.server.storage.FileBlockCache; import peergos.server.storage.auth.JdbcBatCave; +import peergos.server.sync.PairLogger; import peergos.server.sync.SyncConfig; import peergos.server.sync.SyncRunner; import peergos.server.util.Args; +import peergos.server.util.Logging; import peergos.shared.Crypto; import peergos.shared.NetworkAccess; import peergos.shared.OnlineState; @@ -210,6 +220,188 @@ public void notifyDirectoryRequest() { wantsDirectory = true; } + /** Launch the system Files app rooted at the Peergos SAF DocumentsProvider so the + * WebView's "Open in file explorer" button has somewhere meaningful to go on + * Android (there's no filesystem mount path here — the mount is the SAF root). */ + /** Bundle the rotated peergos.<N>.log files in PEERGOS_PATH (up to 10, written by + * java.util.logging.FileHandler with pattern "peergos.%g.log") into a single text + * file and drop it in the user's Downloads folder via MediaStore. */ + @JavascriptInterface + public void downloadLogs() { + new Thread(() -> { + try { + File dir = getFilesDir(); + File[] candidates = dir.listFiles((d, name) -> + name.startsWith("peergos.") && name.endsWith(".log")); + if (candidates == null || candidates.length == 0) { + runOnUiThread(() -> Toast.makeText(this, "No logs found", Toast.LENGTH_SHORT).show()); + return; + } + // FileHandler keeps the active file at generation 0 and increments older + // rotations, so sort highest-generation first to write oldest→newest. + List ordered = new ArrayList<>(Arrays.asList(candidates)); + ordered.sort((a, b) -> logGeneration(b.getName()) - logGeneration(a.getName())); + if (ordered.size() > 10) + ordered = ordered.subList(0, 10); + + String fileName = "peergos-logs-" + System.currentTimeMillis() + ".log"; + ContentValues values = new ContentValues(); + values.put(MediaStore.Downloads.DISPLAY_NAME, fileName); + values.put(MediaStore.Downloads.MIME_TYPE, "text/plain"); + values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); + Uri uri = getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); + if (uri == null) { + runOnUiThread(() -> Toast.makeText(this, "Could not create download file", Toast.LENGTH_SHORT).show()); + return; + } + try (OutputStream out = getContentResolver().openOutputStream(uri)) { + byte[] buf = new byte[64 * 1024]; + for (File f : ordered) { + out.write(("==== " + f.getName() + " ====\n") + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try (FileInputStream in = new FileInputStream(f)) { + int r; + while ((r = in.read(buf)) > 0) + out.write(buf, 0, r); + } + out.write('\n'); + } + } + runOnUiThread(() -> Toast.makeText(this, + "Logs saved to Downloads/" + fileName, Toast.LENGTH_LONG).show()); + } catch (Exception e) { + runOnUiThread(() -> Toast.makeText(this, + "Failed to download logs: " + e.getMessage(), Toast.LENGTH_LONG).show()); + } + }).start(); + } + + @JavascriptInterface + public void downloadSyncLog(String label) { + new Thread(() -> { + try { + Path peergosDir = Paths.get(getFilesDir().getAbsolutePath()); + Path configPath = peergosDir.resolve(SyncConfigHandler.SYNC_CONFIG_FILENAME); + if (! Files.exists(configPath)) { + runOnUiThread(() -> Toast.makeText(this, "No sync config found", Toast.LENGTH_SHORT).show()); + return; + } + Map json = (Map) JSONParser.parse(new String(Files.readAllBytes(configPath))); + List> pairs = (List>) json.get("pairs"); + Map match = null; + for (Map p : pairs) { + String link = (String) p.get("link"); + String pairLabel = link.substring(link.lastIndexOf("/", link.indexOf("#")) + 1, link.indexOf("#")); + if (pairLabel.equals(label)) { + match = p; + break; + } + } + if (match == null) { + runOnUiThread(() -> Toast.makeText(this, "Unknown sync label", Toast.LENGTH_SHORT).show()); + return; + } + String linkPath = (String) match.get("remotepath"); + String localDir = (String) match.get("localpath"); + String hash = PairLogger.hash(linkPath, localDir); + Path rotated = PairLogger.rotatedLogPath(peergosDir, hash); + Path current = PairLogger.currentLogPath(peergosDir, hash); + if (! Files.exists(rotated) && ! Files.exists(current)) { + runOnUiThread(() -> Toast.makeText(this, "No sync log yet for this pair", Toast.LENGTH_SHORT).show()); + return; + } + + String fileName = "sync-" + label + "-" + System.currentTimeMillis() + ".log"; + ContentValues values = new ContentValues(); + values.put(MediaStore.Downloads.DISPLAY_NAME, fileName); + values.put(MediaStore.Downloads.MIME_TYPE, "text/plain"); + values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); + Uri uri = getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); + if (uri == null) { + runOnUiThread(() -> Toast.makeText(this, "Could not create download file", Toast.LENGTH_SHORT).show()); + return; + } + try (OutputStream out = getContentResolver().openOutputStream(uri)) { + if (Files.exists(rotated)) + Files.copy(rotated, out); + if (Files.exists(current)) + Files.copy(current, out); + } + runOnUiThread(() -> Toast.makeText(this, + "Sync log saved to Downloads/" + fileName, Toast.LENGTH_LONG).show()); + } catch (Exception e) { + runOnUiThread(() -> Toast.makeText(this, + "Failed to download sync log: " + e.getMessage(), Toast.LENGTH_LONG).show()); + } + }).start(); + } + + /** One-shot prompt on the user's first sync: ask them to whitelist Peergos in + * battery optimisation so the system doesn't suspend background sync. */ + private void maybePromptBatteryOptimization() { + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + if (pm == null || pm.isIgnoringBatteryOptimizations(getPackageName())) + return; + SharedPreferences prefs = getSharedPreferences("peergos-prefs", MODE_PRIVATE); + if (prefs.getBoolean("battery-opt-dismissed", false)) + return; + new AlertDialog.Builder(this) + .setTitle("Keep syncing in the background") + .setMessage("Android may pause Peergos while your screen is off, which can stop syncs from completing. " + + "Allowing Peergos to ignore battery optimisation lets sync keep running until it finishes. " + + "You can change this later in system settings.") + .setPositiveButton("Allow", (d, w) -> { + try { + startActivity(new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + getPackageName()))); + } catch (ActivityNotFoundException notFound) { + startActivity(new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)); + } + }) + .setNegativeButton("Not now", (d, w) -> + prefs.edit().putBoolean("battery-opt-dismissed", true).apply()) + .show(); + } + + private static int logGeneration(String name) { + try { + int dot1 = name.indexOf('.') + 1; + int dot2 = name.indexOf('.', dot1); + return Integer.parseInt(name.substring(dot1, dot2)); + } catch (Exception e) { + return Integer.MAX_VALUE; + } + } + + @JavascriptInterface + public void openMountInFiles() { + runOnUiThread(() -> { + String username = PeergosSession.context().map(c -> c.username).orElse(null); + if (username == null) { + Toast.makeText(this, "Sign in to open Peergos files", Toast.LENGTH_SHORT).show(); + return; + } + Uri rootUri = DocumentsContract.buildRootUri(DocumentsProviderBackend.AUTHORITY, username); + // android.provider.action.BROWSE is the canonical way to open DocumentsUI + // at a specific root; ACTION_VIEW is the fallback for OEM file apps that + // don't register BROWSE. + Intent browse = new Intent("android.provider.action.BROWSE") + .setDataAndType(rootUri, "vnd.android.document/root") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + try { + startActivity(browse); + } catch (ActivityNotFoundException notFound) { + try { + startActivity(new Intent(Intent.ACTION_VIEW) + .setDataAndType(rootUri, "vnd.android.document/root") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)); + } catch (ActivityNotFoundException stillNotFound) { + Toast.makeText(this, "No file explorer app installed", Toast.LENGTH_LONG).show(); + } + } + }); + } + @Override protected void onCreate(Bundle savedInstanceState) { @@ -1035,8 +1227,10 @@ public SyncRunner startServer(int port) { Data syncArgs = new Data.Builder() .putString("PEERGOS_PATH", peergosDir.toString()) .build(); + // Use CONNECTED rather than UNMETERED because allow-on-mobile is per-pair; + // SyncWorker checks the current network metered-ness and filters pairs. Constraints periodic = new Constraints.Builder() - .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .setRequiresStorageNotLow(true) .build(); @@ -1050,32 +1244,54 @@ public SyncRunner startServer(int port) { // .setExecutor(Executors.newFixedThreadPool(1)) // .build()); WorkManager backgroundWork = WorkManager.getInstance(this); + Path syncConfigPath = peergosDir.resolve(SyncConfigHandler.SYNC_CONFIG_FILENAME); SyncRunner syncer = new SyncRunner() { - private static final String periodicUuid = "fe64ee2f-a2a2-4dab-96d8-0aec9475541f"; - @Override public void start() { + // start() is invoked by SyncConfigHandler after a pair has been + // written to disk; a count of 1 means this is the first pair on + // this install (or the first since the user removed everything). + if (readPairCount() == 1) + runOnUiThread(MainActivity.this::maybePromptBatteryOptimization); runNow(); - backgroundWork.enqueue(new PeriodicWorkRequest.Builder(SyncWorker.class, 15, TimeUnit.MINUTES) - .setConstraints(periodic) - .setId(UUID.fromString(periodicUuid)) - .setInputData(syncArgs).setInitialDelay(Duration.of(1, ChronoUnit.MINUTES)) - .build()); + // UPDATE so constraint changes (e.g. the UNMETERED → CONNECTED switch + // to enable per-pair mobile-data sync) take effect on upgrade without + // resetting the periodic schedule. + backgroundWork.enqueueUniquePeriodicWork("peergos-sync", + ExistingPeriodicWorkPolicy.UPDATE, + new PeriodicWorkRequest.Builder(SyncWorker.class, 15, TimeUnit.MINUTES) + .setConstraints(periodic) + .setInputData(syncArgs) + .setInitialDelay(Duration.of(1, ChronoUnit.MINUTES)) + .build()); } @Override public void runNow() { - backgroundWork.enqueue(new OneTimeWorkRequest.Builder(SyncWorker.class) - .setConstraints(once) - .setId(UUID.randomUUID()) - .setInputData(syncArgs) - .build()); + // Hand the long-running drain to SyncService — the WebView click / + // Activity callback that triggered this path counts as foreground + // so the BG-FGS restriction doesn't apply. + ContextCompat.startForegroundService(MainActivity.this, + new Intent(MainActivity.this, SyncService.class)); } @Override public StatusHolder getStatusHolder() { return SyncWorker.status; } + + private int readPairCount() { + try { + if (! syncConfigPath.toFile().exists()) + return 0; + Map json = (Map) JSONParser.parse( + new String(Files.readAllBytes(syncConfigPath))); + List pairs = (List) json.get("pairs"); + return pairs == null ? 0 : pairs.size(); + } catch (IOException e) { + return -1; + } + } }; Path oldSyncConfigFile = peergosDir.resolve(SyncConfigHandler.OLD_SYNC_CONFIG_FILENAME); @@ -1089,12 +1305,14 @@ public StatusHolder getStatusHolder() { Optional localAppProps = Optional.of(new UserService.LocalAppProperties(peergosDir, serverUrl)); UserService server = new UserService(withoutS3, offlineBats, crypto, offlineCorenode, offlineAccounts, httpSocial, pointerCache, admin, httpUsage, serverMessager, null, - Optional.of(new SyncProperties(syncConfig, a.getPeergosDir(), syncer, Either.b(this::chooseDirToAccess))), localAppProps); + Optional.of(new SyncProperties(syncConfig, a.getPeergosDir(), syncer, Either.b(this::chooseDirToAccess))), localAppProps, + PeergosSession.mountHandler()); InetSocketAddress localAPIAddress = new InetSocketAddress("localhost", port); List appSubdomains = Arrays.asList("markup-viewer,calendar,code-editor,pdf".split(",")); int connectionBacklog = 50; int handlerPoolSize = 4; + Logging.init(a); server.initAndStart(localAPIAddress, Arrays.asList(), Optional.empty(), Optional.empty(), Collections.emptyList(), Collections.emptyList(), appSubdomains, true, Optional.empty(), Optional.empty(), Optional.empty(), true, false, diff --git a/app/src/main/java/peergos/android/PeergosApp.java b/app/src/main/java/peergos/android/PeergosApp.java new file mode 100644 index 0000000..d7572ee --- /dev/null +++ b/app/src/main/java/peergos/android/PeergosApp.java @@ -0,0 +1,82 @@ +package peergos.android; + +import android.app.Application; +import android.util.Log; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; +import java.util.function.Supplier; + +import peergos.server.Main; +import peergos.server.MountProperties; +import peergos.server.UserService; +import peergos.server.mount.MountBackend; +import peergos.server.net.MountConfigHandler; +import peergos.server.util.secrets.SecretStore; +import peergos.server.webdav.MountConfig; +import peergos.shared.Crypto; +import peergos.shared.NetworkAccess; +import peergos.shared.storage.ContentAddressedStorage; +import peergos.shared.user.HttpPoster; + +public class PeergosApp extends Application { + + private static final String TAG = "PeergosApp"; + + @Override + public void onCreate() { + super.onCreate(); + try { + bootstrapMount(); + } catch (Exception e) { + Log.w(TAG, "DocumentsProvider mount bootstrap failed", e); + } + } + + private void bootstrapMount() throws Exception { + File privateStorage = getFilesDir(); + Path peergosDir = Paths.get(privateStorage.getAbsolutePath()); + + SecretStore store = new AndroidSecretStore(this); + MountConfig persisted = MountConfigHandler.readConfig(peergosDir, store); + String serverUrlStr = readSavedServerUrl(peergosDir).orElse("https://peergos.net"); + URL serverUrl = new URL(serverUrlStr); + + Supplier cryptoFactory = () -> Main.initCrypto(new ScryptAndroid()); + Supplier networkFactory = () -> { + HttpPoster poster = new AndroidPoster(serverUrl, true, Optional.empty(), + Optional.of("Peergos-" + UserService.CURRENT_VERSION + "-android-mount")); + Crypto c = cryptoFactory.get(); + ContentAddressedStorage localDht = NetworkAccess.buildLocalDht(poster, true, c.hasher); + return NetworkAccess.buildViaPeergosInstance(poster, poster, localDht, 7_000, c.hasher, false).join(); + }; + + MountProperties props = new MountProperties(persisted, peergosDir, serverUrlStr); + MountBackend backend = new DocumentsProviderBackend(getApplicationContext()); + MountConfigHandler handler = new MountConfigHandler(props, store, backend, cryptoFactory, networkFactory); + PeergosSession.setMountHandler(handler); + handler.start(); + } + + static Optional readSavedServerUrl(Path peergosDir) { + try { + Path configFile = peergosDir.resolve("config"); + if (!Files.exists(configFile)) + return Optional.empty(); + for (String line : Files.readAllLines(configFile)) { + String trimmed = line.trim(); + if (trimmed.startsWith("server-url") || trimmed.startsWith("peergos-url")) { + String[] parts = trimmed.split("=", 2); + if (parts.length == 2 && !parts[1].trim().isEmpty()) + return Optional.of(parts[1].trim()); + } + } + } catch (IOException ignored) {} + return Optional.empty(); + } +} diff --git a/app/src/main/java/peergos/android/PeergosDocumentsProvider.java b/app/src/main/java/peergos/android/PeergosDocumentsProvider.java new file mode 100644 index 0000000..e8f8ee5 --- /dev/null +++ b/app/src/main/java/peergos/android/PeergosDocumentsProvider.java @@ -0,0 +1,683 @@ +package peergos.android; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.graphics.Point; +import android.net.Uri; +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.ParcelFileDescriptor; +import android.os.ProxyFileDescriptorCallback; +import android.os.storage.StorageManager; +import android.provider.DocumentsContract; +import android.provider.DocumentsContract.Document; +import android.provider.DocumentsContract.Root; +import android.provider.DocumentsProvider; +import android.system.ErrnoException; +import android.system.OsConstants; +import android.util.Log; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Base64; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import peergos.shared.Crypto; +import peergos.shared.NetworkAccess; +import peergos.shared.user.UserContext; +import peergos.shared.user.fs.AsyncReader; +import peergos.shared.user.fs.FileWrapper; +import peergos.shared.user.fs.MimeTypes; +import peergos.shared.user.fs.Thumbnail; +import peergos.shared.util.PathUtil; + +public class PeergosDocumentsProvider extends DocumentsProvider { + + private static final String TAG = "PeergosDocsProvider"; + + private static final String[] DEFAULT_ROOT_PROJECTION = { + Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, Root.COLUMN_FLAGS, Root.COLUMN_ICON, + }; + + private static final String[] DEFAULT_DOCUMENT_PROJECTION = { + Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, + }; + + private final ExecutorService streamingPool = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "PeergosDocsProvider stream"); + t.setDaemon(true); + return t; + }); + + private volatile UploadProgressNotifier progressNotifier; + + private UploadProgressNotifier progressNotifier() { + UploadProgressNotifier n = progressNotifier; + if (n == null) { + synchronized (this) { + n = progressNotifier; + if (n == null) n = progressNotifier = new UploadProgressNotifier(getContext()); + } + } + return n; + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor queryRoots(String[] projection) { + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION); + Optional ctxOpt = PeergosSession.context(); + if (ctxOpt.isEmpty()) return cursor; + UserContext ctx = ctxOpt.get(); + String username = ctx.username; + + MatrixCursor.RowBuilder row = cursor.newRow(); + row.add(Root.COLUMN_ROOT_ID, username); + row.add(Root.COLUMN_DOCUMENT_ID, "/" + username); + row.add(Root.COLUMN_TITLE, "Peergos"); + row.add(Root.COLUMN_SUMMARY, username); + row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_CREATE); + row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher_round); + return cursor; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + FileWrapper fw = lookupOrThrow(documentId); + int slash = documentId.lastIndexOf('/'); + boolean parentWritable = false; + if (slash > 0) { + Optional parent = sessionOrThrow().context.getByPath(documentId.substring(0, slash)).join(); + parentWritable = parent.isPresent() && parent.get().isWritable(); + } + addRow(cursor, fw, documentId, parentWritable); + return cursor; + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) + throws FileNotFoundException { + Session s = sessionOrThrow(); + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + FileWrapper parent = lookupOrThrow(parentDocumentId); + boolean parentWritable = parent.isWritable(); + Set children = parent.getChildren(s.crypto.hasher, s.network).join(); + for (FileWrapper child : children) { + if (child.getFileProperties().isHidden) continue; + String childId = joinPath(parentDocumentId, child.getName()); + addRow(cursor, child, childId, parentWritable); + } + return cursor; + } + + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) + throws FileNotFoundException { + boolean wantsWrite = mode.indexOf('w') >= 0 || mode.indexOf('a') >= 0; + return wantsWrite + ? openWritable(documentId, mode, signal) + : openForRead(documentId, signal); + } + + private ParcelFileDescriptor openForRead(String documentId, CancellationSignal signal) + throws FileNotFoundException { + // Hand back a seekable FD via StorageManager.openProxyFileDescriptor so image and + // video viewers (BitmapFactory header peek, MP4 moov-atom lookup) can lseek. A + // pipe is non-seekable and silently breaks both — that's the blank-screen path. + Session s = sessionOrThrow(); + FileWrapper fw = lookupOrThrow(documentId); + long size = fw.getSize(); + Context appCtx = getContext().getApplicationContext(); + StorageManager sm = (StorageManager) appCtx.getSystemService(Context.STORAGE_SERVICE); + HandlerThread thread = new HandlerThread("PeergosProxyFd-" + Integer.toHexString(documentId.hashCode())); + thread.start(); + // Keep the process out of Doze for the lifetime of the FD; released in onRelease. + StreamingForegroundService.acquire(appCtx); + try { + return sm.openProxyFileDescriptor( + ParcelFileDescriptor.MODE_READ_ONLY, + new PeergosProxyCallback(fw, size, s, thread, appCtx), + new Handler(thread.getLooper())); + } catch (IOException e) { + thread.quitSafely(); + StreamingForegroundService.release(appCtx); + throw rethrowAsFnf("openProxyFileDescriptor " + documentId, e); + } + } + + private ParcelFileDescriptor openWritable(String documentId, String mode, CancellationSignal signal) + throws FileNotFoundException { + Session s = sessionOrThrow(); + int slash = documentId.lastIndexOf('/'); + if (slash <= 0) throw new FileNotFoundException("Cannot write to root: " + documentId); + String parentId = documentId.substring(0, slash); + String name = documentId.substring(slash + 1); + FileWrapper parent = lookupOrThrow(parentId); + if (!parent.isWritable()) throw new FileNotFoundException("Read-only parent: " + parentId); + Optional existingOpt = s.context.getByPath(documentId).join(); + + // POSIX-ish semantics: 'w' without 'a' truncates an existing file; 'a' (append) leaves + // the existing content alone. Missing files always get a zero-byte placeholder so + // every subsequent onWrite can use overwriteSection against a real FileWrapper. + boolean appendMode = mode.indexOf('a') >= 0; + boolean truncateMode = !appendMode && mode.indexOf('w') >= 0; + FileWrapper fw; + try { + if (existingOpt.isEmpty()) { + parent.uploadFileWithHash(name, AsyncReader.build(new byte[0]), 0, + Optional.empty(), Optional.empty(), Optional.empty(), + s.network, s.crypto, p -> {}).join(); + fw = s.context.getByPath(documentId).join() + .orElseThrow(() -> new FileNotFoundException("After create: " + documentId)); + } else if (existingOpt.get().isDirectory()) { + throw new FileNotFoundException("Is a directory: " + documentId); + } else { + fw = existingOpt.get(); + if (truncateMode && fw.getSize() > 0) + fw = fw.truncate(0, s.network, s.crypto).join(); + } + } catch (FileNotFoundException e) { + throw e; + } catch (Exception e) { + throw rethrowAsFnf("prepare write " + documentId, e); + } + + Context appCtx = getContext().getApplicationContext(); + StorageManager sm = (StorageManager) appCtx.getSystemService(Context.STORAGE_SERVICE); + HandlerThread thread = new HandlerThread("PeergosProxyFd-" + Integer.toHexString(documentId.hashCode())); + thread.start(); + StreamingForegroundService.acquire(appCtx); + // Indeterminate progress: the editor decides the total size as it writes, so we + // don't know it up front. start(name, 0) renders an indeterminate bar. + UploadProgressNotifier.Handle handle = progressNotifier().start(name, 0); + File cacheDir = appCtx.getCacheDir(); + try { + return sm.openProxyFileDescriptor( + ParcelFileDescriptor.MODE_READ_WRITE, + new PeergosWriteProxyCallback(fw, fw.getSize(), parentId, name, s, thread, + appCtx, appCtx.getContentResolver(), streamingPool, handle, cacheDir), + new Handler(thread.getLooper())); + } catch (IOException e) { + thread.quitSafely(); + StreamingForegroundService.release(appCtx); + handle.fail(e.getMessage() != null ? e.getMessage() : "open failed"); + throw rethrowAsFnf("openProxyFileDescriptor " + documentId, e); + } + } + + @Override + public AssetFileDescriptor openDocumentThumbnail(String documentId, Point sizeHint, + CancellationSignal signal) + throws FileNotFoundException { + FileWrapper fw = lookupOrThrow(documentId); + Optional thumb = fw.getFileProperties().thumbnail; + if (thumb.isEmpty()) throw new FileNotFoundException("No thumbnail for " + documentId); + byte[] bytes = thumb.get().data; + ParcelFileDescriptor[] pipe = createPipeOrThrow(); + ParcelFileDescriptor readFd = pipe[0]; + ParcelFileDescriptor writeFd = pipe[1]; + streamingPool.execute(() -> { + try (OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(writeFd)) { + out.write(bytes); + } catch (IOException e) { + if (!isPipeClosed(e)) Log.w(TAG, "thumbnail write failed", e); + } + }); + return new AssetFileDescriptor(readFd, 0, bytes.length); + } + + @Override + public String createDocument(String parentDocumentId, String mimeType, String displayName) + throws FileNotFoundException { + Session s = sessionOrThrow(); + FileWrapper parent = lookupOrThrow(parentDocumentId); + if (!parent.isWritable()) throw new FileNotFoundException("Read-only: " + parentDocumentId); + try { + if (Document.MIME_TYPE_DIR.equals(mimeType)) { + parent.mkdir(displayName, s.network, false, parent.mirrorBatId(), s.crypto).join(); + } else { + parent.uploadFileWithHash(displayName, AsyncReader.build(new byte[0]), 0, + Optional.empty(), Optional.empty(), Optional.empty(), + s.network, s.crypto, p -> {}).join(); + } + } catch (Exception e) { + throw rethrowAsFnf("createDocument", e); + } + notifyParent(parentDocumentId); + return joinPath(parentDocumentId, displayName); + } + + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + Session s = sessionOrThrow(); + int slash = documentId.lastIndexOf('/'); + if (slash <= 0) throw new FileNotFoundException("Cannot delete root: " + documentId); + String parentId = documentId.substring(0, slash); + FileWrapper parent = lookupOrThrow(parentId); + if (!parent.isWritable()) throw new FileNotFoundException("Read-only parent: " + parentId); + FileWrapper fw = lookupOrThrow(documentId); + try { + fw.remove(parent, PathUtil.get(documentId), s.context).join(); + } catch (Exception e) { + throw rethrowAsFnf("deleteDocument", e); + } + notifyParent(parentId); + } + + @Override + public String renameDocument(String documentId, String displayName) throws FileNotFoundException { + Session s = sessionOrThrow(); + int slash = documentId.lastIndexOf('/'); + if (slash <= 0) throw new FileNotFoundException("Cannot rename root: " + documentId); + String parentId = documentId.substring(0, slash); + FileWrapper parent = lookupOrThrow(parentId); + if (!parent.isWritable()) throw new FileNotFoundException("Read-only parent: " + parentId); + FileWrapper fw = lookupOrThrow(documentId); + try { + fw.rename(displayName, parent, PathUtil.get(documentId), s.context).join(); + } catch (Exception e) { + throw rethrowAsFnf("renameDocument", e); + } + notifyParent(parentId); + return joinPath(parentId, displayName); + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + return documentId.equals(parentDocumentId) + || documentId.startsWith(parentDocumentId + "/"); + } + + @Override + public DocumentsContract.Path findDocumentPath(String parentDocumentId, String childDocumentId) + throws FileNotFoundException { + if (childDocumentId == null || childDocumentId.isEmpty() || childDocumentId.charAt(0) != '/') + throw new FileNotFoundException(childDocumentId); + int secondSlash = childDocumentId.indexOf('/', 1); + String rootId = secondSlash < 0 ? childDocumentId.substring(1) + : childDocumentId.substring(1, secondSlash); + + java.util.List chain = new java.util.ArrayList<>(); + if (parentDocumentId == null) { + chain.add("/" + rootId); + } else { + if (!isChildDocument(parentDocumentId, childDocumentId)) + throw new FileNotFoundException(childDocumentId + " is not under " + parentDocumentId); + chain.add(parentDocumentId); + } + String start = chain.get(0); + int from = start.length(); + while (from < childDocumentId.length()) { + int next = childDocumentId.indexOf('/', from + 1); + if (next < 0) { chain.add(childDocumentId); break; } + chain.add(childDocumentId.substring(0, next)); + from = next; + } + if (!chain.get(chain.size() - 1).equals(childDocumentId)) + chain.add(childDocumentId); + return new DocumentsContract.Path(parentDocumentId == null ? rootId : null, chain); + } + + /** Backs a seekable {@link ParcelFileDescriptor} with a Peergos {@link AsyncReader}. + * All callbacks are serialised on the Handler thread we hand to + * {@code openProxyFileDescriptor}, so the cached reader + position is single-threaded + * and we avoid a seek when reads are sequential — the common video / image case. */ + private static final class PeergosProxyCallback extends ProxyFileDescriptorCallback { + private final FileWrapper fw; + private final long size; + private final Session s; + private final HandlerThread thread; + private final Context appCtx; + private AsyncReader reader; + private long pos; + + PeergosProxyCallback(FileWrapper fw, long size, Session s, HandlerThread thread, Context appCtx) { + this.fw = fw; + this.size = size; + this.s = s; + this.thread = thread; + this.appCtx = appCtx; + } + + @Override + public long onGetSize() { + return size; + } + + @Override + public int onRead(long offset, int len, byte[] data) throws ErrnoException { + if (offset >= size) return 0; + int toRead = (int) Math.min(len, size - offset); + try { + if (reader == null) { + reader = fw.getInputStream(s.network, s.crypto, size, p -> {}).join(); + pos = 0; + } + if (offset != pos) { + reader = reader.seek(offset).join(); + pos = offset; + } + int total = 0; + while (total < toRead) { + int n = reader.readIntoArray(data, total, toRead - total).join(); + if (n <= 0) break; + total += n; + pos += n; + } + return total; + } catch (Exception e) { + Log.w(TAG, "proxy onRead failed at offset=" + offset + " len=" + len, e); + // Drop the stale reader so the next call re-opens from scratch. + try { if (reader != null) reader.close(); } catch (Exception ignored) {} + reader = null; + pos = 0; + throw new ErrnoException("onRead", OsConstants.EIO); + } + } + + @Override + public void onRelease() { + try { if (reader != null) reader.close(); } catch (Exception ignored) {} + reader = null; + thread.quitSafely(); + StreamingForegroundService.release(appCtx); + } + } + + /** Read-write proxy callback. Each onWrite commits a single {@code overwriteSection} + * transaction against the current FileWrapper, which is then replaced with the + * returned version so the next write builds on top. Peergos chunk caching means + * edits within an already-touched chunk (very common for small files / first chunk) + * don't re-fetch from the network. + * + * Two pieces happen out-of-band on release: progress notification (updated per write + * via the per-byte handle), and thumbnail regeneration (streams the post-write + * content into a temp file, runs the image/video thumbnailer, writes back via + * updateThumbnail). The latter is moved to {@code streamingPool} so we don't block + * the system's FD-close path on a network read. */ + private static final class PeergosWriteProxyCallback extends ProxyFileDescriptorCallback { + private final long initialSize; + private final String parentId; + private final String name; + private final Session s; + private final HandlerThread thread; + private final Context appCtx; + private final ContentResolver cr; + private final ExecutorService postReleasePool; + private final UploadProgressNotifier.Handle handle; + private final File cacheDir; + private FileWrapper fw; + private long size; + private AsyncReader reader; + private long pos; + private boolean dirty; + private String writeError; + + PeergosWriteProxyCallback(FileWrapper fw, long size, String parentId, String name, + Session s, HandlerThread thread, Context appCtx, + ContentResolver cr, ExecutorService postReleasePool, + UploadProgressNotifier.Handle handle, File cacheDir) { + this.fw = fw; + this.size = size; + this.initialSize = size; + this.parentId = parentId; + this.name = name; + this.s = s; + this.thread = thread; + this.appCtx = appCtx; + this.cr = cr; + this.postReleasePool = postReleasePool; + this.handle = handle; + this.cacheDir = cacheDir; + } + + @Override + public long onGetSize() { + return initialSize; + } + + @Override + public int onRead(long offset, int len, byte[] data) throws ErrnoException { + if (offset >= size) return 0; + int toRead = (int) Math.min(len, size - offset); + try { + if (reader == null) { + reader = fw.getInputStream(s.network, s.crypto, size, p -> {}).join(); + pos = 0; + } + if (offset != pos) { + reader = reader.seek(offset).join(); + pos = offset; + } + int total = 0; + while (total < toRead) { + int n = reader.readIntoArray(data, total, toRead - total).join(); + if (n <= 0) break; + total += n; + pos += n; + } + return total; + } catch (Exception e) { + Log.w(TAG, "write-proxy onRead failed at offset=" + offset + " len=" + len, e); + try { if (reader != null) reader.close(); } catch (Exception ignored) {} + reader = null; + throw new ErrnoException("onRead", OsConstants.EIO); + } + } + + @Override + public int onWrite(long offset, int len, byte[] data) throws ErrnoException { + try { + byte[] copy = Arrays.copyOf(data, len); + long endIdx = offset + len; + fw = fw.overwriteSectionJS( + AsyncReader.build(copy), + (int) (offset >>> 32), (int) offset, + (int) (endIdx >>> 32), (int) endIdx, + Optional.empty(), s.network, s.crypto, p -> {} + ).join(); + if (endIdx > size) size = endIdx; + dirty = true; + handle.onBytes(len); + // Cached read stream is now stale — drop it so the next onRead sees the new state. + try { if (reader != null) reader.close(); } catch (Exception ignored) {} + reader = null; + return len; + } catch (Exception e) { + Log.w(TAG, "onWrite failed at offset=" + offset + " len=" + len, e); + writeError = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + throw new ErrnoException("onWrite", OsConstants.EIO); + } + } + + @Override + public void onFsync() { + // Each onWrite already commits; nothing extra to flush. + } + + @Override + public void onRelease() { + try { if (reader != null) reader.close(); } catch (Exception ignored) {} + reader = null; + thread.quitSafely(); + // Thumbnail regeneration + progress finalisation runs off-thread so the FUSE + // close path doesn't block on network I/O; the foreground service stays + // acquired until that task completes. + final FileWrapper finalFw = fw; + final long finalSize = size; + final boolean wroteAnything = dirty; + final String finalError = writeError; + postReleasePool.execute(() -> { + try { + if (wroteAnything && finalError == null) + maybeRefreshThumbnail(finalFw, finalSize); + if (finalError == null) handle.finish(); + else handle.fail(finalError); + } finally { + cr.notifyChange(DocumentsContract.buildChildDocumentsUri( + DocumentsProviderBackend.AUTHORITY, parentId), null); + StreamingForegroundService.release(appCtx); + } + }); + } + + /** Pull the post-write content back into a temp file and re-run the + * image/video thumbnailer the upload path used to use. No-op for large files + * or non-media — {@link #generateThumbnail} already caps itself at 64 MiB. */ + private void maybeRefreshThumbnail(FileWrapper finalFw, long finalSize) { + if (finalSize == 0 || finalSize > 64L * 1024 * 1024) return; + File temp = null; + try { + temp = File.createTempFile("peergos-thumb-", ".tmp", cacheDir); + try (AsyncReader r = finalFw.getInputStream(s.network, s.crypto, finalSize, p -> {}).join(); + java.io.FileOutputStream out = new java.io.FileOutputStream(temp)) { + byte[] buf = new byte[64 * 1024]; + long remaining = finalSize; + while (remaining > 0) { + int toRead = (int) Math.min(buf.length, remaining); + int n = r.readIntoArray(buf, 0, toRead).join(); + if (n <= 0) break; + out.write(buf, 0, n); + remaining -= n; + } + } + Optional thumb = generateThumbnail(temp, name); + if (thumb.isPresent()) { + Thumbnail t = thumb.get(); + String dataUrl = "data:" + t.mimeType + ";base64," + + Base64.getEncoder().encodeToString(t.data); + finalFw.updateThumbnail(dataUrl, s.network).join(); + } + } catch (Exception e) { + Log.w(TAG, "post-write thumbnail refresh failed for " + name, e); + } finally { + if (temp != null) try { Files.deleteIfExists(temp.toPath()); } catch (IOException ignored) {} + } + } + } + + private static boolean isPipeClosed(IOException e) { + String m = e.getMessage(); + return m != null && (m.contains("EPIPE") || m.contains("Broken pipe")); + } + + private static Optional generateThumbnail(File temp, String name) { + try { + byte[] head = new byte[Math.min((int) temp.length(), MimeTypes.HEADER_BYTES_TO_IDENTIFY_MIME_TYPE)]; + try (java.io.InputStream in = new java.io.FileInputStream(temp)) { + int got = 0; + while (got < head.length) { + int n = in.read(head, got, head.length - got); + if (n <= 0) break; + got += n; + } + } + String mime = MimeTypes.calculateMimeType(head, name); + if (mime.startsWith("image/")) { + long len = temp.length(); + if (len > 64L * 1024 * 1024) return Optional.empty(); + byte[] bytes = Files.readAllBytes(temp.toPath()); + return new AndroidImageThumbnailer().generateThumbnail(bytes); + } + if (mime.startsWith("video/")) + return MainActivity.generateVideoThumbnail(temp); + return Optional.empty(); + } catch (Exception e) { + Log.w(TAG, "thumbnail generation failed", e); + return Optional.empty(); + } + } + + private void addRow(MatrixCursor cursor, FileWrapper fw, String documentId, boolean parentWritable) { + boolean isDir = fw.isDirectory(); + boolean selfWritable = fw.isWritable(); + String mime = isDir ? Document.MIME_TYPE_DIR : fw.getFileProperties().mimeType; + long size = isDir ? 0 : fw.getSize(); + LocalDateTime modified = fw.getFileProperties().modified; + long modifiedMs = modified == null ? 0L + : modified.toInstant(ZoneOffset.UTC).toEpochMilli(); + int flags = 0; + if (parentWritable) + flags |= Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME; + if (selfWritable) { + if (isDir) flags |= Document.FLAG_DIR_SUPPORTS_CREATE; + else flags |= Document.FLAG_SUPPORTS_WRITE; + } + if (!isDir && fw.getFileProperties().thumbnail.isPresent()) + flags |= Document.FLAG_SUPPORTS_THUMBNAIL; + + MatrixCursor.RowBuilder row = cursor.newRow(); + row.add(Document.COLUMN_DOCUMENT_ID, documentId); + row.add(Document.COLUMN_DISPLAY_NAME, fw.getName()); + row.add(Document.COLUMN_MIME_TYPE, mime); + row.add(Document.COLUMN_SIZE, size); + row.add(Document.COLUMN_LAST_MODIFIED, modifiedMs); + row.add(Document.COLUMN_FLAGS, flags); + } + + private ParcelFileDescriptor[] createPipeOrThrow() throws FileNotFoundException { + try { return ParcelFileDescriptor.createPipe(); } + catch (IOException e) { throw new FileNotFoundException("Pipe creation failed: " + e); } + } + + private void notifyParent(String parentDocumentId) { + Uri uri = DocumentsContract.buildChildDocumentsUri( + DocumentsProviderBackend.AUTHORITY, parentDocumentId); + getContext().getContentResolver().notifyChange(uri, null); + } + + private static String joinPath(String parentDocumentId, String name) { + return parentDocumentId.endsWith("/") ? parentDocumentId + name + : parentDocumentId + "/" + name; + } + + private static FileNotFoundException rethrowAsFnf(String op, Exception cause) { + FileNotFoundException fnf = new FileNotFoundException(op + ": " + cause.getMessage()); + fnf.initCause(cause); + return fnf; + } + + private static class Session { + final UserContext context; + final NetworkAccess network; + final Crypto crypto; + Session(UserContext c, NetworkAccess n, Crypto cr) { context = c; network = n; crypto = cr; } + } + + private Session sessionOrThrow() throws FileNotFoundException { + Optional ctx = PeergosSession.context(); + Optional net = PeergosSession.network(); + Optional crypto = PeergosSession.crypto(); + if (ctx.isEmpty() || net.isEmpty() || crypto.isEmpty()) + throw new FileNotFoundException("Peergos not signed in"); + return new Session(ctx.get(), net.get(), crypto.get()); + } + + private FileWrapper lookupOrThrow(String documentId) throws FileNotFoundException { + Session s = sessionOrThrow(); + Optional fw = s.context.getByPath(documentId).join(); + if (fw.isEmpty()) throw new FileNotFoundException(documentId); + return fw.get(); + } +} diff --git a/app/src/main/java/peergos/android/PeergosSession.java b/app/src/main/java/peergos/android/PeergosSession.java new file mode 100644 index 0000000..6af1acb --- /dev/null +++ b/app/src/main/java/peergos/android/PeergosSession.java @@ -0,0 +1,37 @@ +package peergos.android; + +import java.util.Optional; + +import peergos.server.net.MountConfigHandler; +import peergos.shared.Crypto; +import peergos.shared.NetworkAccess; +import peergos.shared.user.UserContext; + +public final class PeergosSession { + + private static volatile UserContext context; + private static volatile NetworkAccess network; + private static volatile Crypto crypto; + private static volatile MountConfigHandler mountHandler; + + private PeergosSession() {} + + public static void publish(UserContext c, NetworkAccess n, Crypto cr) { + context = c; + network = n; + crypto = cr; + } + + public static void clear() { + context = null; + network = null; + crypto = null; + } + + public static void setMountHandler(MountConfigHandler h) { mountHandler = h; } + public static Optional mountHandler() { return Optional.ofNullable(mountHandler); } + + public static Optional context() { return Optional.ofNullable(context); } + public static Optional network() { return Optional.ofNullable(network); } + public static Optional crypto() { return Optional.ofNullable(crypto); } +} diff --git a/app/src/main/java/peergos/android/ShareReceiverActivity.java b/app/src/main/java/peergos/android/ShareReceiverActivity.java new file mode 100644 index 0000000..c8c8aa2 --- /dev/null +++ b/app/src/main/java/peergos/android/ShareReceiverActivity.java @@ -0,0 +1,161 @@ +package peergos.android; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.provider.DocumentsContract; +import android.util.Log; +import android.widget.Toast; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class ShareReceiverActivity extends Activity { + + private static final String TAG = "PeergosShareReceiver"; + private static final int REQUEST_CREATE = 4001; + + private static final String STATE_SOURCE = "peergos.share.source"; + private static final String STATE_NAME = "peergos.share.name"; + + private Uri sourceUri; + private String displayName; + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (sourceUri != null) outState.putParcelable(STATE_SOURCE, sourceUri); + if (displayName != null) outState.putString(STATE_NAME, displayName); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState != null) { + sourceUri = savedInstanceState.getParcelable(STATE_SOURCE); + displayName = savedInstanceState.getString(STATE_NAME); + if (sourceUri != null) return; + } + + Intent in = getIntent(); + String action = in == null ? null : in.getAction(); + if (!Intent.ACTION_SEND.equals(action)) { + Toast.makeText(this, "Unsupported share action", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + sourceUri = in.getParcelableExtra(Intent.EXTRA_STREAM); + if (sourceUri == null) { + Toast.makeText(this, "No file to share", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + String mimeType = in.getType(); + if (mimeType == null) mimeType = getContentResolver().getType(sourceUri); + if (mimeType == null) mimeType = "application/octet-stream"; + + displayName = queryDisplayName(sourceUri); + if (displayName == null) displayName = defaultName(mimeType); + + Intent create = new Intent(Intent.ACTION_CREATE_DOCUMENT); + create.addCategory(Intent.CATEGORY_OPENABLE); + create.setType(mimeType); + create.putExtra(Intent.EXTRA_TITLE, displayName); + Uri rootDoc = peergosRootDocUri(); + if (rootDoc != null) + create.putExtra(DocumentsContract.EXTRA_INITIAL_URI, rootDoc); + startActivityForResult(create, REQUEST_CREATE); + } + + private static Uri peergosRootDocUri() { + return PeergosSession.context() + .map(ctx -> DocumentsContract.buildDocumentUri( + DocumentsProviderBackend.AUTHORITY, "/" + ctx.username)) + .orElse(null); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode != REQUEST_CREATE) { finish(); return; } + if (resultCode != Activity.RESULT_OK || data == null) { finish(); return; } + Uri target = data.getData(); + if (target == null) { finish(); return; } + + new Thread(() -> { + long copied = -1; + String error = null; + try { + copied = copy(sourceUri, target); + Log.i(TAG, "Share copy: " + copied + " bytes from " + sourceUri + " to " + target); + } catch (Throwable t) { + error = t.getClass().getSimpleName() + ": " + t.getMessage(); + Log.w(TAG, "Share copy failed", t); + } + final long finalCopied = copied; + final String finalError = error; + runOnUiThread(() -> { + String msg = finalError != null ? "Save failed: " + finalError + : "Saved " + finalCopied + " bytes to Peergos"; + Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_LONG).show(); + finish(); + }); + }, "PeergosShareCopy").start(); + } + + private long copy(Uri source, Uri target) throws IOException { + ContentResolver cr = getContentResolver(); + try (InputStream in = cr.openInputStream(source); + OutputStream out = cr.openOutputStream(target)) { + if (in == null) throw new IOException("openInputStream returned null for " + source); + if (out == null) throw new IOException("openOutputStream returned null for " + target); + byte[] buf = new byte[64 * 1024]; + long total = 0; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + total += n; + } + out.flush(); + return total; + } + } + + private String queryDisplayName(Uri uri) { + try (android.database.Cursor c = getContentResolver().query(uri, + new String[] { DocumentsContract.Document.COLUMN_DISPLAY_NAME }, null, null, null)) { + if (c != null && c.moveToFirst()) { + int idx = c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME); + if (idx >= 0) { + String s = c.getString(idx); + if (s != null && !s.isEmpty()) return s; + } + } + } catch (Exception ignored) {} + String last = uri.getLastPathSegment(); + if (last != null && !last.isEmpty()) { + int slash = last.lastIndexOf('/'); + return slash >= 0 ? last.substring(slash + 1) : last; + } + return null; + } + + private static String defaultName(String mimeType) { + long ts = System.currentTimeMillis(); + String ext; + switch (mimeType) { + case "image/jpeg": ext = ".jpg"; break; + case "image/png": ext = ".png"; break; + case "image/webp": ext = ".webp"; break; + case "video/mp4": ext = ".mp4"; break; + case "text/plain": ext = ".txt"; break; + default: ext = ""; break; + } + return "shared-" + ts + ext; + } +} diff --git a/app/src/main/java/peergos/android/StreamingForegroundService.java b/app/src/main/java/peergos/android/StreamingForegroundService.java new file mode 100644 index 0000000..dacb8d8 --- /dev/null +++ b/app/src/main/java/peergos/android/StreamingForegroundService.java @@ -0,0 +1,71 @@ +package peergos.android; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import androidx.core.app.NotificationCompat; + +import java.util.concurrent.atomic.AtomicInteger; + +/** Keeps the app's process exempt from Doze while a SAF consumer (e.g. the system + * video player) holds an open proxy file descriptor against PeergosDocumentsProvider. + * Without this, background network is suspended a few minutes after the WebView + * loses visibility and chunk fetches fail with "Unable to resolve host". */ +public class StreamingForegroundService extends Service { + + public static final String CHANNEL_ID = "peergos-streaming"; + public static final int NOTIFICATION_ID = 81; + public static final String ACTION_STOP = "peergos.android.STREAMING_STOP"; + + private static final AtomicInteger openFds = new AtomicInteger(0); + + /** Increment the open-FD count and start the service on the 0→1 transition. */ + public static void acquire(Context ctx) { + if (openFds.getAndIncrement() == 0) { + ctx.startForegroundService(new Intent(ctx, StreamingForegroundService.class)); + } + } + + /** Decrement the open-FD count and stop the service on the 1→0 transition. */ + public static void release(Context ctx) { + if (openFds.decrementAndGet() <= 0) { + Intent stop = new Intent(ctx, StreamingForegroundService.class); + stop.setAction(ACTION_STOP); + ctx.startService(stop); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && ACTION_STOP.equals(intent.getAction())) { + if (openFds.get() <= 0) stopSelf(); + return START_NOT_STICKY; + } + createNotificationChannel(); + Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Peergos") + .setContentText("Streaming files...") + .setSmallIcon(android.R.drawable.stat_sys_download) + .setOngoing(true) + .build(); + startForeground(NOTIFICATION_ID, notification); + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { return null; } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, "Streaming", NotificationManager.IMPORTANCE_LOW); + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm != null) nm.createNotificationChannel(channel); + } + } +} diff --git a/app/src/main/java/peergos/android/SyncService.java b/app/src/main/java/peergos/android/SyncService.java new file mode 100644 index 0000000..396d636 --- /dev/null +++ b/app/src/main/java/peergos/android/SyncService.java @@ -0,0 +1,63 @@ +package peergos.android; + +import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC; + +import android.app.Notification; +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import androidx.core.app.NotificationCompat; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Long-running foreground service that drains the configured sync pairs to completion. + * Must be started while the app is in the foreground (Activity visible / WebView + * callback) so the BG-FGS restriction does not apply — typically from MainActivity + * on launch and from the add-pair / sync-now JS bridges. + */ +public class SyncService extends Service { + private static final AtomicBoolean running = new AtomicBoolean(false); + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + startForeground(MainActivity.SYNC_NOTIFICATION_ID, buildNotification(), + FOREGROUND_SERVICE_TYPE_DATA_SYNC); + // SyncWorker.lock would already serialise this, but guarding here lets us + // stopSelf the redundant start immediately rather than parking a thread. + if (!running.compareAndSet(false, true)) { + stopSelf(startId); + return START_NOT_STICKY; + } + final int id = startId; + new Thread(() -> { + try { + Path peergosDir = Paths.get(getFilesDir().getAbsolutePath()); + SyncWorker.runSyncOnce(getApplicationContext(), peergosDir); + } finally { + running.set(false); + stopForeground(STOP_FOREGROUND_REMOVE); + stopSelf(id); + } + }, "SyncService").start(); + return START_NOT_STICKY; + } + + private Notification buildNotification() { + return new NotificationCompat.Builder(this, MainActivity.SYNC_CHANNEL_ID) + .setSmallIcon(R.drawable.notification_background) + .setContentTitle("Sync") + .setContentText("Sync in progress...") + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .build(); + } +} diff --git a/app/src/main/java/peergos/android/SyncWorker.java b/app/src/main/java/peergos/android/SyncWorker.java index c9e8eb1..a45a543 100644 --- a/app/src/main/java/peergos/android/SyncWorker.java +++ b/app/src/main/java/peergos/android/SyncWorker.java @@ -1,18 +1,12 @@ package peergos.android; -import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC; - -import android.app.Notification; import android.content.Context; -import android.content.pm.PackageManager; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; import android.net.Uri; import androidx.annotation.NonNull; -import androidx.core.app.ActivityCompat; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.work.Data; -import androidx.work.ForegroundInfo; import androidx.work.Worker; import androidx.work.WorkerParameters; @@ -24,12 +18,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; +import java.util.logging.Level; +import java.util.logging.Logger; import peergos.server.Builder; import peergos.server.JdbcPkiCache; @@ -41,6 +38,7 @@ import peergos.server.sync.SyncConfig; import peergos.server.sync.SyncRunner; import peergos.server.util.Args; +import peergos.server.util.Logging; import peergos.shared.Crypto; import peergos.shared.NetworkAccess; import peergos.shared.OnlineState; @@ -57,46 +55,31 @@ public class SyncWorker extends Worker { public static final SyncRunner.StatusHolder status = new SyncRunner.StatusHolder(); + private static final Logger LOG = Logging.LOG(); public static final Object lock = new Object(); - private final WorkerParameters params; public SyncWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { super(context, workerParams); - this.params = workerParams; } @NonNull @Override - public ForegroundInfo getForegroundInfo() { - return buildForegroundInfo("Sync", "Sync in progress...", MainActivity.SYNC_NOTIFICATION_ID, NotificationCompat.PRIORITY_MIN); - } - - private ForegroundInfo buildForegroundInfo(String title, String text, int notificationId, int priority) { - Notification notif = new NotificationCompat.Builder(getApplicationContext(), MainActivity.SYNC_CHANNEL_ID) - .setSmallIcon(R.drawable.notification_background) - .setContentTitle(title) - .setContentText(text) - .setOngoing(true) - .setPriority(priority) - .build(); - return new ForegroundInfo(notificationId, notif, FOREGROUND_SERVICE_TYPE_DATA_SYNC); + public Result doWork() { + Path peergosDir = Paths.get(getInputData().getString("PEERGOS_PATH")); + return runSyncOnce(getApplicationContext(), peergosDir) ? Result.success() : Result.failure(); } - @NonNull - @Override - public Result doWork() { - setForegroundAsync(getForegroundInfo()); + /** + * Run a single sync pass to completion (or first error). Holds the global sync lock + * so it cooperates with SyncService and other periodic invocations — at most one + * sync runs at a time across the process. + * + * @return true on clean completion, false on UnknownHostException / fatal failure + */ + public static boolean runSyncOnce(Context context, Path peergosDir) { synchronized (lock) { try { System.out.println("SYNC: starting work"); - showNotification("Sync", "Sync in progress...", MainActivity.SYNC_NOTIFICATION_ID, NotificationCompat.PRIORITY_MIN); - Data params = this.params.getInputData(); - int sleepMillis = params.getInt("sleep", 0); - try { - Thread.sleep(sleepMillis); - } catch (InterruptedException ignored) { - } - Path peergosDir = Paths.get(params.getString("PEERGOS_PATH")); Crypto crypto = Main.initCrypto(new ScryptAndroid()); Path oldConfigFile = peergosDir.resolve(SyncConfigHandler.OLD_SYNC_CONFIG_FILENAME); Path jsonSyncConfig = peergosDir.resolve(SyncConfigHandler.SYNC_CONFIG_FILENAME); @@ -110,7 +93,7 @@ public Result doWork() { .with("PEERGOS_PATH", peergosDir.toString()) .with("pki-cache-sql-file", "pki-cache.sqlite"); - URL target = new URL(args.getArg("peergos-url", "https://peergos.net")); + URL target = new URL(PeergosApp.readSavedServerUrl(peergosDir).orElse("https://peergos.net")); HttpPoster poster = new AndroidPoster(target, true, Optional.empty(), Optional.of("Peergos-" + UserService.CURRENT_VERSION + "-android")); ContentAddressedStorage localDht = NetworkAccess.buildLocalDht(poster, true, crypto.hasher); CoreNode directCore = NetworkAccess.buildDirectCorenode(poster); @@ -128,7 +111,7 @@ public Result doWork() { crypto.hasher, Collections.emptyList(), false); if (syncConfig.links.isEmpty()) { System.out.println("No sync args"); - return Result.success(); + return true; } List links = syncConfig.links; List localDirs = syncConfig.localDirs; @@ -137,12 +120,39 @@ public Result doWork() { int maxDownloadParallelism = syncConfig.maxDownloadParallelism; int minFreeSpacePercent = syncConfig.minFreeSpacePercent; + // On a metered (mobile) network, drop pairs that aren't allowed there. + // The periodic worker constraint is CONNECTED, not UNMETERED, so each + // pair's allowOnMobile flag is what gates mobile-data usage. + if (isMeteredNetwork(context)) { + List fLinks = new ArrayList<>(); + List fLocalDirs = new ArrayList<>(); + List fSyncLocalDeletes = new ArrayList<>(); + List fSyncRemoteDeletes = new ArrayList<>(); + for (int i = 0; i < links.size(); i++) { + if (syncConfig.allowOnMobile.get(i)) { + fLinks.add(links.get(i)); + fLocalDirs.add(localDirs.get(i)); + fSyncLocalDeletes.add(syncLocalDeletes.get(i)); + fSyncRemoteDeletes.add(syncRemoteDeletes.get(i)); + } + } + if (fLinks.isEmpty()) { + System.out.println("SYNC: on metered network and no pairs allow mobile data; skipping"); + return true; + } + links = fLinks; + localDirs = fLocalDirs; + syncLocalDeletes = fSyncLocalDeletes; + syncRemoteDeletes = fSyncRemoteDeletes; + } + DirectorySync.syncDirs(links, localDirs, syncLocalDeletes, syncRemoteDeletes, maxDownloadParallelism, minFreeSpacePercent, true, uri -> new AndroidSyncFileSystem(Uri.parse(uri), - getApplicationContext(), crypto), peergosDir, + context, crypto), peergosDir, status, m -> { status.setStatus(m); + LOG.info(m); }, e -> { if (e != null) { @@ -150,27 +160,38 @@ public Result doWork() { if (!(cause instanceof UnknownHostException)) { status.setError(cause.getMessage()); } + LOG.log(Level.WARNING, cause, cause::getMessage); } }, network, crypto); + return true; } catch (MalformedURLException e) { e.printStackTrace(); + return true; } catch (Exception e) { - if (getCause(e) instanceof UnknownHostException) - return Result.failure(); - String msg = e.getMessage(); - if (msg != null && !msg.trim().isEmpty()) { + Throwable cause = getCause(e); + if (cause instanceof UnknownHostException) + return false; + String msg = cause.getMessage(); + if (msg != null && !msg.trim().isEmpty()) status.setError(msg); - showNotification("Sync error", msg, MainActivity.SYNC_NOTIFICATION_ERROR_ID, NotificationCompat.PRIORITY_DEFAULT); - } - return Result.failure(); - } finally { - closeNotification(MainActivity.SYNC_NOTIFICATION_ID); + LOG.log(Level.WARNING, cause, cause::getMessage); + return false; } - - return Result.success(); } } + private static boolean isMeteredNetwork(Context context) { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) return false; + Network active = cm.getActiveNetwork(); + if (active == null) return false; + NetworkCapabilities caps = cm.getNetworkCapabilities(active); + if (caps == null) return false; + // NET_CAPABILITY_NOT_METERED is set on Wi-Fi/Ethernet by default; mobile data + // and metered hotspots lack it. + return !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED); + } + private static Throwable getCause(Throwable t) { Throwable cause = t.getCause(); if (cause == null) @@ -183,37 +204,4 @@ private static Throwable getCause(Throwable t) { return getCause(cause); return cause; } - - public void closeNotification(int notificationId) { - NotificationManagerCompat mgr = NotificationManagerCompat.from(getApplicationContext()); - mgr.cancel(notificationId); - } - - public void showNotification(String title, String text, int notificationId, int priority) { - DirectorySync.log(text); -// Context context = getApplicationContext(); - // This PendingIntent can be used to cancel the worker -// PendingIntent intent = WorkManager.getInstance(context) -// .createCancelPendingIntent(getId()); - - NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext(), MainActivity.SYNC_CHANNEL_ID) - .setSmallIcon(R.drawable.notification_background) - .setContentTitle(title) - .setContentText(text) - .setOngoing(true) - // Add the cancel action to the notification which can - // be used to cancel the worker -// .addAction(android.R.drawable.ic_delete, "Cancel", intent) - .setPriority(priority); - - NotificationManagerCompat mgr = NotificationManagerCompat.from(getApplicationContext()); - if (ActivityCompat.checkSelfPermission(getApplicationContext(), - android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - return; - } - // notificationId is a unique int for each notification that you must define. - Notification notif = builder.build(); - mgr.notify(notificationId, notif); - setForegroundAsync(new ForegroundInfo(notificationId, notif, FOREGROUND_SERVICE_TYPE_DATA_SYNC)); - } } diff --git a/app/src/main/java/peergos/android/UploadProgressNotifier.java b/app/src/main/java/peergos/android/UploadProgressNotifier.java new file mode 100644 index 0000000..12a97f5 --- /dev/null +++ b/app/src/main/java/peergos/android/UploadProgressNotifier.java @@ -0,0 +1,95 @@ +package peergos.android; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +import androidx.core.app.NotificationCompat; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +class UploadProgressNotifier { + + private static final String CHANNEL_ID = "peergos-docs-uploads"; + private static final AtomicInteger nextId = new AtomicInteger(1); + + private final Context ctx; + private final NotificationManager nm; + + UploadProgressNotifier(Context ctx) { + this.ctx = ctx; + this.nm = ctx.getSystemService(NotificationManager.class); + ensureChannel(); + } + + private void ensureChannel() { + if (nm.getNotificationChannel(CHANNEL_ID) != null) return; + NotificationChannel ch = new NotificationChannel(CHANNEL_ID, "Peergos uploads", + NotificationManager.IMPORTANCE_LOW); + ch.setDescription("File upload progress"); + nm.createNotificationChannel(ch); + } + + Handle start(String filename, long size) { + return new Handle(nextId.incrementAndGet(), filename, size); + } + + class Handle { + private final int id; + private final String filename; + private final long size; + private final AtomicLong sent = new AtomicLong(); + private volatile long lastUpdateMs; + + private Handle(int id, String filename, long size) { + this.id = id; + this.filename = filename; + this.size = size; + postProgress(0); + } + + void onBytes(long delta) { + long cumulative = sent.addAndGet(delta); + long now = System.currentTimeMillis(); + if (now - lastUpdateMs < 200 && cumulative < size) return; + lastUpdateMs = now; + postProgress(cumulative); + } + + private void postProgress(long cumulative) { + int progress = size > 0 ? (int) Math.min(100, cumulative * 100 / size) : 0; + try { + Notification n = new NotificationCompat.Builder(ctx, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher_round) + .setContentTitle("Uploading " + filename) + .setContentText(progress + "%") + .setProgress(100, progress, size <= 0) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build(); + nm.notify(id, n); + } catch (SecurityException ignored) { + // POST_NOTIFICATIONS not granted (Android 13+). Silently skip. + } + } + + void finish() { + nm.cancel(id); + } + + void fail(String message) { + try { + Notification n = new NotificationCompat.Builder(ctx, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher_round) + .setContentTitle("Upload failed: " + filename) + .setContentText(message) + .setAutoCancel(true) + .build(); + nm.notify(id, n); + } catch (SecurityException ignored) {} + } + } +} diff --git a/app/src/main/jniLibs/x86_64/libsqlitejdbc.so b/app/src/main/jniLibs/x86_64/libsqlitejdbc.so new file mode 100644 index 0000000..4bfd40c Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libsqlitejdbc.so differ diff --git a/app/src/test/java/peergos/android/AndroidAsyncReaderTest.java b/app/src/test/java/peergos/android/AndroidAsyncReaderTest.java new file mode 100644 index 0000000..b06e65d --- /dev/null +++ b/app/src/test/java/peergos/android/AndroidAsyncReaderTest.java @@ -0,0 +1,91 @@ +package peergos.android; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.util.Random; + +/** + * Regression test for AndroidAsyncReader.seek (the mechanism AndroidSyncFileSystem.getBytes + * relies on to position a reader at a diff-upload's start offset). + * + * InputStream.skip(n) is not guaranteed to skip n bytes — for content-provider / pipe backed + * streams it routinely skips fewer and returns the actual count. seek() must honour that and + * keep skipping until the full delta is consumed, otherwise the reader is left short of the + * requested offset and every subsequent read returns bytes from the wrong position — which, + * during a diff upload of an edited file, writes the wrong bytes into the remote chunk. + */ +public class AndroidAsyncReaderTest { + + /** Mimics a stream whose skip() under-skips: at most maxSkipPerCall bytes per call. */ + private static class ShortSkipStream extends ByteArrayInputStream { + private final int maxSkipPerCall; + ShortSkipStream(byte[] buf, int maxSkipPerCall) { + super(buf); + this.maxSkipPerCall = maxSkipPerCall; + } + @Override + public synchronized long skip(long n) { + return super.skip(Math.min(n, maxSkipPerCall)); + } + } + + /** Mimics a stream whose read() returns fewer bytes than asked: at most maxPerRead per call. */ + private static class ShortReadStream extends ByteArrayInputStream { + private final int maxPerRead; + ShortReadStream(byte[] buf, int maxPerRead) { + super(buf); + this.maxPerRead = maxPerRead; + } + @Override + public synchronized int read(byte[] b, int off, int len) { + return super.read(b, off, Math.min(len, maxPerRead)); + } + } + + @Test + public void readIntoArrayFullyFillsDespiteShortReads() { + // The AsyncReader contract (relied on by FileUploader, which encrypts the whole chunk + // buffer) is: readIntoArray fills the requested length unless EOF. InputStream.read may + // return fewer bytes than asked (routine for content-provider streams), so readIntoArray + // must loop; otherwise an uploaded chunk is zero-padded and the file is corrupted. + byte[] data = new byte[100_000]; + new Random(7).nextBytes(data); + + AndroidAsyncReader reader = new AndroidAsyncReader( + new ShortReadStream(data, 4096), // returns at most 4096 bytes per read() + () -> new ShortReadStream(data, 4096)); + + byte[] got = new byte[data.length]; + int read = reader.readIntoArray(got, 0, got.length).join(); + + Assert.assertEquals("readIntoArray must fill the whole buffer, not stop at the first short read", + data.length, read); + Assert.assertArrayEquals(data, got); + } + + @Test + public void seekLandsAtRequestedOffsetDespiteShortSkip() { + byte[] data = new byte[100_000]; + new Random(42).nextBytes(data); + int offset = 40_000; + int len = 1000; + + AndroidAsyncReader reader = new AndroidAsyncReader( + new ShortSkipStream(data, 7), // skips at most 7 bytes per skip() call + () -> new ShortSkipStream(data, 7)); + + reader.seek(offset).join(); + + byte[] got = new byte[len]; + int read = reader.readIntoArray(got, 0, len).join(); + Assert.assertEquals(len, read); + + byte[] expected = new byte[len]; + System.arraycopy(data, offset, expected, 0, len); + Assert.assertArrayEquals( + "seek must land at the requested offset even when the stream's skip() under-skips", + expected, got); + } +} diff --git a/app/src/test/java/peergos/android/HashChunksTest.java b/app/src/test/java/peergos/android/HashChunksTest.java new file mode 100644 index 0000000..35ddc67 --- /dev/null +++ b/app/src/test/java/peergos/android/HashChunksTest.java @@ -0,0 +1,95 @@ +package peergos.android; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import peergos.shared.user.fs.Chunk; + +/** + * Regression test for AndroidSyncFileSystem.hashChunks. + * + * The per-chunk hash must be SHA-256 over that chunk's exact byte range, independent of how + * the underlying InputStream chunks its reads. AndroidSyncFileSystem reads with a plain + * fin.read(buf), which is NOT guaranteed to fill the buffer (content-provider / SAF backed + * file descriptors regularly return short reads). If a read straddles a Chunk.MAX_SIZE + * boundary, the bytes past the boundary ("leftover") must be carried into the next chunk's + * digest, matching the canonical implementation (peergos.server.crypto.hash.ScryptJava). + * + * The buggy version reset chunkOffset to 0 and dropped those leftover bytes, so every chunk + * after the first straddling read diverged from the true content hash. + */ +public class HashChunksTest { + + /** SHA-256 of each exact [i*MAX, (i+1)*MAX) slice — the correct answer by definition. */ + private static List groundTruth(byte[] data) throws Exception { + List out = new ArrayList<>(); + if (data.length == 0) { + out.add(MessageDigest.getInstance("SHA-256").digest()); + return out; + } + for (int start = 0; start < data.length; start += Chunk.MAX_SIZE) { + int end = Math.min(data.length, start + Chunk.MAX_SIZE); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(data, start, end - start); + out.add(md.digest()); + } + return out; + } + + /** ByteArrayInputStream that returns one deliberately short read to shift buffer alignment + * so a later read straddles a chunk boundary — mimicking a content-provider FD. */ + private static class ShortFirstRead extends ByteArrayInputStream { + private boolean shortened = false; + ShortFirstRead(byte[] buf) { super(buf); } + @Override + public synchronized int read(byte[] b, int off, int len) { + if (!shortened) { + shortened = true; + return super.read(b, off, Math.min(len, 40_000)); + } + return super.read(b, off, len); + } + @Override + public synchronized int read(byte[] b) { + return read(b, 0, b.length); + } + } + + private static void assertChunksMatch(List expected, List actual) { + Assert.assertEquals("chunk count", expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) + Assert.assertArrayEquals("chunk " + i + " hash", expected.get(i), actual.get(i)); + } + + @Test + public void hashChunksIsCorrectWithShortReads() throws Exception { + // 3 chunks (two full + a partial), enough to expose a boundary-straddling read. + int size = 2 * Chunk.MAX_SIZE + 7_777; + byte[] data = new byte[size]; + new Random(42).nextBytes(data); + + List expected = groundTruth(data); + + // Sanity: with full reads the alignment is preserved and the hash is already correct. + assertChunksMatch(expected, AndroidSyncFileSystem.hashChunks(new ByteArrayInputStream(data), size)); + + // The real test: a short read must not corrupt the chunk hashes. + assertChunksMatch(expected, AndroidSyncFileSystem.hashChunks(new ShortFirstRead(data), size)); + } + + @Test + public void parallelHashChunksIsCorrectWithShortReads() throws Exception { + int size = 2 * Chunk.MAX_SIZE + 7_777; + byte[] data = new byte[size]; + new Random(7).nextBytes(data); + + List expected = groundTruth(data); + assertChunksMatch(expected, AndroidSyncFileSystem.parallelHashChunks(() -> new ShortFirstRead(data), 8, size)); + } +} diff --git a/web-ui b/web-ui new file mode 160000 index 0000000..867ab45 --- /dev/null +++ b/web-ui @@ -0,0 +1 @@ +Subproject commit 867ab45cba03a0fae4eae561e604f37ad104e61f