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