Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 11 additions & 250 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,14 @@ android {
applicationId = "net.opendasharchive.openarchive"
minSdk = 29
targetSdk = 36
versionCode = 30041
versionCode = 30043
versionName = "4.0.5"
multiDexEnabled = true
vectorDrawables.useSupportLibrary = true
// minSdk=29 (Android 10) requires 64-bit hardware — all supported devices are arm64
ndk {
abiFilters += listOf("arm64-v8a")
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
val localProps = loadLocalProperties()
resValue("string", "mixpanel_key", localProps.getProperty("MIXPANELKEY") ?: "")
Expand Down Expand Up @@ -119,10 +123,6 @@ 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
Expand Down Expand Up @@ -310,21 +310,17 @@ dependencies {
"gmsImplementation"(libs.google.play.review.ktx)
"gmsImplementation"(libs.google.play.app.update.ktx)
"gmsImplementation"("com.google.android.gms:play-services-location:21.1.0")
"gmsImplementation"("com.google.android.play:integrity:1.4.0")

// Tor
implementation(libs.tor.android)
implementation(libs.jtorctl)

// C2PA - Content Authenticity
// TODO: Add actual C2PA library once available
// simple-c2pa (org.witness:simple-c2pa:0.0.13) is not available in Maven
// Options:
// 1. Use c2pa-android (https://github.com/contentauth/c2pa-android)
// 2. Use c2pa-rs directly via JNI
// 3. Build simple-c2pa from source
// For now, using stub implementation in C2paHelper
// implementation(libs.simple.c2pa)
// implementation(libs.jna)
// C2PA - Content Authenticity (contentauth/c2pa-android)
implementation("com.github.contentauth:c2pa-android:0.0.9")
implementation("org.bouncycastle:bcprov-jdk18on:1.81")
implementation("org.bouncycastle:bcpkix-jdk18on:1.81")
implementation("org.bouncycastle:bcpg-jdk18on:1.81")

// Utilities
implementation(libs.timber)
Expand Down Expand Up @@ -368,241 +364,6 @@ detekt {
ignoreFailures = true
}

// ============================================================
// C2PA Rust FFI — Auto-build for FOSS variants
// ============================================================
//
// Task graph (runs only for FOSS builds):
// mergeXxxJniLibFolders
// └── buildC2paRustLibs [incremental: skipped if .so files are up-to-date]
// └── generateCargoConfig [skipped if .cargo/config.toml already exists]
// └── installRustTargets [idempotent rustup target add]
// └── restoreC2paSource [skipped if Cargo.toml exists]
//
// To force a full rebuild: ./gradlew buildC2paRustLibs --rerun-tasks
// To regenerate NDK config: delete rust-c2pa-ffi/.cargo/config.toml and rebuild

val preferredNdkVersion = "27.1.12297006"
val androidApiLevel = "30"
val rustC2paDir = rootProject.file("rust-c2pa-ffi")
val jniLibsDir = project.file("src/main/jniLibs")

// Rust target triple → Android ABI directory name
val rustToAbi = linkedMapOf(
"aarch64-linux-android" to "arm64-v8a",
"armv7-linux-androideabi" to "armeabi-v7a",
"i686-linux-android" to "x86",
"x86_64-linux-android" to "x86_64"
)

fun findNdk(): File {
// 1. Explicit NDK env var (highest priority)
listOfNotNull(
System.getenv("ANDROID_NDK_HOME"),
System.getenv("ANDROID_NDK_ROOT"),
).map(::File).firstOrNull { it.isDirectory }?.let { return it }

// 2. Find SDK, then look for ndk/ subdirectory
val sdk = listOfNotNull(
System.getenv("ANDROID_HOME"),
System.getenv("ANDROID_SDK_ROOT"),
"${System.getProperty("user.home")}/Library/Android/sdk", // macOS default
"${System.getProperty("user.home")}/Android/Sdk", // Linux/Windows default
).map(::File).firstOrNull { it.isDirectory }
?: throw GradleException(
"Android SDK not found.\n" +
"Set ANDROID_HOME to your SDK root directory and try again."
)

val ndkParent = File(sdk, "ndk")
if (!ndkParent.isDirectory) throw GradleException(
"Android NDK not found at $ndkParent.\n" +
"Install it via Android Studio → SDK Manager → SDK Tools → NDK (Side by side)."
)

// Prefer the pinned version; otherwise use the latest installed
File(ndkParent, preferredNdkVersion).takeIf { it.isDirectory }?.let { return it }
return ndkParent.listFiles()
?.filter { it.isDirectory }
?.maxByOrNull { it.name }
?: throw GradleException("No NDK versions found in $ndkParent")
}

fun ndkBinDir(ndk: File): File {
val prebuilt = File(ndk, "toolchains/llvm/prebuilt")
return prebuilt.listFiles()
?.firstOrNull { it.isDirectory }
?.let { File(it, "bin") }
?: throw GradleException("NDK prebuilt toolchain not found under $prebuilt")
}

/**
* Resolves a bare executable name (e.g. "cargo", "rustup") to its absolute path.
* Gradle's daemon process does not inherit the user's shell PATH, so tools installed
* in ~/.cargo/bin are not visible to a plain ProcessBuilder call. Searching common
* locations here bypasses that limitation without requiring a shell wrapper.
*/
fun resolveExe(name: String): String {
if (name.contains("/")) return name // already a path
val searchDirs = listOf(
"${System.getProperty("user.home")}/.cargo/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
) + (System.getenv("PATH") ?: "").split(File.pathSeparatorChar)
return searchDirs
.map { File(it, name) }
.firstOrNull { it.canExecute() }
?.absolutePath
?: name
}

/** Runs a command via ProcessBuilder, streams output to stdout/stderr, throws on non-zero exit. */
fun runCommand(vararg cmd: String, workDir: File = rootProject.projectDir, env: Map<String, String> = emptyMap()) {
val resolved = listOf(resolveExe(cmd[0])) + cmd.drop(1)
val pb = ProcessBuilder(resolved).directory(workDir).inheritIO()
if (env.isNotEmpty()) pb.environment().putAll(env)
val result = pb.start().waitFor()
check(result == 0) { "Command failed (exit $result): ${cmd.joinToString(" ")}" }
}

// Maps each Rust triple to its (clang binary prefix, CC env-var key)
val targetDetails = linkedMapOf(
"aarch64-linux-android" to Pair("aarch64-linux-android${androidApiLevel}", "aarch64_linux_android"),
"armv7-linux-androideabi" to Pair("armv7a-linux-androideabi${androidApiLevel}", "armv7_linux_androideabi"),
"i686-linux-android" to Pair("i686-linux-android${androidApiLevel}", "i686_linux_android"),
"x86_64-linux-android" to Pair("x86_64-linux-android${androidApiLevel}", "x86_64_linux_android"),
)

// ─── Task 1: Restore source from git if the directory was deleted ────────────
val restoreC2paSource by tasks.registering {
group = "c2pa"
description = "Restores rust-c2pa-ffi/ from git HEAD if the directory is missing"
onlyIf { !File(rustC2paDir, "Cargo.toml").exists() }
doLast {
logger.lifecycle("rust-c2pa-ffi/ missing — restoring from git HEAD...")
runCommand("git", "checkout", "HEAD", "--", "rust-c2pa-ffi")
logger.lifecycle("✓ rust-c2pa-ffi/ restored from git")
}
}

// ─── Task 2: Validate Rust toolchain; auto-install Android targets ───────────
val installRustTargets by tasks.registering {
group = "c2pa"
description = "Verifies Cargo is installed and ensures all Android Rust targets are added"
dependsOn(restoreC2paSource)
doLast {
val cargoOk = ProcessBuilder(resolveExe("cargo"), "--version")
.redirectErrorStream(true)
.start()
.waitFor() == 0
if (!cargoOk) throw GradleException(
"Cargo not found. Install Rust from https://rustup.rs/\n" +
"Then run: rustup target add ${rustToAbi.keys.joinToString(" ")}"
)
// rustup target add is idempotent — safe to call on every build
runCommand(*( listOf("rustup", "target", "add") + rustToAbi.keys ).toTypedArray())
logger.lifecycle("✓ Rust Android targets verified")
}
}

// ─── Task 3: Generate .cargo/config.toml using the installed NDK ─────────────
// Runs on every build but only writes to disk when content changes, so
// buildC2paRustLibs (which uses the file as input) stays UP-TO-DATE when NDK is unchanged.
val generateCargoConfig by tasks.registering {
group = "c2pa"
description = "Generates rust-c2pa-ffi/.cargo/config.toml with NDK linker paths"
dependsOn(installRustTargets)
doLast {
val ndk = findNdk()
val bin = ndkBinDir(ndk)

// NOTE: CC_*/AR_* env vars must live in the top-level [env] section.
// [target.X.env] is NOT a valid Cargo config key and is silently ignored,
// which causes cc-rs (used by the ring crate) to fail with "tool not found".
val newContent = buildString {
appendLine("# Auto-generated by Gradle — do not edit manually.")
appendLine("# Regenerate: run ./gradlew generateCargoConfig --rerun-tasks")
appendLine("# NDK: ${ndk.absolutePath}")
appendLine()
appendLine("[env]")
appendLine("ANDROID_NDK_HOME = \"${ndk.absolutePath}\"")
// CC/CXX/AR vars for cc-rs and other build scripts (one entry per ABI, all in [env])
for ((_, pair) in targetDetails) {
val (clangPrefix, envKey) = pair
appendLine("CC_$envKey = \"$bin/${clangPrefix}-clang\"")
appendLine("CXX_$envKey = \"$bin/${clangPrefix}-clang++\"")
appendLine("AR_$envKey = \"$bin/llvm-ar\"")
}
appendLine()
for ((triple, pair) in targetDetails) {
val (clangPrefix, _) = pair
appendLine("[target.$triple]")
appendLine("linker = \"$bin/${clangPrefix}-clang\"")
appendLine("ar = \"$bin/llvm-ar\"")
appendLine("rustflags = [\"-C\", \"link-arg=-Wl,-z,max-page-size=16384\"]")
appendLine()
}
appendLine("[build]")
appendLine("target-dir = \"target\"")
}

val configFile = File(rustC2paDir, ".cargo/config.toml")
File(rustC2paDir, ".cargo").mkdirs()
if (!configFile.exists() || configFile.readText() != newContent) {
configFile.writeText(newContent)
logger.lifecycle("✓ .cargo/config.toml written (NDK: ${ndk.absolutePath})")
} else {
logger.lifecycle("✓ .cargo/config.toml unchanged")
}
}
}

// ─── Task 4: Compile the Rust library for all Android ABIs ───────────────────
val buildC2paRustLibs by tasks.registering {
group = "c2pa"
description = "Compiles libc2pa_ffi.so for all Android ABIs (incremental)"
dependsOn(generateCargoConfig)

// Gradle skips this task automatically when inputs are unchanged and outputs exist
inputs.dir(File(rustC2paDir, "src"))
inputs.files(
File(rustC2paDir, "Cargo.toml"),
File(rustC2paDir, "Cargo.lock"),
File(rustC2paDir, ".cargo/config.toml"),
)
outputs.files(rustToAbi.values.map { abi -> jniLibsDir.resolve("$abi/libc2pa_ffi.so") })

doLast {
val bin = ndkBinDir(findNdk())
rustToAbi.forEach { (rustTarget, abi) ->
logger.lifecycle(" Building $abi ($rustTarget)...")
val (clangPrefix, envKey) = targetDetails[rustTarget]!!
// Pass CC/AR explicitly — belt-and-suspenders alongside .cargo/config.toml [env]
val env = mapOf(
"CC_$envKey" to "$bin/${clangPrefix}-clang",
"CXX_$envKey" to "$bin/${clangPrefix}-clang++",
"AR_$envKey" to "$bin/llvm-ar",
)
runCommand("cargo", "build", "--target", rustTarget, "--release", workDir = rustC2paDir, env = env)
val soFile = rustC2paDir.resolve("target/$rustTarget/release/libc2pa_ffi.so")
check(soFile.exists()) {
"cargo build succeeded but .so not found at: ${soFile.absolutePath}"
}
jniLibsDir.resolve(abi).mkdirs()
soFile.copyTo(jniLibsDir.resolve("$abi/libc2pa_ffi.so"), overwrite = true)
logger.lifecycle(" ✓ $abi/libc2pa_ffi.so (${soFile.length() / 1024} KB)")
}
logger.lifecycle("C2PA Rust FFI build complete.")
}
}

// ─── Wire buildC2paRustLibs into ALL variant builds (GMS + FOSS) ─────────────
afterEvaluate {
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
.configureEach { dependsOn(buildC2paRustLibs) }
}

// Conditionally apply Google Services plugins only for GMS builds
if (gradle.startParameter.taskRequests.toString().contains("Gms", ignoreCase = true)) {
apply(plugin = "com.google.gms.google-services")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package net.opendasharchive.openarchive.util

import android.content.Context
import java.io.File

object SafetyNetHelper {
// Play Integrity API requires GMS — not available on FOSS builds.
fun requestGst(context: Context, mediaHash: String, outFile: File) = Unit

Check warning

Code scanning / detekt

Function parameter is unused and should be removed. Warning

Function parameter context is unused.

Check warning

Code scanning / detekt

Function parameter is unused and should be removed. Warning

Function parameter mediaHash is unused.

Check warning

Code scanning / detekt

Function parameter is unused and should be removed. Warning

Function parameter outFile is unused.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package net.opendasharchive.openarchive.util

import android.content.Context

Check warning

Code scanning / detekt

Detects imports in non default order Warning

Imports must be ordered in lexicographic order without any empty lines in-between with "java", "javax", "kotlin" and aliases in the end
import android.util.Base64
import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.IntegrityTokenRequest
import com.google.android.gms.tasks.Tasks
import net.opendasharchive.openarchive.core.logger.AppLogger
import java.io.File

object SafetyNetHelper {
/**
* Requests a Play Integrity token and writes it as a JWT string to [outFile].
* No-op on network or API failure — outFile simply won't exist, and the caller
* skips it in the upload list.
*/
fun requestGst(context: Context, mediaHash: String, outFile: File) {
try {
val nonce = Base64.encodeToString(mediaHash.toByteArray(Charsets.UTF_8),

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline after "("
Base64.URL_SAFE or Base64.NO_WRAP)

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline before ")"

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Missing newline before ")"
val manager = IntegrityManagerFactory.create(context.applicationContext)
val request = IntegrityTokenRequest.builder().setNonce(nonce).build()
val response = Tasks.await(manager.requestIntegrityToken(request))
outFile.writeText(response.token())
AppLogger.i("[GST] Play Integrity token written for $mediaHash")
} catch (e: Exception) {

Check warning

Code scanning / detekt

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled. Warning

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
AppLogger.w("[GST] Play Integrity request failed: ${e.message}")
}
}
}
17 changes: 7 additions & 10 deletions app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import net.opendasharchive.openarchive.services.tor.TorServiceManager
import kotlinx.coroutines.launch
import net.opendasharchive.openarchive.analytics.api.AnalyticsManager
import net.opendasharchive.openarchive.analytics.api.session.SessionTracker
import net.opendasharchive.openarchive.core.security.C2paKeyStore
import net.opendasharchive.openarchive.analytics.di.analyticsModule
import net.opendasharchive.openarchive.db.AppDatabase
import net.opendasharchive.openarchive.db.MigrationWorker
Expand All @@ -43,7 +42,8 @@ import net.opendasharchive.openarchive.core.di.passcodeModule
import net.opendasharchive.openarchive.features.settings.passcode.PasscodeGate
import net.opendasharchive.openarchive.core.di.retrofitModule
import net.opendasharchive.openarchive.core.logger.AppLogger
import net.opendasharchive.openarchive.util.C2paHelper
import net.opendasharchive.openarchive.util.ProofCompanionGenerator
import net.opendasharchive.openarchive.util.ProofmodeC2paManager
import net.opendasharchive.openarchive.core.repositories.MediaRepository
import net.opendasharchive.openarchive.util.CleanInsightsManager
import net.opendasharchive.openarchive.util.Prefs
Expand Down Expand Up @@ -102,8 +102,11 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv

Prefs.load(this)

// Initialize C2PA Helper
C2paHelper.init(this)
// Initialize ProofmodeC2paManager (generates self-signed key on first run)
ProofmodeC2paManager.init(this)

// Initialize PGP key for proof companion files
ProofCompanionGenerator.init(this)

// --- 2-launch synchronous migration strategy ---
// L1: If Sugar DB exists and Room migration hasn't run yet, open Room directly
Expand Down Expand Up @@ -185,12 +188,6 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv

applyTheme()

// Migrate C2PA keys from plaintext SharedPreferences to SecureStorage (one-time)
val c2paKeyStore: C2paKeyStore by inject()
c2paKeyStore.migrateFromPrefsIfNeeded(
androidx.preference.PreferenceManager.getDefaultSharedPreferences(this)
)

// Schedule periodic cache cleanup (runs every 7 days when battery is not low)
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
CacheCleanupWorker.WORK_NAME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import net.opendasharchive.openarchive.core.config.AppConfig
import net.opendasharchive.openarchive.core.security.C2paKeyStore
import net.opendasharchive.openarchive.core.security.SecurityManager
import net.opendasharchive.openarchive.features.core.dialog.DefaultResourceProvider
import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager
Expand Down Expand Up @@ -68,8 +67,6 @@ val coreModule = module {
// Centrally managed security flags
single { SecurityManager(get(named("default_prefs"))) }

// Secure storage for C2PA signing keys (Android Keystore backed)
single { C2paKeyStore(androidApplication()) }
}


Loading
Loading