diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 91aa3c28..ac67231a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,8 +67,8 @@ android { applicationId = "net.opendasharchive.openarchive" minSdk = 29 targetSdk = 36 - versionCode = 30029 - versionName = "4.0.6" + versionCode = 30032 + versionName = "4.0.11" multiDexEnabled = true vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -87,8 +87,8 @@ android { getByName("release") { signingConfig = signingConfigs.getByName("debug") - isMinifyEnabled = false - isShrinkResources = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } @@ -119,6 +119,10 @@ android { val localProps = loadLocalProperties() val acraEmail = localProps.getProperty("ACRA_EMAIL") ?: System.getenv("ACRA_EMAIL") ?: "" buildConfigField("String", "ACRA_EMAIL", "\"$acraEmail\"") + // No real devices use x86/x86_64 — emulators can use armeabi-v7a via translation + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } } // Environment dimension @@ -177,15 +181,13 @@ android { androidResources { generateLocaleConfig = true + // Strip unused locale resources from all transitive libraries + localeFilters += setOf( + "en", "ar", "fa", "fr", "es", "de", "ckb", "pt", "ru", "zh", "tr", "id", "uk", "nl" + ) } - configurations.all { - resolutionStrategy { - force("org.bouncycastle:bcprov-jdk15to18:1.72") - exclude(group = "org.bouncycastle", module = "bcprov-jdk15on") - } - } } base { @@ -211,12 +213,7 @@ dependencies { implementation(libs.androidx.exifinterface) // AndroidX UI Components - implementation(libs.androidx.constraintlayout) - implementation(libs.androidx.constraintlayout.compose) - implementation(libs.androidx.coordinatorlayout) implementation(libs.androidx.recyclerview) - implementation(libs.androidx.recyclerview.selection) - implementation(libs.androidx.viewpager2) implementation(libs.androidx.swiperefresh) // AndroidX Activity & Fragment @@ -232,17 +229,14 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.process) - // AndroidX Navigation + // AndroidX Navigation (fragment kept for BaseFragment.findNavController) implementation(libs.androidx.navigation.fragment) - implementation(libs.androidx.navigation.ui) implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.navigation.fragment.compose) // AndroidX Navigation3 implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.lifecycle.viewmodel.navigation3) - implementation(libs.koin.compose.navigation3) implementation(libs.androidx.navigationevent) // Compose UI @@ -287,17 +281,14 @@ dependencies { implementation(libs.okhttp) implementation(libs.okhttp.logging) implementation(libs.retrofit) - implementation(libs.retrofit.gson) implementation(libs.retrofit.kotlinx.serialization) implementation(libs.guardianproject.sardine) - implementation(libs.jsoup) // Images & Media implementation(libs.coil) implementation(libs.coil.compose) implementation(libs.coil.video) implementation(libs.coil.network) - implementation(libs.picasso) // CameraX implementation(libs.androidx.camera.core) @@ -308,7 +299,7 @@ dependencies { implementation(libs.androidx.camera.compose) implementation(libs.androidx.camera.extensions) - // Barcode Scanning + // Barcode Scanning (ZXing only — ML Kit removed, 20 MB native saved) implementation(libs.zxing.core) implementation(libs.zxing.android.embedded) @@ -331,17 +322,9 @@ dependencies { //implementation(libs.google.http.client.gson) //implementation(libs.google.drive.api) - // Security & Cryptography - implementation("com.google.crypto.tink:tink-android:1.20.0") - implementation(libs.bouncycastle.bcprov) - implementation(libs.bouncycastle.bcpkix) - api(libs.bouncycastle.bcpg) - implementation(libs.netcipher) - - // Tor & Bitcoin + // Tor implementation(libs.tor.android) implementation(libs.jtorctl) - implementation(libs.bitcoinj.core) // C2PA - Content Authenticity // TODO: Add actual C2PA library once available @@ -354,18 +337,10 @@ dependencies { // implementation(libs.simple.c2pa) // implementation(libs.jna) - // Barcode Scanning - implementation(libs.google.mlkit.barcode) - implementation(libs.zxing.core) - implementation(libs.zxing.android.embedded) - // Utilities implementation(libs.timber) implementation(libs.gson) - implementation(libs.guava) - implementation(libs.guava.listenablefuture) implementation(libs.dotsindicator) - implementation(libs.permissionx) implementation(libs.satyan.sugar) // Analytics Module (includes crash reporting) @@ -386,8 +361,10 @@ dependencies { detektPlugins(libs.detekt.rules.compose) } +// xpp3 ships XmlPullParser which conflicts with Android's built-in version — exclude the jar configurations.all { - exclude(group = "com.google.guava", module = "listenablefuture") + exclude(group = "xpp3", module = "xpp3") + exclude(group = "xpp3", module = "xpp3_min") } detekt { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 5c86c45e..58bd2486 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,29 +1,220 @@ -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in /home/josh/android-sdks/tools/proguard/proguard-android.txt -# You can edit the include path and order by changing the proguardFiles -# directive in build.gradle.kts. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# Add any project specific keep options here: - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - +# ============================================================ +# OkHttp / Okio +# ============================================================ -dontwarn okhttp3.** -dontwarn okio.** -dontwarn javax.annotation.** --dontwarn org.conscrypt.** -# A resource is loaded with a relative path so the package of this class must be preserved. -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase +# ============================================================ +# AndroidX Credentials +# ============================================================ -if class androidx.credentials.CredentialManager --keep class androidx.credentials.playservices.** { - *; -} \ No newline at end of file +-keep class androidx.credentials.playservices.** { *; } + +# ============================================================ +# Room — keep entity/DAO class names for SQLite reflection +# ============================================================ +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-keep @androidx.room.Dao interface * +-keepclassmembers @androidx.room.Entity class * { *; } +-keepclassmembers @androidx.room.Dao interface * { *; } +-keep class * extends androidx.room.migration.Migration + +# ============================================================ +# Koin — DSL-based DI, no annotations, but uses class names at runtime +# ============================================================ +-keep class org.koin.** { *; } +-keepnames class * { @org.koin.core.annotation.* *; } + +# ============================================================ +# Retrofit + kotlinx.serialization +# ============================================================ +-keepattributes Signature, InnerClasses, EnclosingMethod +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# kotlinx.serialization +-keepclassmembers class kotlinx.serialization.json.** { *** Companion; } +-keepclassmembers @kotlinx.serialization.Serializable class ** { + *** Companion; + kotlinx.serialization.KSerializer serializer(...); +} +-keep,includedescriptorclasses class net.opendasharchive.openarchive.**$$serializer { *; } +-keepclasseswithmembers class * { + @kotlinx.serialization.Serializable ; +} + +# ============================================================ +# Gson — only used for Sugar ORM legacy models +# Remove once Sugar → Room migration complete +# ============================================================ +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; + @com.google.gson.annotations.Expose ; +} +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# ============================================================ +# Sugar ORM — keep until Room migration complete +# ============================================================ +-keep class com.orm.** { *; } +-keep class * extends com.orm.SugarRecord { *; } +-keepclassmembers class * extends com.orm.SugarRecord { *; } + +# ============================================================ +# Tor +# ============================================================ +-keep class net.freehaven.tor.control.** { *; } +-keep class org.torproject.jni.** { *; } +-dontwarn org.torproject.** + +# ============================================================ +# Snowbird / C2PA native JNI bridges +# ============================================================ +-keep class net.opendasharchive.openarchive.services.snowbird.SnowbirdBridge { *; } +-keep class net.opendasharchive.openarchive.util.C2paFfi { *; } + +# ============================================================ +# App — keep JNI-callable methods and Parcelables +# ============================================================ +-keepclasseswithmembernames class * { + native ; +} +-keepclassmembers class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} +-keepclassmembers class * implements java.io.Serializable { + private static final java.io.ObjectStreamField[] serialPersistentFields; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# ============================================================ +# Compose — keep stability annotations +# ============================================================ +-keep class androidx.compose.runtime.** { *; } +-dontwarn androidx.compose.** + +# ============================================================ +# Coil +# ============================================================ +-dontwarn coil.** + +# ============================================================ +# Timber +# ============================================================ +-dontwarn org.jetbrains.annotations.** + +# ============================================================ +# Kotlin reflect (used by Koin + serialization) +# ============================================================ +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt + +# ============================================================ +# CameraX +# ============================================================ +-keep class androidx.camera.** { *; } +-dontwarn androidx.camera.** + +# ============================================================ +# ZXing +# ============================================================ +-keep class com.google.zxing.** { *; } +-keep class com.journeyapps.barcodescanner.** { *; } + +# ============================================================ +# WorkManager +# ============================================================ +-keep class * extends androidx.work.Worker +-keep class * extends androidx.work.CoroutineWorker +-keepclassmembers class * extends androidx.work.Worker { public (...); } +-keepclassmembers class * extends androidx.work.CoroutineWorker { public (...); } + +# ============================================================ +# CleanInsights SDK — uses Moshi reflection to deserialize Configuration subclass. +# R8 must not rename or strip any CleanInsights classes or their members. +# ============================================================ +-keep class org.cleaninsights.sdk.** { *; } +-keepclassmembers class org.cleaninsights.sdk.** { *; } + +# Moshi — keep all JsonClass-annotated classes and their adapters +-keep @com.squareup.moshi.JsonClass class * { *; } +-keepclasseswithmembers class * { + @com.squareup.moshi.Json ; +} +-keep class com.squareup.moshi.** { *; } +-keepclassmembers class ** { + @com.squareup.moshi.FromJson *; + @com.squareup.moshi.ToJson *; +} + +# ============================================================ +# xpp3 / XmlPullParser — Android SDK provides this natively; +# the xpp3 jar ships a duplicate that confuses R8. Suppress. +# ============================================================ +-dontwarn org.xmlpull.v1.** +-dontwarn org.xmlpull.** +-keep class org.xmlpull.v1.** { *; } + +# ============================================================ +# DataStore +# ============================================================ +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { ; } + +# ============================================================ +# MetadataCollector — Class.forName() on these two classes at runtime. +# R8 must not rename them or ClassNotFoundException is thrown. +# ============================================================ +-keep class net.opendasharchive.openarchive.util.GmsLocationProvider { *; } +-keep class net.opendasharchive.openarchive.util.FossLocationProvider { *; } + +# ============================================================ +# Sugar ORM model subclasses — field names mapped to DB columns via reflection. +# Renaming any field breaks reads silently (returns null) or crashes. +# Remove this block once Sugar → Room migration is complete. +# ============================================================ +-keep class net.opendasharchive.openarchive.db.sugar.** { *; } +-keepclassmembers class net.opendasharchive.openarchive.db.sugar.** { *; } + +# ============================================================ +# Snowbird DTO serializers — kotlinx.serialization generates $$serializer +# companions that R8 strips if not explicitly kept. +# ============================================================ +-keep,includedescriptorclasses class net.opendasharchive.openarchive.services.snowbird.data.**$$serializer { *; } +-keepclassmembers @kotlinx.serialization.Serializable class net.opendasharchive.openarchive.services.snowbird.data.** { + *** Companion; + kotlinx.serialization.KSerializer serializer(...); +} + +# ============================================================ +# Koin type resolution — Koin matches types by KClass name at runtime. +# Renaming app classes breaks any of the 35 by inject() / get() call sites. +# ============================================================ +-keepnames class net.opendasharchive.openarchive.** { *; } + +# ============================================================ +# Sardine (WebDAV) — parses XML responses by mapping element names to +# fields via reflection. Renaming fields produces silent null reads. +# ============================================================ +-dontwarn org.apache.** +-keep class org.apache.** { *; } +-dontwarn com.github.sardine.** +-keep class com.github.sardine.** { *; } +-dontwarn com.thegrizzlylabs.sardine.** +-keep class com.thegrizzlylabs.sardine.** { *; } +-dontwarn info.guardianproject.sardine.** +-keep class info.guardianproject.sardine.** { *; } diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/QRImageAnalyzer.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/QRImageAnalyzer.kt index 0d8463b7..2292c2a2 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/QRImageAnalyzer.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/QRImageAnalyzer.kt @@ -1,42 +1,37 @@ package net.opendasharchive.openarchive.core.presentation.components -import androidx.annotation.OptIn -import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.common.InputImage +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer -/** - * Image analysis helper for ML Kit barcode scanning. - */ class QRImageAnalyzer( private val onQrCodeScanned: (String) -> Unit ) : ImageAnalysis.Analyzer { - private val scanner = BarcodeScanning.getClient() + private val reader = MultiFormatReader().apply { + setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) + } - @OptIn(ExperimentalGetImage::class) override fun analyze(imageProxy: ImageProxy) { - val mediaImage = imageProxy.image - if (mediaImage != null) { - val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - - scanner.process(image) - .addOnSuccessListener { barcodes -> - for (barcode in barcodes) { - barcode.rawValue?.let { - onQrCodeScanned(it) - } - } - } - .addOnFailureListener { - // Handle failure - } - .addOnCompleteListener { - imageProxy.close() - } - } else { + val buffer = imageProxy.planes[0].buffer + val bytes = ByteArray(buffer.remaining()).also { buffer.get(it) } + val source = PlanarYUVLuminanceSource( + bytes, imageProxy.width, imageProxy.height, + 0, 0, imageProxy.width, imageProxy.height, false + ) + try { + val result = reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))) + onQrCodeScanned(result.text) + } catch (_: NotFoundException) { + // no QR in this frame — normal, keep analyzing + } finally { + reader.reset() imageProxy.close() } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/security/TinkVaultCredentialStore.kt b/app/src/main/java/net/opendasharchive/openarchive/core/security/TinkVaultCredentialStore.kt index a38b9410..78111652 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/security/TinkVaultCredentialStore.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/security/TinkVaultCredentialStore.kt @@ -1,25 +1,30 @@ package net.opendasharchive.openarchive.core.security import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties import android.util.Base64 import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import com.google.crypto.tink.Aead -import com.google.crypto.tink.KeyTemplates -import com.google.crypto.tink.RegistryConfiguration -import com.google.crypto.tink.aead.AeadConfig -import com.google.crypto.tink.integration.android.AndroidKeysetManager import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +// Replaces TinkVaultCredentialStore — same AES-256-GCM + Android Keystore, no Tink dependency. +// Migration: if decryption fails (pre-existing Tink-encrypted data), the credential is cleared +// and the user will be prompted to re-enter their server password on next connection. class TinkVaultCredentialStore( context: Context, private val io: CoroutineDispatcher = Dispatchers.IO @@ -34,34 +39,48 @@ class TinkVaultCredentialStore( ) } - private val aead: Aead by lazy { - AeadConfig.register() - val keysetHandle = AndroidKeysetManager.Builder() - .withSharedPref(appContext, KEYSET_PREF_KEY, KEYSET_PREF_FILE) - .withKeyTemplate(KeyTemplates.get("AES256_GCM")) - .withMasterKeyUri("android-keystore://$MASTER_KEY_ALIAS") - .build() - .keysetHandle - - keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + private fun getOrCreateKey(): SecretKey { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (ks.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + kg.init( + KeyGenParameterSpec.Builder(KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + return kg.generateKey() } override suspend fun putSecret(vaultId: Long, secret: String) = withContext(io) { - val encrypted = aead.encrypt( - secret.toByteArray(Charsets.UTF_8), - associatedData(vaultId) - ) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + val iv = cipher.iv + val ciphertext = cipher.doFinal(secret.toByteArray(Charsets.UTF_8)) + val payload = iv + ciphertext // 12-byte IV prepended dataStore.edit { prefs -> - prefs[secretKey(vaultId)] = Base64.encodeToString(encrypted, Base64.NO_WRAP) + prefs[secretKey(vaultId)] = Base64.encodeToString(payload, Base64.NO_WRAP) } Unit } override suspend fun getSecret(vaultId: Long): String? = withContext(io) { val encoded = dataStore.data.first()[secretKey(vaultId)] ?: return@withContext null - val ciphertext = Base64.decode(encoded, Base64.NO_WRAP) - val decrypted = aead.decrypt(ciphertext, associatedData(vaultId)) - String(decrypted, Charsets.UTF_8) + runCatching { + val payload = Base64.decode(encoded, Base64.NO_WRAP) + val iv = payload.sliceArray(0 until GCM_IV_LENGTH) + val ciphertext = payload.sliceArray(GCM_IV_LENGTH until payload.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + String(cipher.doFinal(ciphertext), Charsets.UTF_8) + }.getOrElse { + // Decrypt failed — likely Tink-encrypted data from before migration. + // Clear the stale entry so user can re-enter credentials. + dataStore.edit { prefs -> prefs.remove(secretKey(vaultId)) } + null + } } override suspend fun hasSecret(vaultId: Long): Boolean = withContext(io) { @@ -69,21 +88,18 @@ class TinkVaultCredentialStore( } override suspend fun deleteSecret(vaultId: Long) = withContext(io) { - dataStore.edit { prefs -> - prefs.remove(secretKey(vaultId)) - } + dataStore.edit { prefs -> prefs.remove(secretKey(vaultId)) } Unit } private fun secretKey(vaultId: Long) = stringPreferencesKey("vault_secret_$vaultId") - private fun associatedData(vaultId: Long): ByteArray = - "vault_credentials:$vaultId".toByteArray(Charsets.UTF_8) - private companion object { const val DATASTORE_FILE_NAME = "vault_secure_credentials" - const val KEYSET_PREF_FILE = "vault_secure_credentials_keyset" - const val KEYSET_PREF_KEY = "vault_secure_credentials_key" - const val MASTER_KEY_ALIAS = "openarchive_vault_master_key" + const val KEY_ALIAS = "openarchive_vault_master_key" + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val GCM_IV_LENGTH = 12 + const val GCM_TAG_BITS = 128 } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/FolderBar.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/FolderBar.kt index f745cff4..6058c70f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/FolderBar.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/FolderBar.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView @@ -221,8 +222,20 @@ private fun RowScope.FolderBarInfoMode( ) { menu.forEach { item -> val enabled = item !is FolderMenuItem.SelectMedia || state.totalMediaCount > 0 + val isDestructive = item is FolderMenuItem.Remove DropdownMenuItem( - text = { Text(stringResource(id = item.titleRes)) }, + text = { + Text( + text = stringResource(id = item.titleRes), + style = MaterialTheme.typography.titleMedium.copy(fontFamily = MontserratFontFamily), + fontWeight = FontWeight.SemiBold, + color = when { + isDestructive -> MaterialTheme.colorScheme.error + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + else -> MaterialTheme.colorScheme.onSurface + } + ) + }, enabled = enabled, onClick = { onIntent(FolderBarIntent.OptionsDismissed) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeScreen.kt index 2ce6913e..1ee72ca5 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeScreen.kt @@ -184,6 +184,7 @@ fun HomeScreen( archive, submission.id, result.capturedUris, + fromCamera = true, ) evidenceList.forEach { evidence -> mediaRepository.addEvidence(evidence) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt index 7c87c246..d78fe1c1 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt @@ -29,12 +29,13 @@ object MediaPicker { archive: Archive, submissionId: Long, uris: List, + fromCamera: Boolean = false, ): ArrayList { val result = ArrayList() for (uri in uris) { try { - val evidence = import(context, archive, submissionId, uri) + val evidence = import(context, archive, submissionId, uri, fromCamera) if (evidence != null) result.add(evidence) } catch (e: Exception) { AppLogger.e("Error importing media", e) @@ -49,6 +50,7 @@ object MediaPicker { archive: Archive, submissionId: Long, uri: Uri, + fromCamera: Boolean = false, ): Evidence? { val title = Utility.getUriDisplayName(context, uri) @@ -146,8 +148,10 @@ object MediaPicker { mediaHashString = mediaHashString ) - // --- Generate C2PA sidecar with the full proof metadata map --- - if (file != null && mediaHashString.isNotEmpty()) { + // C2PA: only for in-app camera captures of image/video — not imports, not PDFs + val isCameraMedia = fromCamera && + (mimeType.startsWith("image/") || mimeType.startsWith("video/")) + if (file != null && mediaHashString.isNotEmpty() && isCameraMedia) { C2paHelper.generateManifest( context = context, mediaFile = file, diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewMediaScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewMediaScreen.kt index d812521e..477fd298 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewMediaScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewMediaScreen.kt @@ -119,6 +119,7 @@ fun PreviewMediaScreen( archive, submission.id, result.capturedUris, + fromCamera = true, ) evidenceList.forEach { evidence -> mediaRepository.addEvidence(evidence) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/PasscodeRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/PasscodeRepository.kt index aae93516..541c5457 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/PasscodeRepository.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/PasscodeRepository.kt @@ -2,16 +2,18 @@ package net.opendasharchive.openarchive.features.settings.passcode import android.content.Context import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties import android.util.Base64 -import com.google.crypto.tink.Aead -import com.google.crypto.tink.KeyTemplates -import com.google.crypto.tink.aead.AeadConfig -import com.google.crypto.tink.integration.android.AndroidKeysetManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.util.Prefs -import com.google.crypto.tink.RegistryConfiguration import net.opendasharchive.openarchive.core.config.AppConfig +import net.opendasharchive.openarchive.util.Prefs +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec class PasscodeRepository( private val prefs: SharedPreferences, @@ -132,23 +134,47 @@ class PasscodeRepository( } } -class PrefAead(context: Context) { - private val aead: Aead - - init { - val appContext = context.applicationContext - AeadConfig.register() - - val keysetHandle = AndroidKeysetManager.Builder() - .withSharedPref(context, "tink_keyset", "tink_keyset_pref") - .withKeyTemplate(KeyTemplates.get("AES256_GCM")) // or AES256_GCM_SIV - //.withMasterKeyUri("android-keystore://openarchive_master_key") - .build() - .keysetHandle - - aead = keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) +// Replaces Tink PrefAead — AES-256-GCM backed by Android Keystore, zero external dependencies. +// Migration: if decrypt fails on pre-existing Tink-encrypted data, returns empty ByteArray +// so PasscodeRepository clears the passcode and the user re-sets it on next launch. +class PrefAead(@Suppress("UNUSED_PARAMETER") context: Context) { + + private fun getOrCreateKey(): SecretKey { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (ks.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + kg.init( + KeyGenParameterSpec.Builder(KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + return kg.generateKey() + } + + fun encrypt(plain: ByteArray, @Suppress("UNUSED_PARAMETER") aad: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + return cipher.iv + cipher.doFinal(plain) // 12-byte IV prepended + } + + fun decrypt(cipherPayload: ByteArray, @Suppress("UNUSED_PARAMETER") aad: ByteArray): ByteArray { + return runCatching { + val iv = cipherPayload.sliceArray(0 until GCM_IV_LENGTH) + val ciphertext = cipherPayload.sliceArray(GCM_IV_LENGTH until cipherPayload.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + cipher.doFinal(ciphertext) + }.getOrElse { ByteArray(0) } // stale Tink data — caller detects empty and resets + } + + private companion object { + const val KEY_ALIAS = "openarchive_passcode_key" + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val GCM_IV_LENGTH = 12 + const val GCM_TAG_BITS = 128 } - - fun encrypt(plain: ByteArray, aad: ByteArray): ByteArray = aead.encrypt(plain, aad) - fun decrypt(cipher: ByteArray, aad: ByteArray): ByteArray = aead.decrypt(cipher, aad) } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/ScryptHashingStrategy.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/ScryptHashingStrategy.kt deleted file mode 100644 index 6c332809..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/ScryptHashingStrategy.kt +++ /dev/null @@ -1,28 +0,0 @@ -package net.opendasharchive.openarchive.features.settings.passcode - -import org.bouncycastle.crypto.generators.SCrypt -import java.security.SecureRandom - -// ScryptHashingStrategy.kt -class ScryptHashingStrategy : HashingStrategy { - - companion object { - private const val N = 16384 // CPU/Memory cost parameter - private const val r = 8 // Block size - private const val p = 1 // Parallelization parameter - private const val KEY_LENGTH = 32 // 256 bits - private const val SALT_LENGTH = 16 - } - - override val saltLength: Int - get() = SALT_LENGTH - - override suspend fun generateSalt(): ByteArray { - val random = SecureRandom() - return ByteArray(SALT_LENGTH).also { random.nextBytes(it) } - } - - override suspend fun hash(passcode: String, salt: ByteArray): ByteArray { - return SCrypt.generate(passcode.toByteArray(), salt, N, r, p, KEY_LENGTH) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt index c63d4c3c..e9b746d2 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -4,7 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.webkit.MimeTypeMap -import com.google.common.net.UrlEscapers +import android.net.Uri import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -474,7 +474,7 @@ abstract class Conduit( if (title.isBlank()) title = evidence.mediaHashString if (escapeTitle) { - title = UrlEscapers.urlPathSegmentEscaper().escape(title) ?: title + title = Uri.encode(title) ?: title } if (!title.endsWith(".$ext")) { diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/IaConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/IaConduit.kt index 78a78abf..72761ef5 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/IaConduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/IaConduit.kt @@ -51,7 +51,6 @@ class IaConduit(evidence: Evidence, context: Context) : Conduit(evidence, contex val fileName = getUploadFileName(mEvidence, true) val metaJson = getMetadata() - val c2paManifest = getC2paManifest() if (mEvidence.serverUrl.isBlank()) { // TODO this should make sure we aren't accidentally using one of archive.org's metadata fields by accident @@ -71,16 +70,6 @@ class IaConduit(evidence: Evidence, context: Context) : Conduit(evidence, contex AppLogger.e("Failed to upload meta.json for $fileName", e) } - // Upload C2PA manifest, if enabled and successfully created — non-fatal - if (c2paManifest != null) { - try { - AppLogger.d("Uploading C2PA manifest to Internet Archive: ${c2paManifest.name}") - client.uploadProofFiles(c2paManifest, auth) - } catch (e: Throwable) { - AppLogger.e("Failed to upload C2PA manifest for $fileName", e) - } - } - jobSucceeded() return true diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf73a312..36292f34 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,8 +12,8 @@ Servers Add Server Rename Folder - Select Media - Remove Folder + Select media + Remove folder Archive folder Remove folder from app Folder name cannot be empty