diff --git a/.gitignore b/.gitignore index d2677ed22..eb07fb9aa 100644 --- a/.gitignore +++ b/.gitignore @@ -43,8 +43,12 @@ proguard/ .DS_Store app/releaseflavor/* +# Rust - C2PA FFI +rust-c2pa-ffi/target/ +**/*.rs.bk + # fastlane -fastlane/metadata/* + fastlane/report.xml fastlane/Preview.html fastlane/screenshots @@ -60,3 +64,8 @@ fastlane/.env /.kotlin/ /app/release/ /app/prod/release/save-unspecified-prod-release.aab + +# C2PA Rust FFI compiled libraries (built during F-Droid build) +app/src/main/jniLibs/**/*.so +# Auto-generated by Gradle/build-android.sh — contains machine-specific NDK paths +rust-c2pa-ffi/.cargo/config.toml diff --git a/README.md b/README.md index 2cb4b5724..a8b26bf1a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Save by OpenArchive: Secure Mobile Media Preservation +###### codex resume 019be9d5-77ac-74b0-bc6b-a97dbcc8b6d7 + Save is an open-source app created by OpenArchive to help eyewitnesses and human rights defenders preserve truth to power by securely sharing, archiving, verifying, and encrypting their mobile media.
diff --git a/analytics/build.gradle.kts b/analytics/build.gradle.kts index 9a2d2479d..d0bfe89bc 100644 --- a/analytics/build.gradle.kts +++ b/analytics/build.gradle.kts @@ -1,12 +1,11 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) } kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3) } } @@ -24,6 +23,31 @@ android { buildConfig = true } + // Match parent app's flavor dimensions + flavorDimensions += listOf("distribution", "env") + + productFlavors { + create("gms") { + dimension = "distribution" + } + + create("foss") { + dimension = "distribution" + } + + create("dev") { + dimension = "env" + } + + create("staging") { + dimension = "env" + } + + create("prod") { + dimension = "env" + } + } + buildTypes { debug { buildConfigField("boolean", "ENABLE_ANALYTICS_IN_DEBUG", "true") @@ -53,10 +77,20 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.process) - // Analytics SDKs - api(libs.mixpanel) + // Analytics SDKs - flavor specific + "gmsApi"(libs.mixpanel) + "gmsApi"(libs.firebase.analytics) + + // CleanInsights for both GMS and FOSS builds api(libs.clean.insights) - api(libs.firebase.analytics) + + // Crash Reporting - flavor specific + "gmsApi"(libs.firebase.crashlytics) + "fossApi"("ch.acra:acra-mail:5.11.3") + "fossApi"("ch.acra:acra-dialog:5.11.3") + + // Logging + implementation(libs.timber) // Dependency Injection implementation(libs.koin.core) diff --git a/analytics/src/foss/java/net/opendasharchive/openarchive/analytics/crash/AcraCrashReporter.kt b/analytics/src/foss/java/net/opendasharchive/openarchive/analytics/crash/AcraCrashReporter.kt new file mode 100644 index 000000000..4c9382ab9 --- /dev/null +++ b/analytics/src/foss/java/net/opendasharchive/openarchive/analytics/crash/AcraCrashReporter.kt @@ -0,0 +1,54 @@ +package net.opendasharchive.openarchive.analytics.crash + +import android.content.Context +import org.acra.ACRA +import timber.log.Timber + +/** + * ACRA implementation of CrashReporter for FOSS builds + * + * Provides crash reporting functionality using ACRA (Application Crash Reports for Android), + * a FOSS alternative to proprietary crash reporting services. Used in F-Droid builds to + * maintain FOSS compliance while still providing crash reporting capabilities. + * + * ACRA is privacy-focused and allows users to control what data is sent. + * + * @param context Application context needed for ACRA operations + */ +class AcraCrashReporter(private val context: Context) : CrashReporter { + + override fun initialize() { + try { + // ACRA is initialized in SaveApp.attachBaseContext() + // This method just confirms it's ready + Timber.d("ACRA Crash Reporter ready") + } catch (e: Exception) { + Timber.e(e, "Failed to initialize ACRA") + } + } + + override fun log(message: String) { + try { + // ACRA doesn't support breadcrumbs like Crashlytics + // We use custom data instead to store context + ACRA.errorReporter.putCustomData("last_log", message) + } catch (e: Exception) { + Timber.e(e, "Failed to log message to ACRA") + } + } + + override fun recordException(throwable: Throwable) { + try { + // Record non-fatal exception + ACRA.errorReporter.handleSilentException(throwable) + } catch (e: Exception) { + Timber.e(e, "Failed to record exception to ACRA") + } + } + + override fun setUserIdentifier(identifier: String) { + // For privacy in FOSS builds, we don't set user identifiers + // Users appreciate F-Droid builds being more privacy-focused + // If needed in the future, you could set an anonymized identifier here + } +} diff --git a/analytics/src/foss/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt b/analytics/src/foss/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt new file mode 100644 index 000000000..6dd97c643 --- /dev/null +++ b/analytics/src/foss/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt @@ -0,0 +1,65 @@ +package net.opendasharchive.openarchive.analytics.di + +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.AnalyticsManagerImpl +import net.opendasharchive.openarchive.analytics.api.session.SessionTracker +import net.opendasharchive.openarchive.analytics.api.session.SessionTrackerImpl +import net.opendasharchive.openarchive.analytics.core.AnalyticsProvider +import net.opendasharchive.openarchive.analytics.crash.AcraCrashReporter +import net.opendasharchive.openarchive.analytics.crash.CrashReporter +import net.opendasharchive.openarchive.analytics.providers.cleaninsights.CleanInsightsProvider +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +/** + * FOSS Analytics Module - CleanInsights only + * + * This module is used for F-Droid builds and only includes CleanInsights, + * a privacy-focused, GDPR-compliant analytics provider. + * + * Usage in app module: + * ```kotlin + * startKoin { + * modules( + * analyticsModule( + * mixpanelToken = getString(R.string.mixpanel_key), + * cleanInsightsConsentChecker = { CleanInsightsManager.hasConsent() } + * ) + * ) + * } + * ``` + */ +fun analyticsModule( + mixpanelToken: String, // Ignored in FOSS build, kept for signature compatibility + cleanInsightsConsentChecker: () -> Boolean +) = module { + + // CleanInsights Provider - Privacy-focused analytics + single(qualifier = org.koin.core.qualifier.named("cleaninsights")) { + CleanInsightsProvider( + context = androidContext(), + campaignId = "main", + consentChecker = cleanInsightsConsentChecker + ) + } + + // AnalyticsManager - Unified interface with only CleanInsights + single { + AnalyticsManagerImpl( + providers = listOf( + get(qualifier = org.koin.core.qualifier.named("cleaninsights")) + ) + ) + } + + // SessionTracker - Reactive session management + single { + SessionTrackerImpl( + analyticsManager = get(), + context = androidContext() + ) + } + + // Crash Reporting - ACRA + single { AcraCrashReporter(androidContext()) } +} diff --git a/analytics/src/gms/java/net/opendasharchive/openarchive/analytics/crash/FirebaseCrashReporter.kt b/analytics/src/gms/java/net/opendasharchive/openarchive/analytics/crash/FirebaseCrashReporter.kt new file mode 100644 index 000000000..06815a559 --- /dev/null +++ b/analytics/src/gms/java/net/opendasharchive/openarchive/analytics/crash/FirebaseCrashReporter.kt @@ -0,0 +1,35 @@ +package net.opendasharchive.openarchive.analytics.crash + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import timber.log.Timber + +/** + * Firebase Crashlytics implementation of CrashReporter for GMS builds + * + * Provides crash reporting functionality using Google's Firebase Crashlytics service. + * Used in Google Play Store builds to track crashes and non-fatal exceptions. + */ +class FirebaseCrashReporter : CrashReporter { + private var crashlytics: FirebaseCrashlytics? = null + + override fun initialize() { + try { + crashlytics = FirebaseCrashlytics.getInstance() + Timber.d("Firebase Crashlytics initialized successfully") + } catch (e: Exception) { + Timber.e(e, "Failed to initialize Firebase Crashlytics") + } + } + + override fun log(message: String) { + crashlytics?.log(message) + } + + override fun recordException(throwable: Throwable) { + crashlytics?.recordException(throwable) + } + + override fun setUserIdentifier(identifier: String) { + crashlytics?.setUserId(identifier) + } +} diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt b/analytics/src/gms/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt similarity index 91% rename from analytics/src/main/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt rename to analytics/src/gms/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt index 9f7eac48c..a54b90ebd 100644 --- a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt +++ b/analytics/src/gms/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt @@ -5,6 +5,8 @@ import net.opendasharchive.openarchive.analytics.api.AnalyticsManagerImpl import net.opendasharchive.openarchive.analytics.api.session.SessionTracker import net.opendasharchive.openarchive.analytics.api.session.SessionTrackerImpl import net.opendasharchive.openarchive.analytics.core.AnalyticsProvider +import net.opendasharchive.openarchive.analytics.crash.CrashReporter +import net.opendasharchive.openarchive.analytics.crash.FirebaseCrashReporter import net.opendasharchive.openarchive.analytics.providers.cleaninsights.CleanInsightsProvider import net.opendasharchive.openarchive.analytics.providers.firebase.FirebaseProvider import net.opendasharchive.openarchive.analytics.providers.mixpanel.MixpanelProvider @@ -78,4 +80,7 @@ fun analyticsModule( context = androidContext() ) } + + // Crash Reporting - Firebase Crashlytics + single { FirebaseCrashReporter() } } diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/firebase/FirebaseProvider.kt b/analytics/src/gms/java/net/opendasharchive/openarchive/analytics/providers/firebase/FirebaseProvider.kt similarity index 100% rename from analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/firebase/FirebaseProvider.kt rename to analytics/src/gms/java/net/opendasharchive/openarchive/analytics/providers/firebase/FirebaseProvider.kt diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt b/analytics/src/gms/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt similarity index 100% rename from analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt rename to analytics/src/gms/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsEvent.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsEvent.kt index c19fb23d0..2186e07fe 100644 --- a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsEvent.kt +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsEvent.kt @@ -409,4 +409,56 @@ sealed interface AnalyticsEvent { override val value = errorCode.toDouble() override val properties = mapOf("error_code" to errorCode) } + + // ==================== TOR CONNECTIVITY ==================== + + data class TorConnectionAttempt( + val success: Boolean, + val retryCount: Int = 0, + val durationMs: Long = 0, + ) : AnalyticsEvent { + override val category = "tor" + override val action = if (success) "connection_success" else "connection_failure" + override val label: String? = null + override val value = durationMs.toDouble() + override val properties = mapOf( + "success" to success, + "retry_count" to retryCount, + "duration_ms" to durationMs, + ) + } + + data class TorVerificationAttempt( + val success: Boolean, + val retryCount: Int = 0, + val durationMs: Long = 0, + val errorType: String? = null, + ) : AnalyticsEvent { + override val category = "tor" + override val action = if (success) "verification_success" else "verification_failure" + override val label = errorType + override val value = durationMs.toDouble() + override val properties = buildMap { + put("success", success) + put("retry_count", retryCount) + put("duration_ms", durationMs) + errorType?.let { put("error_type", it) } + } + } + + data object TorEnabled : AnalyticsEvent { + override val category = "tor" + override val action = "enabled" + override val label: String? = null + override val value: Double? = null + override val properties: Map = emptyMap() + } + + data object TorDisabled : AnalyticsEvent { + override val category = "tor" + override val action = "disabled" + override val label: String? = null + override val value: Double? = null + override val properties: Map = emptyMap() + } } diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/crash/CrashReporter.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/crash/CrashReporter.kt new file mode 100644 index 000000000..1d5fa9f46 --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/crash/CrashReporter.kt @@ -0,0 +1,40 @@ +package net.opendasharchive.openarchive.analytics.crash + +/** + * Interface for crash reporting abstraction + * + * Allows different implementations for GMS (Firebase Crashlytics) and FOSS (ACRA) builds. + * This abstraction ensures that crash reporting functionality can be swapped based on + * the build variant without changing the calling code. + */ +interface CrashReporter { + /** + * Initialize the crash reporter + * Should be called once during app startup + */ + fun initialize() + + /** + * Log a message to the crash reporter + * Used for breadcrumbs and context information + * + * @param message The message to log + */ + fun log(message: String) + + /** + * Record an exception to the crash reporter + * For non-fatal exceptions that should be tracked + * + * @param throwable The exception to record + */ + fun recordException(throwable: Throwable) + + /** + * Set a user identifier for crash reports + * Helps associate crashes with specific users (when privacy allows) + * + * @param identifier The user identifier (should be anonymized if needed) + */ + fun setUserIdentifier(identifier: String) +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf96fda60..91aa3c28a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,15 +5,19 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.navigation.safeargs) - alias(libs.plugins.androidx.room) + alias(libs.plugins.androidx.room3) alias(libs.plugins.detekt.plugin) - alias(libs.plugins.google.gms.google.services) - alias(libs.plugins.google.firebase.crashlytics) + // Rust Android Gradle plugin - COMMENTED OUT due to Gradle 9.2 incompatibility + // Use manual Rust build script instead (see rust-c2pa-ffi/build-android.sh) + // id("org.mozilla.rust-android-gradle.rust-android") version "0.9.4" + // Google Services plugins applied conditionally at bottom of file for GMS builds only + // koin.compiler plugin REMOVED: only needed for annotation-based Koin (@Module/@Single). + // This project uses DSL modules only, and the plugin caused spurious "Missing definition" + // errors on incremental builds (whole-graph validation fails when not all files recompile). } fun loadLocalProperties(): Properties = Properties().apply { @@ -29,19 +33,22 @@ fun loadLocalProperties(): Properties = Properties().apply { } } -kotlin { - - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - languageVersion.set(KotlinVersion.KOTLIN_2_2) - } -} - kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) - languageVersion.set(KotlinVersion.KOTLIN_2_2) + languageVersion.set(KotlinVersion.KOTLIN_2_3) + + // ---- Experimental APIs ---- + optIn.add("androidx.compose.material3.ExperimentalMaterial3Api",) + optIn.add("com.google.accompanist.permissions.ExperimentalPermissionsApi",) + optIn.add("kotlin.time.ExperimentalTime",) + optIn.add("kotlinx.coroutines.ExperimentalCoroutinesApi",) + + // ---- Kotlin compiler feature flags ---- + freeCompilerArgs.add("-Xcontext-parameters") + freeCompilerArgs.add("-Xcontext-sensitive-resolution",) + freeCompilerArgs.add("-Xexplicit-backing-fields") } } @@ -60,8 +67,8 @@ android { applicationId = "net.opendasharchive.openarchive" minSdk = 29 targetSdk = 36 - versionCode = 30024 - versionName = "4.0.4" + versionCode = 30029 + versionName = "4.0.6" multiDexEnabled = true vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -69,14 +76,11 @@ android { resValue("string", "mixpanel_key", localProps.getProperty("MIXPANELKEY") ?: "") } - base { - archivesName.set("save-${project.version}") - } - buildFeatures { viewBinding = true buildConfig = true compose = true + resValues = true } buildTypes { @@ -94,10 +98,30 @@ android { } } - flavorDimensions += "env" + flavorDimensions += listOf("distribution", "env") productFlavors { + // Distribution dimension + create("gms") { + dimension = "distribution" + buildConfigField("boolean", "IS_GMS_BUILD", "true") + buildConfigField("boolean", "IS_FOSS_BUILD", "false") + } + + create("foss") { + dimension = "distribution" + // No applicationIdSuffix for FOSS builds + // F-Droid expects: net.opendasharchive.openarchive.release + buildConfigField("boolean", "IS_GMS_BUILD", "false") + buildConfigField("boolean", "IS_FOSS_BUILD", "true") + // ACRA crash report email - loaded from local.properties or env var + val localProps = loadLocalProperties() + val acraEmail = localProps.getProperty("ACRA_EMAIL") ?: System.getenv("ACRA_EMAIL") ?: "" + buildConfigField("String", "ACRA_EMAIL", "\"$acraEmail\"") + } + + // Environment dimension create("dev") { dimension = "env" versionNameSuffix = "-dev" @@ -130,9 +154,13 @@ android { resources { excludes.addAll( listOf( - "META-INF/LICENSE.txt", "META-INF/NOTICE.txt", "META-INF/LICENSE", - "META-INF/NOTICE", "META-INF/DEPENDENCIES", "LICENSE.txt" - ) + "META-INF/LICENSE.txt", + "META-INF/NOTICE.txt", + "META-INF/LICENSE", + "META-INF/NOTICE", + "META-INF/DEPENDENCIES", + "LICENSE.txt", + ), ) } } @@ -147,6 +175,10 @@ android { } } + androidResources { + generateLocaleConfig = true + } + configurations.all { resolutionStrategy { @@ -154,10 +186,14 @@ android { exclude(group = "org.bouncycastle", module = "bcprov-jdk15on") } } +} - room { - schemaDirectory("$projectDir/schemas") - } +base { + archivesName.set("save-${project.version}") +} + +room3 { + schemaDirectory("$projectDir/schemas") } dependencies { @@ -165,6 +201,8 @@ dependencies { // Kotlin Core implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.datetime) // AndroidX Core implementation(libs.androidx.core.ktx) @@ -200,30 +238,41 @@ dependencies { 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 implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.icons.extended) + implementation(libs.androidx.compose.material3.adaptive) + //implementation(libs.androidx.compose.icons.extended) implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.runtime.livedata) implementation(libs.compose.preferences) + implementation(libs.reorderable) + implementation(libs.accompanist.permissions) // Material Design implementation(libs.google.material) // AndroidX Other implementation(libs.androidx.preferences) + implementation(libs.androidx.datastore.core) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.biometric) implementation(libs.androidx.security.crypto) implementation(libs.androidx.work) // Room Database - implementation(libs.androidx.room.runtime) - implementation(libs.androidx.room.ktx) - ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room3.runtime) + ksp(libs.androidx.room3.compiler) // Dependency Injection - Koin implementation(libs.koin.core) @@ -241,12 +290,14 @@ dependencies { 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) @@ -254,20 +305,26 @@ dependencies { implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.video) implementation(libs.androidx.camera.view) + implementation(libs.androidx.camera.compose) implementation(libs.androidx.camera.extensions) + // Barcode Scanning + implementation(libs.zxing.core) + implementation(libs.zxing.android.embedded) + // Media3 - ExoPlayer implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) - // Google Play Services + // Google Play Services (GMS builds only) //implementation(libs.google.auth) //implementation(libs.google.play.asset.delivery.ktx) //implementation(libs.google.play.feature.delivery) //implementation(libs.google.play.feature.delivery.ktx) - implementation(libs.google.play.review) - implementation(libs.google.play.review.ktx) - implementation(libs.google.play.app.update.ktx) + "gmsImplementation"(libs.google.play.review) + "gmsImplementation"(libs.google.play.review.ktx) + "gmsImplementation"(libs.google.play.app.update.ktx) + "gmsImplementation"("com.google.android.gms:play-services-location:21.1.0") // Google Drive API //implementation(libs.google.api.client.android) @@ -275,6 +332,7 @@ dependencies { //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) @@ -285,21 +343,19 @@ dependencies { implementation(libs.jtorctl) implementation(libs.bitcoinj.core) - // ProofMode - implementation(libs.proofmode) { - exclude(group = "org.bitcoinj") - exclude(group = "com.google.protobuf") - exclude(group = "org.slf4j") - exclude(group = "net.jcip") - exclude(group = "commons-cli") - exclude(group = "org.json") - exclude(group = "com.google.guava") - exclude(group = "com.google.guava", module = "guava-jdk5") - exclude(group = "com.google.code.findbugs", module = "annotations") - exclude(group = "com.squareup.okio", module = "okio") - } + // 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) // Barcode Scanning + implementation(libs.google.mlkit.barcode) implementation(libs.zxing.core) implementation(libs.zxing.android.embedded) @@ -312,12 +368,9 @@ dependencies { implementation(libs.permissionx) implementation(libs.satyan.sugar) - // Analytics Module + // Analytics Module (includes crash reporting) implementation(project(":analytics")) - // Firebase (Crashlytics only - Analytics is in :analytics module) - implementation(libs.firebase.crashlytics) - // Testing testImplementation(libs.junit) testImplementation(libs.robolectric) @@ -348,3 +401,244 @@ detekt { autoCorrect = false 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 = 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") + apply(plugin = "com.google.firebase.crashlytics") +} diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml deleted file mode 100644 index 3dd8b3d7c..000000000 --- a/app/detekt-baseline.xml +++ /dev/null @@ -1,1918 +0,0 @@ - - - - - AnnotationOnSeparateLine:Hbks.kt$Hbks.Availability.Enroll$@RequiresApi(Build.VERSION_CODES.R) data - ArgumentListWrapping:AlertHelper.kt$AlertHelper.Companion$( context, if (message != null) context.getString(message) else null, title, icon, buttons ) - ArgumentListWrapping:BaseButton.kt$( modifier = modifier, text = text, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize, fontWeight = fontWeight, color = color )) - ArgumentListWrapping:BrowseFoldersAdapter.kt$BrowseFoldersAdapter.FolderViewHolder$( binding.root) - ArgumentListWrapping:BrowseFoldersAdapter.kt$BrowseFoldersAdapter.FolderViewHolder$(binding.root) - ArgumentListWrapping:Collection.kt$Collection.Companion$( Collection::class.java, "project_id = ?", arrayOf(projectId.toString()), null, "id ASC", null) - ArgumentListWrapping:Collection.kt$Collection.Companion$(Collection::class.java, "project_id = ?", arrayOf(projectId.toString()), null, "id ASC", null) - ArgumentListWrapping:Context.kt$( this, getString(R.string.no_webbrowser_found_error), Toast.LENGTH_LONG) - ArgumentListWrapping:Context.kt$(this, getString(R.string.no_webbrowser_found_error), Toast.LENGTH_LONG) - ArgumentListWrapping:CreateNewFolderFragment.kt$CreateNewFolderFragment$( requireContext(), getString(R.string.folder_name_already_exists), Toast.LENGTH_LONG ) - ArgumentListWrapping:Drawable.kt$( TypedValue.COMPLEX_UNIT_DIP, biggerSideDipLength.toFloat(), context.resources.displayMetrics ) - ArgumentListWrapping:DrawableExtensions.kt$( (intrinsicWidth * factor).roundToInt(), (intrinsicHeight * factor).roundToInt(), context) - ArgumentListWrapping:DrawableExtensions.kt$( TypedValue.COMPLEX_UNIT_DIP, biggerSideDipLength.toFloat(), context.resources.displayMetrics) - ArgumentListWrapping:DrawableExtensions.kt$((intrinsicWidth * factor).roundToInt(), (intrinsicHeight * factor).roundToInt(), context) - ArgumentListWrapping:DrawableExtensions.kt$(TypedValue.COMPLEX_UNIT_DIP, biggerSideDipLength.toFloat(), context.resources.displayMetrics) - ArgumentListWrapping:EditFolderActivity.kt$EditFolderActivity$( this, R.string.action_remove_project, R.string.remove_from_app, buttons = listOf( AlertHelper.positiveButton(R.string.remove) { _, _ -> mProject.delete() finish() }, AlertHelper.negativeButton() ) ) - ArgumentListWrapping:FileUtils.kt$FileUtils$( Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id)) - ArgumentListWrapping:FileUtils.kt$FileUtils$("$TAG File -", "Authority: " + uri.authority + ", Fragment: " + uri.fragment + ", Port: " + uri.port + ", Query: " + uri.query + ", Scheme: " + uri.scheme + ", Host: " + uri.host + ", Segments: " + uri.pathSegments.toString() ) - ArgumentListWrapping:FolderAdapter.kt$FolderAdapter$( LayoutInflater.from(parent.context), parent, false ) - ArgumentListWrapping:FullscreenDimmingOverlay.kt$FullScreenCreateGroupDimmingOverlay$( context, title = "Confirm", message = "Do you want to cancel?", positiveButtonText = "Yes", negativeButtonText = "No") - ArgumentListWrapping:FullscreenDimmingOverlay.kt$FullScreenDimmingOverlay$( context, title = "Confirm", message = "Do you want to cancel?", positiveButtonText = "Yes", negativeButtonText = "No") - ArgumentListWrapping:GDriveActivity.kt$GDriveActivity$( AlertHelper.positiveButton(R.string.remove) { _, _ -> // delete sign-in from database space.delete() // google logout val googleSignInClient = GoogleSignIn.getClient(applicationContext, GoogleSignInOptions.DEFAULT_SIGN_IN) googleSignInClient.revokeAccess().addOnCompleteListener { googleSignInClient.signOut() } // leave activity Space.navigate(this) }, AlertHelper.negativeButton()) - ArgumentListWrapping:GDriveConduit.kt$GDriveConduit$( "the createFolder calls defined in Conduit don't map to GDrive API. use GDriveConduit.createFolder instead") - ArgumentListWrapping:GDriveConduit.kt$GDriveConduit$("the createFolder calls defined in Conduit don't map to GDrive API. use GDriveConduit.createFolder instead") - ArgumentListWrapping:GDriveConduit.kt$GDriveConduit.Companion$( "mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed = false") - ArgumentListWrapping:GDriveConduit.kt$GDriveConduit.Companion$( "mimeType='application/vnd.google-apps.folder' and name = '$folderName' and trashed = false and '$parentId' in parents") - ArgumentListWrapping:GDriveConduit.kt$GDriveConduit.Companion$("mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed = false") - ArgumentListWrapping:GDriveConduit.kt$GDriveConduit.Companion$("mimeType='application/vnd.google-apps.folder' and name = '$folderName' and trashed = false and '$parentId' in parents") - ArgumentListWrapping:IaConduit.kt$IaConduit$( mContext.contentResolver, Uri.fromFile(uploadFile), uploadFile.length(), textMediaType, createListener(cancellable = { !mCancelled }) ) - ArgumentListWrapping:InternetArchiveFragment.kt$InternetArchiveFragment$( message) - ArgumentListWrapping:InternetArchiveFragment.kt$InternetArchiveFragment$(message) - ArgumentListWrapping:InternetArchiveLoginScreen.kt$( Intent.ACTION_VIEW, Uri.parse(CreateLogin.URI) ) - ArgumentListWrapping:InternetArchiveLoginScreen.kt$( contract = ActivityResultContracts.StartActivityForResult(), onResult = {}) - ArgumentListWrapping:InternetArchiveLoginScreen.kt$( modifier = Modifier .weight(1f) .heightIn(ThemeDimensions.touchable) .padding(ThemeDimensions.spacing.small), shape = RoundedCornerShape(ThemeDimensions.roundedCorner), onClick = { dispatch(Action.Cancel) }) - ArgumentListWrapping:InternetArchiveLoginScreen.kt$( modifier = Modifier.heightIn(ThemeDimensions.touchable), onClick = { dispatch(CreateLogin) }) - ArgumentListWrapping:InternetArchiveLoginScreen.kt$( modifier = Modifier.sizeIn(ThemeDimensions.touchable), onClick = { showPassword = !showPassword }) - ArgumentListWrapping:InternetArchiveLoginScreen.kt$( username = "user@example.org", password = "abc123" ) - ArgumentListWrapping:InternetArchiveMapper.kt$InternetArchiveMapper$( access = response.access, secret = response.secret ) - ArgumentListWrapping:MainActivity.kt$MainActivity$( AddMediaDialogFragment.RESP_FILES, this@MainActivity ) - ArgumentListWrapping:MainActivity.kt$MainActivity$( AddMediaDialogFragment.RESP_PHOTO_GALLERY, this@MainActivity ) - ArgumentListWrapping:MainActivity.kt$MainActivity$( AddMediaDialogFragment.RESP_TAKE_PHOTO, this@MainActivity ) - ArgumentListWrapping:MainActivity.kt$MainActivity$( Context.INPUT_METHOD_SERVICE) - ArgumentListWrapping:MainActivity.kt$MainActivity$( Manifest.permission.POST_NOTIFICATIONS) - ArgumentListWrapping:MainActivity.kt$MainActivity$(Context.INPUT_METHOD_SERVICE) - ArgumentListWrapping:MainActivity.kt$MainActivity$(Manifest.permission.POST_NOTIFICATIONS) - ArgumentListWrapping:Media.kt$Media.Companion$( Media::class.java, statuses.joinToString(" OR ") { "status = ?" }, statuses.map { it.id.toString() }.toTypedArray(), null, order, null ) - ArgumentListWrapping:MediaAdapter.kt$MediaAdapter$( it, it.getString(R.string.upload_unsuccessful_description), R.string.upload_unsuccessful, R.drawable.ic_error, listOf( AlertHelper.positiveButton(R.string.retry) { _, _ -> media[pos].apply { sStatus = Media.Status.Queued statusMessage = "" save() BroadcastManager.postChange(it, collectionId, id) } UploadService.startUploadService(it) }, AlertHelper.negativeButton(R.string.remove) { _, _ -> deleteItem(pos) }, AlertHelper.neutralButton() ) ) - ArgumentListWrapping:MediaViewHolder.kt$MediaViewHolder$( "Binding media item ${media?.id} with status ${media?.sStatus} and progress ${media?.uploadPercentage}") - ArgumentListWrapping:MediaViewHolder.kt$MediaViewHolder$("Binding media item ${media?.id} with status ${media?.sStatus} and progress ${media?.uploadPercentage}") - ArgumentListWrapping:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity$( WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE ) - ArgumentListWrapping:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity.<no name provided>$( mBinding.fab.context, R.drawable.ic_arrow_right, ) - ArgumentListWrapping:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity.<no name provided>$( mBinding.fab.context, com.esafirm.imagepicker.R.drawable.ef_ic_done_white, ) - ArgumentListWrapping:PasscodeEntryScreen.kt$( text = "Enter Your Passcode", style = TextStyle( fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground ) ) - ArgumentListWrapping:Picker.kt$Picker$( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO) - ArgumentListWrapping:Picker.kt$Picker$( context, "${context.packageName}.provider", it ) - ArgumentListWrapping:ProofModeScreen.kt$( stringResource( R.string.prefs_use_proofmode_description, "https://www.google.com" ), HtmlCompat.FROM_HTML_MODE_COMPACT ) - ArgumentListWrapping:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Fragment$( R.string.pref_key_use_proof_mode) - ArgumentListWrapping:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Fragment$(R.string.pref_key_use_proof_mode) - ArgumentListWrapping:SaveClient.kt$SaveClient.Companion.<no name provided>$( Result.failure(OrbotException(context.getString(R.string.tor_connection_invalid)))) - ArgumentListWrapping:SaveClient.kt$SaveClient.Companion.<no name provided>$( Result.failure(OrbotException(context.getString(R.string.tor_connection_timeout)))) - ArgumentListWrapping:SaveClient.kt$SaveClient.Companion.<no name provided>$( Result.failure(e ?: OrbotException(context.getString(R.string.tor_connection_exception)))) - ArgumentListWrapping:SaveClient.kt$SaveClient.Companion.<no name provided>$(Result.failure(OrbotException(context.getString(R.string.tor_connection_invalid)))) - ArgumentListWrapping:SaveClient.kt$SaveClient.Companion.<no name provided>$(Result.failure(OrbotException(context.getString(R.string.tor_connection_timeout)))) - ArgumentListWrapping:SaveClient.kt$SaveClient.Companion.<no name provided>$(Result.failure(e ?: OrbotException(context.getString(R.string.tor_connection_exception)))) - ArgumentListWrapping:SettingsScreen.kt$( "light" to "Light", "dark" to "Dark", "system" to "System Default" ) - ArgumentListWrapping:SettingsScreen.kt$( key = "about_app", title = { Text("Save by Open Archive") }, summary = { Text("Tap to view about Save App") }, onClick = { // Handle URL intent openUrl(context, "https://open-archive.org/save") }) - ArgumentListWrapping:SettingsScreen.kt$( key = "pref_app_passcode", defaultValue = false, title = { Text("Lock app with passcode") }, summary = { Text("6 digit passcode") }) - ArgumentListWrapping:SettingsScreen.kt$( key = "pref_media_folders", title = { Text("Media Folders") }, summary = { Text("Add or remove media folders") }) - ArgumentListWrapping:SettingsScreen.kt$( key = "pref_media_servers", title = { Text("Media Servers") }, summary = { Text("Add or remove media servers") }) - ArgumentListWrapping:SettingsScreen.kt$( key = "privacy_policy", title = { Text("Terms & Privacy Policy") }, summary = { Text("Tap to view our Terms & Privacy Policy") }, onClick = { // Handle URL intent openUrl(context, "https://open-archive.org/privacy") }) - ArgumentListWrapping:SettingsScreen.kt$( key = "proof_mode", title = { Text("Proof Mode") }) - ArgumentListWrapping:SettingsScreen.kt$( key = "upload_wifi_only", defaultValue = false, title = { Text("Upload over Wi-Fi only") }, summary = { Text("Only upload media when connected to Wi-Fi") }) - ArgumentListWrapping:SettingsScreen.kt$( key = "use_tor", defaultValue = false, title = { Text("Use Tor") }, summary = { Text("Enable Tor for encryption") }) - ArgumentListWrapping:SmartFragmentStatePagerAdapter.kt$SmartFragmentStatePagerAdapter$( fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) - ArgumentListWrapping:SmartFragmentStatePagerAdapter.kt$SmartFragmentStatePagerAdapter$(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) - ArgumentListWrapping:SnowbirdCreateGroupFragment.kt$SnowbirdCreateGroupFragment$( group.key, viewBinding.repoNameTextfield.text.toString() ) - ArgumentListWrapping:SnowbirdFileListAdapter.kt$SnowbirdFileListAdapter$( ContextCompat.getDrawable(context, R.drawable.outline_cloud_done_24)?.scaled(40, context)) - ArgumentListWrapping:SnowbirdFileListAdapter.kt$SnowbirdFileListAdapter$( ContextCompat.getDrawable(context, R.drawable.outline_cloud_download_24)?.scaled(40, context)) - ArgumentListWrapping:SnowbirdFileListAdapter.kt$SnowbirdFileListAdapter$(ContextCompat.getDrawable(context, R.drawable.outline_cloud_done_24)?.scaled(40, context)) - ArgumentListWrapping:SnowbirdFileListAdapter.kt$SnowbirdFileListAdapter$(ContextCompat.getDrawable(context, R.drawable.outline_cloud_download_24)?.scaled(40, context)) - ArgumentListWrapping:SnowbirdFileListFragment.kt$SnowbirdFileListFragment$( ActivityResultContracts.GetMultipleContents()) - ArgumentListWrapping:SnowbirdFileListFragment.kt$SnowbirdFileListFragment$( R.color.colorPrimary, R.color.colorPrimaryDark ) - ArgumentListWrapping:SnowbirdFileListFragment.kt$SnowbirdFileListFragment$( requireContext(), title = "Download Media?", message = "Are you sure you want to download this media?", positiveButtonText = "Yes", negativeButtonText = "No") - ArgumentListWrapping:SnowbirdFileListFragment.kt$SnowbirdFileListFragment$( requireContext(), title = "Success", message = "File successfully downloaded") - ArgumentListWrapping:SnowbirdFileListFragment.kt$SnowbirdFileListFragment$(ActivityResultContracts.GetMultipleContents()) - ArgumentListWrapping:SnowbirdGroup.kt$SnowbirdGroup.Companion$( SnowbirdGroup::class.java, whereClause, whereArgs.toTypedArray(), null, null, null) - ArgumentListWrapping:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment$( RESULT_REQUEST_KEY, bundleOf( RESULT_BUNDLE_NAVIGATION_KEY to RESULT_VAL_RAVEN_REPO_LIST_SCREEN, RESULT_BUNDLE_GROUP_KEY to groupKey ) ) - ArgumentListWrapping:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment$( groupKey) - ArgumentListWrapping:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment$(groupKey) - ArgumentListWrapping:SnowbirdRepo.kt$SnowbirdRepo.Companion$( SnowbirdRepo::class.java, whereClause, whereArgs.toTypedArray(), null, null, null ) - ArgumentListWrapping:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment$( R.color.colorPrimary, R.color.colorPrimaryDark ) - ArgumentListWrapping:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment$( object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.menu_snowbird, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_add -> { Utility.showMaterialWarning( context = requireContext(), message = "Feature not implemented yet.", positiveButtonText = "OK" ) true } else -> false } } }, viewLifecycleOwner, Lifecycle.State.RESUMED ) - ArgumentListWrapping:Space.kt$Space.Companion$( Space::class.java, whereClause, whereArgs.toTypedArray(), null, null, null ) - ArgumentListWrapping:SpaceAdapter.kt$SpaceAdapter$( DIFF_CALLBACK) - ArgumentListWrapping:SpaceAdapter.kt$SpaceAdapter$( LayoutInflater.from(parent.context), parent, false ) - ArgumentListWrapping:SpaceAdapter.kt$SpaceAdapter$(DIFF_CALLBACK) - ArgumentListWrapping:SpaceDrawerAdapter.kt$SpaceDrawerAdapter$( DIFF_CALLBACK) - ArgumentListWrapping:SpaceDrawerAdapter.kt$SpaceDrawerAdapter$(DIFF_CALLBACK) - ArgumentListWrapping:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$( ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START) - ArgumentListWrapping:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START) - ArgumentListWrapping:TextView.kt$( Position.Start.get(drawables), Position.Top.get(drawables), Position.End.get(drawables), Position.Bottom.get(drawables)) - ArgumentListWrapping:TorStatusDatabase.kt$TorStatusDatabase$( context, DATABASE_NAME, null, DATABASE_VERSION) - ArgumentListWrapping:TorStatusDatabase.kt$TorStatusDatabase$(context, DATABASE_NAME, null, DATABASE_VERSION) - ArgumentListWrapping:UnixSocketClient.kt$UnixSocketClient$( endpoint, method, body, { json.encodeToString(it) }, { json.decodeFromString<RESPONSE>(it) }) - ArgumentListWrapping:UnixSocketClient.kt$UnixSocketClient$( socket, endpoint, method, body, serialize) - ArgumentListWrapping:UnixSocketClient.kt$UnixSocketClient$(endpoint, method, body, { json.encodeToString(it) }, { json.decodeFromString<RESPONSE>(it) }) - ArgumentListWrapping:UnixSocketClient.kt$UnixSocketClient$(socket, endpoint, method, body, serialize) - ArgumentListWrapping:UploadService.kt$UploadService$( NOTIFICATION_CHANNEL_ID, getString(R.string.uploads), NotificationManager.IMPORTANCE_LOW ) - ArgumentListWrapping:UploadService.kt$UploadService$( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) - ArgumentListWrapping:WebDavConduit.kt$WebDavConduit$( chunkPath, buffer, mMedia.mimeType, object : SardineListener { override fun transferred(bytes: Long) { jobProgress(offset.toLong() + bytes) } override fun continueUpload(): Boolean { return !mCancelled } }) - ArgumentListWrapping:WebDavConduit.kt$WebDavConduit$( construct(base, path, file.name), file, "text/plain", false, null) - ArgumentListWrapping:WebDavConduit.kt$WebDavConduit$( mContext.contentResolver, fullPath, mMedia.fileUri, mMedia.contentLength, mMedia.mimeType, false, object : SardineListener { var lastBytes: Long = 0 override fun transferred(bytes: Long) { if (bytes > lastBytes) { jobProgress(bytes) lastBytes = bytes } AppLogger.i("Bytes transferred for for ${mMedia.id}: ", "$bytes") } override fun continueUpload(): Boolean { AppLogger.i("Should continue upload for ${mMedia.id}?", "$mCancelled") return !mCancelled } }) - ArgumentListWrapping:WebDavConduit.kt$WebDavConduit$(mContext.contentResolver, fullPath, mMedia.fileUri, mMedia.contentLength, mMedia.mimeType, false, object : SardineListener { var lastBytes: Long = 0 override fun transferred(bytes: Long) { if (bytes > lastBytes) { jobProgress(bytes) lastBytes = bytes } AppLogger.i("Bytes transferred for for ${mMedia.id}: ", "$bytes") } override fun continueUpload(): Boolean { AppLogger.i("Should continue upload for ${mMedia.id}?", "$mCancelled") return !mCancelled } }) - ArgumentListWrapping:WebDavSetupLicenseFragment.kt$WebDavSetupLicenseFragment$( message = getString(R.string.you_have_successfully_connected_to_a_private_server)) - ArgumentListWrapping:WebDavSetupLicenseFragment.kt$WebDavSetupLicenseFragment$(message = getString(R.string.you_have_successfully_connected_to_a_private_server)) - ChainWrapping:Media.kt$Media$|| - ChainWrapping:Picker.kt$Picker$&& - ChainWrapping:PreviewAdapter.kt$PreviewAdapter.Companion.<no name provided>$&& - CommentSpacing:AddFolderActivity.kt$AddFolderActivity$//mBinding = ActivityAddFolderBinding.inflate(layoutInflater) - CommentSpacing:AddFolderActivity.kt$AddFolderActivity$//mBinding.browseFolderContainer.hide() - CommentSpacing:AddFolderActivity.kt$AddFolderActivity$//setContentView(mBinding.root) - CommentSpacing:BadgeDrawable.kt$BadgeDrawable$//NO-OP - CommentSpacing:BaseSnowbirdFragment.kt$BaseSnowbirdFragment$//FullScreenOverlayManager.hide() - CommentSpacing:BaseSnowbirdFragment.kt$BaseSnowbirdFragment$//FullScreenOverlayManager.show(this@BaseSnowbirdFragment) - CommentSpacing:DialogConfigBuilder.kt$DialogBuilder$//?: ButtonData(defaultPositiveTextFor(type)), - CommentSpacing:HomeActivity.kt$HomeActivity$//TODO: Refresh projects in MainViewModel - CommentSpacing:HomeScreen.kt$//@Composable - CommentSpacing:HomeScreen.kt$//fun MainMediaScreen(projectId: Long) { - CommentSpacing:HomeScreen.kt$//} - CommentSpacing:IaConduit.kt$IaConduit$/// Upload ProofMode metadata, if enabled and successfully created. - CommentSpacing:IaConduit.kt$IaConduit$/// headers for meta-data and proof mode - CommentSpacing:IaConduit.kt$IaConduit$/// upload proof mode - CommentSpacing:InternetArchiveActivity.kt$//fun Activity.measureNewBackend(type: Space.Type) { - CommentSpacing:InternetArchiveActivity.kt$//} - CommentSpacing:InternetArchiveDetailsScreen.kt$//InternetArchiveHeader() - CommentSpacing:InternetArchiveDetailsScreen.kt$//dismiss - CommentSpacing:InternetArchiveDetailsScreen.kt$//isRemoving = true - CommentSpacing:InternetArchiveLoginScreen.kt$//focusedIndicatorColor = Color.Transparent, - CommentSpacing:InternetArchiveLoginScreen.kt$//unfocusedIndicatorColor = Color.Transparent, - CommentSpacing:MainActivity.kt$MainActivity$///enableEdgeToEdge() - CommentSpacing:MainActivity.kt$MainActivity$//binding.contentMain.tvSelectedCount.text = if (count > 0) "Selected: $count" else "Select Media" - CommentSpacing:MainMediaFragment.kt$MainMediaFragment$//update selection UI by summing selected counts from all adapters. - CommentSpacing:MediaAdapter.kt$MediaAdapter$//CleanInsightsManager.measureEvent("backend", "upload-error", media[pos].space?.friendlyName) - CommentSpacing:MediaViewHolder.kt$MediaViewHolder.Box$//(binding as RvMediaBoxBinding).fileInfo - CommentSpacing:MediaViewHolder.kt$MediaViewHolder.Box$//(binding as RvMediaBoxBinding).title - CommentSpacing:PasscodeSetupActivity.kt$PasscodeSetupActivity$//onBackPressedCallback.handleOnBackPressed() - CommentSpacing:PasscodeSetupActivity.kt$PasscodeSetupActivity$//onBackPressedDispatcher.addCallback(onBackPressedCallback) - CommentSpacing:PreviewActivity.kt$PreviewActivity$//mBinding.addMenu.container.show(animate = true) - CommentSpacing:SettingsFragment.kt$SettingsFragment$//torViewModel.updateTorServiceState() - CommentSpacing:SnowbirdFileListAdapter.kt$SnowbirdFileListAdapter$//button.setBackgroundResource(R.drawable.button_outlined_ripple) - CommentSpacing:SnowbirdGroupListAdapter.kt$//interface SnowbirdGroupsAdapterListener { - CommentSpacing:SnowbirdGroupListAdapter.kt$//} - CommentSpacing:SnowbirdGroupListAdapter.kt$SnowbirdGroupsAdapter.ViewHolder$//binding.button.setBackgroundResource(R.drawable.button_outlined_ripple) - CommentSpacing:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment$//findNavController().navigate(SnowbirdGroupListFragmentDirections.navigateToSnowbirdShareScreen(groupKey)) - CommentSpacing:SnowbirdRepoListAdapter.kt$SnowbirdRepoListAdapter.SnowbirdRepoListViewHolder$//binding.button.setBackgroundResource(R.drawable.button_outlined_ripple) - CommentSpacing:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment$//findNavController().navigate(SnowbirdRepoListFragmentDirections.navigateToSnowbirdListFilesScreen(groupKey, repoKey)) - CommentSpacing:SpaceAdapter.kt$SpaceAdapter$//@Suppress("NAME_SHADOWING") - CommentSpacing:SpaceAdapter.kt$SpaceAdapter$//spaces.add(Space(ADD_SPACE_ID)) - CommentSpacing:SpaceAdapter.kt$SpaceAdapter$//val spaces = spaces.toMutableList() - CommentSpacing:UnixSocketClient.kt$//sealed class ClientResponse<out T> { - CommentSpacing:UnixSocketClient.kt$//} - CommentSpacing:WebDavConduit.kt$WebDavConduit$/// Upload ProofMode metadata, if enabled and successfully created. - CommentSpacing:WebDavFragment.kt$WebDavFragment$//Refresh menu to hide confirm btn again - CommentSpacing:WebDavFragment.kt$WebDavFragment$//attemptLogin() - CommentSpacing:WebDavFragment.kt$WebDavFragment.<no name provided>$//todo: save changes here and show success dialog - CommentWrapping:MainMediaScreen.kt$/* no op */ - ComplexCondition:Hbks.kt$Hbks$key == null || cipher == null || ciphertext == null || ciphertext.size < 12 - ComposableParamOrder:Accordion.kt$Accordion - ComposableParamOrder:BaseDialog.kt$BaseDialog - ComposableParamOrder:ExpandableSpaceList.kt$ExpandableSpaceList - ComposableParamOrder:FolderOptionsPopup.kt$FolderOptionsPopup - ComposableParamOrder:HomeScreen.kt$HomeScreen - ComposableParamOrder:HomeScreen.kt$SaveNavGraph - ComposableParamOrder:InternetArchiveLoginScreen.kt$CustomSecureField - ComposableParamOrder:InternetArchiveLoginScreen.kt$CustomTextField - ComposableParamOrder:NumericKeypad.kt$NumberButton - ComposableParamOrder:NumericKeypad.kt$NumericKeypad - ComposableParamOrder:PrimaryButton.kt$PrimaryButton - ComposableParamOrder:UiImage.kt$UiImage$asIcon - CompositionLocalAllowlist:Colors.kt$LocalColors - CompositionLocalAllowlist:Dimensions.kt$LocalDimensions - ContentSlotReused:Accordion.kt$bodyContent - CyclomaticComplexMethod:DialogConfigBuilder.kt$DialogBuilder$@Composable fun build(): DialogConfig - CyclomaticComplexMethod:DialogConfigBuilder.kt$DialogBuilder$fun build(resourceProvider: ResourceProvider): DialogConfig - CyclomaticComplexMethod:FileUtils.kt$FileUtils$@SuppressLint("NewAPI", "LogNotTimber") fun getPath(context: Context, uri: Uri): String? - CyclomaticComplexMethod:HomeScreen.kt$@Composable fun HomeScreenContent( onExit: () -> Unit, state: HomeScreenState, onAction: (HomeScreenAction) -> Unit, onNavigateToCache: () -> Unit = {} ) - CyclomaticComplexMethod:IaConduit.kt$IaConduit$private fun mainHeader(): Headers - CyclomaticComplexMethod:MainMediaViewHolder.kt$MainMediaViewHolder$fun bind(media: Media? = null, isInSelectionMode: Boolean = false, doImageFade: Boolean = true) - CyclomaticComplexMethod:MediaAdapter.kt$MediaAdapter$override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder - CyclomaticComplexMethod:MediaViewHolder.kt$MediaViewHolder$@SuppressLint("SetTextI18n") fun bind(media: Media? = null, batchMode: Boolean = false, doImageFade: Boolean = true) - CyclomaticComplexMethod:NumericKeypad.kt$@Composable private fun NumberButton( label: String, enabled: Boolean = true, onClick: () -> Unit, hapticManager: HapticManager = koinInject() ) - CyclomaticComplexMethod:PreviewViewHolder.kt$PreviewViewHolder$@SuppressLint("SetTextI18n") fun bind(media: Media? = null, batchMode: Boolean = false, doImageFade: Boolean = true) - CyclomaticComplexMethod:ReviewActivity.kt$ReviewActivity$private fun refresh() - CyclomaticComplexMethod:UnixSocketClientUtilityExtensions.kt$suspend fun UnixSocketClient.readBinaryResponseWithCancellation( inputStream: InputStream, onProgress: ((Long) -> Unit)? = null ): Triple<Int, Map<String, String>, ByteArray> - CyclomaticComplexMethod:WebDavConduit.kt$WebDavConduit$@Throws(IOException::class) private suspend fun uploadChunked(base: HttpUrl, path: List<String>, fileName: String): Boolean - EmptyFunctionBlock:CreateNewFolderFragment.kt$CreateNewFolderFragment.<no name provided>${} - EmptyFunctionBlock:PasscodeEntryViewModel.kt$PasscodeEntryViewModel${ } - EmptyFunctionBlock:ReviewActivity.kt$ReviewActivity.<no name provided>${ } - EmptyFunctionBlock:TorStatusDatabase.kt$TorStatusDatabase${ } - EmptyFunctionBlock:WebDavFragment.kt$WebDavFragment.<no name provided>${} - Filename:SnowbirdGroupListAdapter.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupListAdapter.kt - FinalNewline:ActivityExtension.kt$net.opendasharchive.openarchive.extensions.ActivityExtension.kt - FinalNewline:AddFolderActivity.kt$net.opendasharchive.openarchive.features.folders.AddFolderActivity.kt - FinalNewline:AddFolderScreen.kt$net.opendasharchive.openarchive.features.folders.AddFolderScreen.kt - FinalNewline:AddMediaDialogFragment.kt$net.opendasharchive.openarchive.features.media.AddMediaDialogFragment.kt - FinalNewline:AddMediaType.kt$net.opendasharchive.openarchive.features.media.AddMediaType.kt - FinalNewline:AlertHelper.kt$net.opendasharchive.openarchive.util.AlertHelper.kt - FinalNewline:ApiError.kt$net.opendasharchive.openarchive.db.ApiError.kt - FinalNewline:ApiResponse.kt$net.opendasharchive.openarchive.services.snowbird.service.ApiResponse.kt - FinalNewline:AppConfig.kt$net.opendasharchive.openarchive.features.settings.passcode.AppConfig.kt - FinalNewline:AppLogger.kt$net.opendasharchive.openarchive.core.logger.AppLogger.kt - FinalNewline:ApplicationExtensions.kt$net.opendasharchive.openarchive.extensions.ApplicationExtensions.kt - FinalNewline:BackoffStrategy.kt$net.opendasharchive.openarchive.services.snowbird.service.BackoffStrategy.kt - FinalNewline:BadgeDrawable.kt$net.opendasharchive.openarchive.util.BadgeDrawable.kt - FinalNewline:BaseActivity.kt$net.opendasharchive.openarchive.features.core.BaseActivity.kt - FinalNewline:BaseButton.kt$net.opendasharchive.openarchive.features.core.BaseButton.kt - FinalNewline:BaseComposeActivity.kt$net.opendasharchive.openarchive.features.core.BaseComposeActivity.kt - FinalNewline:BaseDialog.kt$net.opendasharchive.openarchive.features.core.dialog.BaseDialog.kt - FinalNewline:BaseFragment.kt$net.opendasharchive.openarchive.features.core.BaseFragment.kt - FinalNewline:BaseViewModel.kt$net.opendasharchive.openarchive.util.BaseViewModel.kt - FinalNewline:BasicAuthInterceptor.kt$net.opendasharchive.openarchive.services.webdav.BasicAuthInterceptor.kt - FinalNewline:BiometricAuthenticator.kt$net.opendasharchive.openarchive.features.settings.passcode.BiometricAuthenticator.kt - FinalNewline:BottomSheetExtensions.kt$net.opendasharchive.openarchive.extensions.BottomSheetExtensions.kt - FinalNewline:BrowseFolderScreen.kt$net.opendasharchive.openarchive.features.folders.BrowseFolderScreen.kt - FinalNewline:BrowseFoldersAdapter.kt$net.opendasharchive.openarchive.features.folders.BrowseFoldersAdapter.kt - FinalNewline:BrowseFoldersFragment.kt$net.opendasharchive.openarchive.features.folders.BrowseFoldersFragment.kt - FinalNewline:BrowseFoldersViewModel.kt$net.opendasharchive.openarchive.features.folders.BrowseFoldersViewModel.kt - FinalNewline:Collection.kt$net.opendasharchive.openarchive.db.Collection.kt - FinalNewline:Colors.kt$net.opendasharchive.openarchive.core.presentation.theme.Colors.kt - FinalNewline:Conduit.kt$net.opendasharchive.openarchive.services.Conduit.kt - FinalNewline:ConsentActivity.kt$net.opendasharchive.openarchive.features.settings.ConsentActivity.kt - FinalNewline:ContentPickerFragment.kt$net.opendasharchive.openarchive.features.media.ContentPickerFragment.kt - FinalNewline:Context.kt$net.opendasharchive.openarchive.util.extensions.Context.kt - FinalNewline:CreativeCommonsLicenseManager.kt$net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager.kt - FinalNewline:CustomBottomNavBar.kt$net.opendasharchive.openarchive.core.presentation.components.CustomBottomNavBar.kt - FinalNewline:CustomButton.kt$net.opendasharchive.openarchive.features.main.ui.CustomButton.kt - FinalNewline:DefaultScaffold.kt$net.opendasharchive.openarchive.features.settings.passcode.components.DefaultScaffold.kt - FinalNewline:DialogConfigBuilder.kt$net.opendasharchive.openarchive.features.core.dialog.DialogConfigBuilder.kt - FinalNewline:Drawable.kt$net.opendasharchive.openarchive.util.extensions.Drawable.kt - FinalNewline:DrawableExtensions.kt$net.opendasharchive.openarchive.extensions.DrawableExtensions.kt - FinalNewline:DrawableUtil.kt$net.opendasharchive.openarchive.util.DrawableUtil.kt - FinalNewline:DriveServiceHelper.kt$net.opendasharchive.openarchive.util.DriveServiceHelper.kt - FinalNewline:DurationExtensions.kt$net.opendasharchive.openarchive.extensions.DurationExtensions.kt - FinalNewline:EditFolderActivity.kt$net.opendasharchive.openarchive.features.settings.EditFolderActivity.kt - FinalNewline:Effects.kt$net.opendasharchive.openarchive.core.state.Effects.kt - FinalNewline:EmptyableRecyclerView.kt$net.opendasharchive.openarchive.features.main.ui.EmptyableRecyclerView.kt - FinalNewline:ExpandableSpaceList.kt$net.opendasharchive.openarchive.features.main.ui.components.ExpandableSpaceList.kt - FinalNewline:FeaturesModule.kt$net.opendasharchive.openarchive.core.di.FeaturesModule.kt - FinalNewline:FileUploadResult.kt$net.opendasharchive.openarchive.db.FileUploadResult.kt - FinalNewline:FileUtils.kt$net.opendasharchive.openarchive.util.FileUtils.kt - FinalNewline:FolderAdapter.kt$net.opendasharchive.openarchive.FolderAdapter.kt - FinalNewline:FolderDrawerAdapter.kt$net.opendasharchive.openarchive.features.main.adapters.FolderDrawerAdapter.kt - FinalNewline:FolderOptionsPopup.kt$net.opendasharchive.openarchive.features.main.ui.components.FolderOptionsPopup.kt - FinalNewline:FoldersActivity.kt$net.opendasharchive.openarchive.features.settings.FoldersActivity.kt - FinalNewline:FullscreenDimmingOverlay.kt$net.opendasharchive.openarchive.util.FullscreenDimmingOverlay.kt - FinalNewline:FullscreenOverlayManager.kt$net.opendasharchive.openarchive.util.FullscreenOverlayManager.kt - FinalNewline:GeneralSettingsActivity.kt$net.opendasharchive.openarchive.features.settings.GeneralSettingsActivity.kt - FinalNewline:HapticManager.kt$net.opendasharchive.openarchive.features.settings.passcode.HapticManager.kt - FinalNewline:HashingStrategy.kt$net.opendasharchive.openarchive.features.settings.passcode.HashingStrategy.kt - FinalNewline:Hbks.kt$net.opendasharchive.openarchive.util.Hbks.kt - FinalNewline:HomeActivity.kt$net.opendasharchive.openarchive.features.main.HomeActivity.kt - FinalNewline:HomeAppBar.kt$net.opendasharchive.openarchive.features.main.ui.components.HomeAppBar.kt - FinalNewline:HomeScreen.kt$net.opendasharchive.openarchive.features.main.ui.HomeScreen.kt - FinalNewline:HttpLikeException.kt$net.opendasharchive.openarchive.services.snowbird.service.HttpLikeException.kt - FinalNewline:ISnowbirdAPI.kt$net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI.kt - FinalNewline:InternetArchiveLocalSource.kt$net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveLocalSource.kt - FinalNewline:InternetArchiveScreen.kt$net.opendasharchive.openarchive.features.internetarchive.presentation.InternetArchiveScreen.kt - FinalNewline:JoinGroupResponse.kt$net.opendasharchive.openarchive.db.JoinGroupResponse.kt - FinalNewline:Listener.kt$net.opendasharchive.openarchive.core.state.Listener.kt - FinalNewline:MainBottomBar.kt$net.opendasharchive.openarchive.features.main.ui.components.MainBottomBar.kt - FinalNewline:MainDrawerContent.kt$net.opendasharchive.openarchive.features.main.ui.components.MainDrawerContent.kt - FinalNewline:MainMediaAdapter.kt$net.opendasharchive.openarchive.features.main.adapters.MainMediaAdapter.kt - FinalNewline:MainMediaAdapterTest.kt$net.opendasharchive.openarchive.MainMediaAdapterTest.kt - FinalNewline:MainMediaScreen.kt$net.opendasharchive.openarchive.features.main.ui.MainMediaScreen.kt - FinalNewline:MainViewModel.kt$net.opendasharchive.openarchive.features.main.MainViewModel.kt - FinalNewline:MediaAdapter.kt$net.opendasharchive.openarchive.db.MediaAdapter.kt - FinalNewline:MediaCacheScreen.kt$net.opendasharchive.openarchive.features.main.ui.MediaCacheScreen.kt - FinalNewline:MediaLaunchers.kt$net.opendasharchive.openarchive.features.media.MediaLaunchers.kt - FinalNewline:Notifier.kt$net.opendasharchive.openarchive.core.state.Notifier.kt - FinalNewline:NumericKeypad.kt$net.opendasharchive.openarchive.features.settings.passcode.components.NumericKeypad.kt - FinalNewline:Onboarding23Activity.kt$net.opendasharchive.openarchive.features.onboarding.Onboarding23Activity.kt - FinalNewline:Onboarding23FragmentStateAdapter.kt$net.opendasharchive.openarchive.features.onboarding.Onboarding23FragmentStateAdapter.kt - FinalNewline:Onboarding23InstructionsActivity.kt$net.opendasharchive.openarchive.features.onboarding.Onboarding23InstructionsActivity.kt - FinalNewline:Onboarding23SlideFragment.kt$net.opendasharchive.openarchive.features.onboarding.Onboarding23SlideFragment.kt - FinalNewline:PBKDF2HashingStrategy.kt$net.opendasharchive.openarchive.features.settings.passcode.PBKDF2HashingStrategy.kt - FinalNewline:PasscodeDots.kt$net.opendasharchive.openarchive.features.settings.passcode.components.PasscodeDots.kt - FinalNewline:PasscodeEntryActivity.kt$net.opendasharchive.openarchive.features.settings.passcode.passcode_entry.PasscodeEntryActivity.kt - FinalNewline:PasscodeEntryScreen.kt$net.opendasharchive.openarchive.features.settings.passcode.passcode_entry.PasscodeEntryScreen.kt - FinalNewline:PasscodeEntryViewModel.kt$net.opendasharchive.openarchive.features.settings.passcode.passcode_entry.PasscodeEntryViewModel.kt - FinalNewline:PasscodeManager.kt$net.opendasharchive.openarchive.features.settings.passcode.PasscodeManager.kt - FinalNewline:PasscodeModule.kt$net.opendasharchive.openarchive.core.di.PasscodeModule.kt - FinalNewline:PasscodeRepository.kt$net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository.kt - FinalNewline:PasscodeSetupActivity.kt$net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupActivity.kt - FinalNewline:PasscodeSetupScreen.kt$net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupScreen.kt - FinalNewline:PasscodeSetupViewModel.kt$net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupViewModel.kt - FinalNewline:Picker.kt$net.opendasharchive.openarchive.features.media.Picker.kt - FinalNewline:Preview.kt$net.opendasharchive.openarchive.core.presentation.theme.Preview.kt - FinalNewline:PreviewActivity.kt$net.opendasharchive.openarchive.features.media.PreviewActivity.kt - FinalNewline:PreviewAdapter.kt$net.opendasharchive.openarchive.features.media.PreviewAdapter.kt - FinalNewline:PreviewViewHolder.kt$net.opendasharchive.openarchive.features.media.adapter.PreviewViewHolder.kt - FinalNewline:PrimaryButton.kt$net.opendasharchive.openarchive.core.presentation.components.PrimaryButton.kt - FinalNewline:ProcessingTracker.kt$net.opendasharchive.openarchive.util.ProcessingTracker.kt - FinalNewline:Project.kt$net.opendasharchive.openarchive.db.Project.kt - FinalNewline:ProofModeHelper.kt$net.opendasharchive.openarchive.util.ProofModeHelper.kt - FinalNewline:ProofModeScreen.kt$net.opendasharchive.openarchive.features.settings.ProofModeScreen.kt - FinalNewline:QRScannerActivity.kt$net.opendasharchive.openarchive.features.main.QRScannerActivity.kt - FinalNewline:Reducer.kt$net.opendasharchive.openarchive.core.state.Reducer.kt - FinalNewline:RequestListener.kt$net.opendasharchive.openarchive.services.internetarchive.RequestListener.kt - FinalNewline:RequestNameDTO.kt$net.opendasharchive.openarchive.db.RequestNameDTO.kt - FinalNewline:RestEndpointTask.kt$net.opendasharchive.openarchive.features.main.RestEndpointTask.kt - FinalNewline:RetrofitAPI.kt$net.opendasharchive.openarchive.services.snowbird.service.RetrofitAPI.kt - FinalNewline:RetrofitClient.kt$net.opendasharchive.openarchive.services.snowbird.service.RetrofitClient.kt - FinalNewline:RetrofitModule.kt$net.opendasharchive.openarchive.core.di.RetrofitModule.kt - FinalNewline:RetryConfig.kt$net.opendasharchive.openarchive.services.snowbird.service.RetryConfig.kt - FinalNewline:SaveApp.kt$net.opendasharchive.openarchive.SaveApp.kt - FinalNewline:ScryptHashingStrategy.kt$net.opendasharchive.openarchive.features.settings.passcode.ScryptHashingStrategy.kt - FinalNewline:SectionViewHolder.kt$net.opendasharchive.openarchive.features.main.SectionViewHolder.kt - FinalNewline:SerializableMarker.kt$net.opendasharchive.openarchive.db.SerializableMarker.kt - FinalNewline:ServerOptionItem.kt$net.opendasharchive.openarchive.features.spaces.ServerOptionItem.kt - FinalNewline:SettingsFragment.kt$net.opendasharchive.openarchive.features.settings.SettingsFragment.kt - FinalNewline:SettingsScreen.kt$net.opendasharchive.openarchive.features.settings.SettingsScreen.kt - FinalNewline:Shape.kt$net.opendasharchive.openarchive.core.presentation.theme.Shape.kt - FinalNewline:SmartFragmentStatePagerAdapter.kt$net.opendasharchive.openarchive.util.SmartFragmentStatePagerAdapter.kt - FinalNewline:SnowbirdBridge.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdBridge.kt - FinalNewline:SnowbirdConduit.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdConduit.kt - FinalNewline:SnowbirdCreateGroupFragment.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdCreateGroupFragment.kt - FinalNewline:SnowbirdError.kt$net.opendasharchive.openarchive.db.SnowbirdError.kt - FinalNewline:SnowbirdFileListAdapter.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdFileListAdapter.kt - FinalNewline:SnowbirdFileListFragment.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdFileListFragment.kt - FinalNewline:SnowbirdFileRepository.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdFileRepository.kt - FinalNewline:SnowbirdFileViewModel.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdFileViewModel.kt - FinalNewline:SnowbirdFragment.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdFragment.kt - FinalNewline:SnowbirdGroup.kt$net.opendasharchive.openarchive.db.SnowbirdGroup.kt - FinalNewline:SnowbirdGroupListAdapter.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupListAdapter.kt - FinalNewline:SnowbirdGroupListFragment.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupListFragment.kt - FinalNewline:SnowbirdGroupOverviewFragment.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupOverviewFragment.kt - FinalNewline:SnowbirdGroupRepository.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupRepository.kt - FinalNewline:SnowbirdGroupViewModel.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupViewModel.kt - FinalNewline:SnowbirdJoinGroupFragment.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdJoinGroupFragment.kt - FinalNewline:SnowbirdRepo.kt$net.opendasharchive.openarchive.db.SnowbirdRepo.kt - FinalNewline:SnowbirdRepoListAdapter.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoListAdapter.kt - FinalNewline:SnowbirdRepoListFragment.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoListFragment.kt - FinalNewline:SnowbirdRepoRepository.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoRepository.kt - FinalNewline:SnowbirdRepoViewModel.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoViewModel.kt - FinalNewline:SnowbirdResult.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdResult.kt - FinalNewline:SnowbirdService.kt$net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService.kt - FinalNewline:SnowbirdServiceStatus.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdServiceStatus.kt - FinalNewline:SnowbirdShareFragment.kt$net.opendasharchive.openarchive.services.snowbird.SnowbirdShareFragment.kt - FinalNewline:Space.kt$net.opendasharchive.openarchive.db.Space.kt - FinalNewline:SpaceAdapter.kt$net.opendasharchive.openarchive.SpaceAdapter.kt - FinalNewline:SpaceDrawerAdapter.kt$net.opendasharchive.openarchive.features.main.adapters.SpaceDrawerAdapter.kt - FinalNewline:SpaceListFragment.kt$net.opendasharchive.openarchive.features.spaces.SpaceListFragment.kt - FinalNewline:SpaceListScreen.kt$net.opendasharchive.openarchive.features.spaces.SpaceListScreen.kt - FinalNewline:SpaceSetupFragment.kt$net.opendasharchive.openarchive.features.settings.SpaceSetupFragment.kt - FinalNewline:SpaceSetupSuccessFragment.kt$net.opendasharchive.openarchive.features.settings.SpaceSetupSuccessFragment.kt - FinalNewline:SpacingItemDecoration.kt$net.opendasharchive.openarchive.util.SpacingItemDecoration.kt - FinalNewline:Stateful.kt$net.opendasharchive.openarchive.core.state.Stateful.kt - FinalNewline:Store.kt$net.opendasharchive.openarchive.core.state.Store.kt - FinalNewline:StringExtensions.kt$net.opendasharchive.openarchive.extensions.StringExtensions.kt - FinalNewline:SuspendableExtensions.kt$net.opendasharchive.openarchive.extensions.SuspendableExtensions.kt - FinalNewline:SwipeToDeleteCallback.kt$net.opendasharchive.openarchive.upload.SwipeToDeleteCallback.kt - FinalNewline:TextView.kt$net.opendasharchive.openarchive.util.extensions.TextView.kt - FinalNewline:ThrowableExceptions.kt$net.opendasharchive.openarchive.extensions.ThrowableExceptions.kt - FinalNewline:ToolbarConfigurable.kt$net.opendasharchive.openarchive.features.core.ToolbarConfigurable.kt - FinalNewline:TorStatusContentProvider.kt$net.opendasharchive.openarchive.provider.TorStatusContentProvider.kt - FinalNewline:TorStatusDatabase.kt$net.opendasharchive.openarchive.provider.TorStatusDatabase.kt - FinalNewline:TwoLetterDrawable.kt$net.opendasharchive.openarchive.util.TwoLetterDrawable.kt - FinalNewline:UiImage.kt$net.opendasharchive.openarchive.features.core.UiImage.kt - FinalNewline:UiText.kt$net.opendasharchive.openarchive.features.core.UiText.kt - FinalNewline:UnitTests.kt$net.opendasharchive.openarchive.UnitTests.kt - FinalNewline:UnixSocketAPI.kt$net.opendasharchive.openarchive.services.snowbird.service.UnixSocketAPI.kt - FinalNewline:UnixSocketClient.kt$net.opendasharchive.openarchive.features.main.UnixSocketClient.kt - FinalNewline:UnixSocketClientFileExtensions.kt$net.opendasharchive.openarchive.features.main.UnixSocketClientFileExtensions.kt - FinalNewline:UnixSocketClientUtilityExtensions.kt$net.opendasharchive.openarchive.features.main.UnixSocketClientUtilityExtensions.kt - FinalNewline:UnixSocketModule.kt$net.opendasharchive.openarchive.core.di.UnixSocketModule.kt - FinalNewline:UploadManagerActivity.kt$net.opendasharchive.openarchive.upload.UploadManagerActivity.kt - FinalNewline:UploadManagerFragment.kt$net.opendasharchive.openarchive.upload.UploadManagerFragment.kt - FinalNewline:UploadService.kt$net.opendasharchive.openarchive.upload.UploadService.kt - FinalNewline:UriExtensions.kt$net.opendasharchive.openarchive.extensions.UriExtensions.kt - FinalNewline:Util.kt$net.opendasharchive.openarchive.services.internetarchive.Util.kt - FinalNewline:Utility.kt$net.opendasharchive.openarchive.util.Utility.kt - FinalNewline:ViewExtension.kt$net.opendasharchive.openarchive.extensions.ViewExtension.kt - FinalNewline:WebDAVModel.kt$net.opendasharchive.openarchive.db.WebDAVModel.kt - FinalNewline:WebDavConduit.kt$net.opendasharchive.openarchive.services.webdav.WebDavConduit.kt - FinalNewline:WebDavFragment.kt$net.opendasharchive.openarchive.services.webdav.WebDavFragment.kt - FinalNewline:WebDavSetupLicenseFragment.kt$net.opendasharchive.openarchive.services.webdav.WebDavSetupLicenseFragment.kt - ForbiddenComment:FeaturesModule.kt$// TODO: have some registry of feature modules - ForbiddenComment:FullscreenDimmingOverlay.kt$FullScreenCreateGroupDimmingOverlay$// TODO: Cancel the offending event - ForbiddenComment:FullscreenDimmingOverlay.kt$FullScreenDimmingOverlay$// TODO: Cancel the offending event - ForbiddenComment:HomeActivity.kt$HomeActivity$// TODO: Display a dialog or Snackbar explaining why notifications are needed. - ForbiddenComment:HomeActivity.kt$HomeActivity$// TODO: Extract path, query parameters, etc. - ForbiddenComment:HomeActivity.kt$HomeActivity$// TODO: Launch your preview activity or update the UI as needed. - ForbiddenComment:HomeActivity.kt$HomeActivity$// TODO: Refresh projects in MainViewModel - ForbiddenComment:HomeActivity.kt$HomeActivity$// TODO: Return your current project from a ViewModel or other state. - ForbiddenComment:HomeActivity.kt$HomeActivity$// TODO: Update your UI state, refresh fragment content, etc. - ForbiddenComment:HomeActivity.kt$HomeActivity$// TODO: Update your navigation or fragment state to display the selected folder. - ForbiddenComment:InternetArchiveLocalSource.kt$InternetArchiveLocalSource$// TODO: just use a memory cache for demo, will need to store in DB - ForbiddenComment:InternetArchiveLoginUseCase.kt$InternetArchiveLoginUseCase$// TODO: use local data source for database - ForbiddenComment:UploadManagerActivity.kt$UploadManagerActivity.<no name provided>$// // TODO: Record metadata. See iOS implementation. - ForbiddenPublicDataClass:ApiError.kt$ApiError$ClientError : ApiError - ForbiddenPublicDataClass:ApiError.kt$ApiError$HttpError : ApiError - ForbiddenPublicDataClass:ApiError.kt$ApiError$NetworkError : ApiError - ForbiddenPublicDataClass:ApiError.kt$ApiError$ServerError : ApiError - ForbiddenPublicDataClass:ApiError.kt$ApiError$UnexpectedError : ApiError - ForbiddenPublicDataClass:ApiResponse.kt$ApiResponse$ErrorResponse : ApiResponse - ForbiddenPublicDataClass:ApiResponse.kt$ApiResponse$ListResponse<T> : ApiResponse - ForbiddenPublicDataClass:ApiResponse.kt$ApiResponse$SingleResponse<T> : ApiResponse - ForbiddenPublicDataClass:AppConfig.kt$AppConfig - ForbiddenPublicDataClass:BackoffStrategy.kt$BackoffStrategy$Exponential : BackoffStrategy - ForbiddenPublicDataClass:BackoffStrategy.kt$BackoffStrategy$Linear : BackoffStrategy - ForbiddenPublicDataClass:BrowseFoldersViewModel.kt$Folder - ForbiddenPublicDataClass:Collection.kt$Collection : SugarRecord - ForbiddenPublicDataClass:Colors.kt$ColorTheme - ForbiddenPublicDataClass:DialogConfigBuilder.kt$ButtonData - ForbiddenPublicDataClass:DialogConfigBuilder.kt$DialogConfig - ForbiddenPublicDataClass:Dimensions.kt$DimensionsTheme - ForbiddenPublicDataClass:Dimensions.kt$Elevations - ForbiddenPublicDataClass:Dimensions.kt$Icons - ForbiddenPublicDataClass:Dimensions.kt$Spacing - ForbiddenPublicDataClass:FileUploadResult.kt$FileUploadResult : SerializableMarker - ForbiddenPublicDataClass:Hbks.kt$Hbks.Availability$Available : Availability - ForbiddenPublicDataClass:Hbks.kt$Hbks.Availability$Enroll : Availability - ForbiddenPublicDataClass:HomeScreen.kt$HomeScreenAction$AddMediaClicked : HomeScreenAction - ForbiddenPublicDataClass:HomeScreen.kt$HomeScreenAction$UpdateSelectedProject : HomeScreenAction - ForbiddenPublicDataClass:HomeScreen.kt$HomeScreenState - ForbiddenPublicDataClass:InternetArchive.kt$InternetArchive - ForbiddenPublicDataClass:InternetArchive.kt$InternetArchive$Auth - ForbiddenPublicDataClass:InternetArchive.kt$InternetArchive$MetaData - ForbiddenPublicDataClass:InternetArchiveDetailsState.kt$InternetArchiveDetailsState - ForbiddenPublicDataClass:InternetArchiveDetailsViewModel.kt$InternetArchiveDetailsViewModel.Action$Load : Action - ForbiddenPublicDataClass:InternetArchiveDetailsViewModel.kt$InternetArchiveDetailsViewModel.Action$Loaded : Action - ForbiddenPublicDataClass:InternetArchiveLoginRequest.kt$InternetArchiveLoginRequest - ForbiddenPublicDataClass:InternetArchiveLoginResponse.kt$InternetArchiveLoginResponse - ForbiddenPublicDataClass:InternetArchiveLoginResponse.kt$InternetArchiveLoginResponse$S3 - ForbiddenPublicDataClass:InternetArchiveLoginResponse.kt$InternetArchiveLoginResponse$Values - ForbiddenPublicDataClass:InternetArchiveLoginState.kt$InternetArchiveLoginAction$LoginError : InternetArchiveLoginAction - ForbiddenPublicDataClass:InternetArchiveLoginState.kt$InternetArchiveLoginAction$LoginSuccess : InternetArchiveLoginAction - ForbiddenPublicDataClass:InternetArchiveLoginState.kt$InternetArchiveLoginAction$UpdatePassword : InternetArchiveLoginAction - ForbiddenPublicDataClass:InternetArchiveLoginState.kt$InternetArchiveLoginAction$UpdateUsername : InternetArchiveLoginAction - ForbiddenPublicDataClass:InternetArchiveLoginState.kt$InternetArchiveLoginState - ForbiddenPublicDataClass:JoinGroupResponse.kt$JoinGroupResponse : SerializableMarker - ForbiddenPublicDataClass:MainMediaScreen.kt$CollectionSection - ForbiddenPublicDataClass:MainViewModel.kt$MainUiState - ForbiddenPublicDataClass:Media.kt$Media : SugarRecord - ForbiddenPublicDataClass:MediaCacheScreen.kt$MediaFile - ForbiddenPublicDataClass:MediaLaunchers.kt$MediaLaunchers - ForbiddenPublicDataClass:PasscodeEntryViewModel.kt$PasscodeEntryScreenAction$OnNumberClick : PasscodeEntryScreenAction - ForbiddenPublicDataClass:PasscodeEntryViewModel.kt$PasscodeEntryScreenState - ForbiddenPublicDataClass:PasscodeEntryViewModel.kt$PasscodeEntryUiEvent$IncorrectPasscode : PasscodeEntryUiEvent - ForbiddenPublicDataClass:PasscodeSetupViewModel.kt$PasscodeSetupUiAction$OnNumberClick : PasscodeSetupUiAction - ForbiddenPublicDataClass:PasscodeSetupViewModel.kt$PasscodeSetupUiState - ForbiddenPublicDataClass:Project.kt$Project : SugarRecord - ForbiddenPublicDataClass:RequestNameDTO.kt$MembershipRequest : SerializableMarker - ForbiddenPublicDataClass:RequestNameDTO.kt$RequestName : SerializableMarker - ForbiddenPublicDataClass:RetryConfig.kt$RetryConfig - ForbiddenPublicDataClass:SectionViewHolder.kt$SectionViewHolder - ForbiddenPublicDataClass:SnowbirdError.kt$SnowbirdError$GeneralError : SnowbirdError - ForbiddenPublicDataClass:SnowbirdError.kt$SnowbirdError$NetworkError : SnowbirdError - ForbiddenPublicDataClass:SnowbirdFileItem.kt$SnowbirdFileItem : SugarRecordSerializableMarker - ForbiddenPublicDataClass:SnowbirdFileItem.kt$SnowbirdFileList : SerializableMarker - ForbiddenPublicDataClass:SnowbirdFileViewModel.kt$SnowbirdFileViewModel.State$DownloadSuccess : State - ForbiddenPublicDataClass:SnowbirdFileViewModel.kt$SnowbirdFileViewModel.State$Error : State - ForbiddenPublicDataClass:SnowbirdFileViewModel.kt$SnowbirdFileViewModel.State$FetchSuccess : State - ForbiddenPublicDataClass:SnowbirdFileViewModel.kt$SnowbirdFileViewModel.State$UploadSuccess : State - ForbiddenPublicDataClass:SnowbirdGroup.kt$SnowbirdGroup : SugarRecordSerializableMarker - ForbiddenPublicDataClass:SnowbirdGroup.kt$SnowbirdGroupList : SerializableMarker - ForbiddenPublicDataClass:SnowbirdGroupViewModel.kt$SnowbirdGroupViewModel.GroupState$Error : GroupState - ForbiddenPublicDataClass:SnowbirdGroupViewModel.kt$SnowbirdGroupViewModel.GroupState$JoinGroupSuccess : GroupState - ForbiddenPublicDataClass:SnowbirdGroupViewModel.kt$SnowbirdGroupViewModel.GroupState$MultiGroupSuccess : GroupState - ForbiddenPublicDataClass:SnowbirdGroupViewModel.kt$SnowbirdGroupViewModel.GroupState$SingleGroupSuccess : GroupState - ForbiddenPublicDataClass:SnowbirdRepo.kt$SnowbirdRepo : SugarRecordSerializableMarker - ForbiddenPublicDataClass:SnowbirdRepo.kt$SnowbirdRepoList : SerializableMarker - ForbiddenPublicDataClass:SnowbirdRepoViewModel.kt$SnowbirdRepoViewModel.RepoState$Error : RepoState - ForbiddenPublicDataClass:SnowbirdRepoViewModel.kt$SnowbirdRepoViewModel.RepoState$MultiRepoSuccess : RepoState - ForbiddenPublicDataClass:SnowbirdRepoViewModel.kt$SnowbirdRepoViewModel.RepoState$RepoFetchSuccess : RepoState - ForbiddenPublicDataClass:SnowbirdRepoViewModel.kt$SnowbirdRepoViewModel.RepoState$SingleRepoSuccess : RepoState - ForbiddenPublicDataClass:SnowbirdResult.kt$SnowbirdResult$Error : SnowbirdResult - ForbiddenPublicDataClass:SnowbirdResult.kt$SnowbirdResult$Success<out T> : SnowbirdResult - ForbiddenPublicDataClass:SnowbirdService.kt$ServiceStatus$Failed : ServiceStatus - ForbiddenPublicDataClass:SnowbirdServiceStatus.kt$SnowbirdServiceStatus$Error : SnowbirdServiceStatus - ForbiddenPublicDataClass:Space.kt$Space : SugarRecord - ForbiddenPublicDataClass:SpaceDrawerAdapter.kt$SpaceDrawerAdapter.SpaceItem$SpaceItemData : SpaceItem - ForbiddenPublicDataClass:SuspendableExtensions.kt$RetryAttempt$Failure : RetryAttempt - ForbiddenPublicDataClass:SuspendableExtensions.kt$RetryAttempt$Retry : RetryAttempt - ForbiddenPublicDataClass:SuspendableExtensions.kt$RetryAttempt$Success<T> : RetryAttemptRetryResult - ForbiddenPublicDataClass:UiImage.kt$UiImage$DrawableResource : UiImage - ForbiddenPublicDataClass:UiImage.kt$UiImage$DynamicVector : UiImage - ForbiddenPublicDataClass:UiText.kt$UiText$DynamicString : UiText - ForbiddenPublicDataClass:UiText.kt$UiText$StringResource : UiText - ForbiddenPublicDataClass:WebDAVModel.kt$BackendCapabilities - ForbiddenPublicDataClass:WebDAVModel.kt$Data - ForbiddenPublicDataClass:WebDAVModel.kt$Meta - ForbiddenPublicDataClass:WebDAVModel.kt$Ocs - ForbiddenPublicDataClass:WebDAVModel.kt$Quota - ForbiddenPublicDataClass:WebDAVModel.kt$WebDAVModel - FunctionNaming:Accordion.kt$@Composable fun Accordion( modifier: Modifier = Modifier, headerModifier: Modifier = Modifier, state: AccordionState = rememberAccordionState(), animate: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, headerContent: @Composable () -> Unit, bodyContent: @Composable () -> Unit, ) - FunctionNaming:AddFolderScreen.kt$@Composable fun AddFolderScreen() - FunctionNaming:AddFolderScreen.kt$@Composable fun AddFolderScreenContent( onCreateFolder: () -> Unit, onBrowseFolders: () -> Unit ) - FunctionNaming:AddFolderScreen.kt$@Composable fun FolderOption(iconRes: Int, text: String, onClick: () -> Unit) - FunctionNaming:AddFolderScreen.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun AddFolderScreenPreview() - FunctionNaming:BaseButton.kt$@Composable fun BaseButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, backgroundColor: Color = MaterialTheme.colorScheme.primary, textColor: Color = MaterialTheme.colorScheme.onPrimary, cornerRadius: Dp = 12.dp, ) - FunctionNaming:BaseButton.kt$@Composable fun BaseDestructiveButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, borderColor: Color = MaterialTheme.colorScheme.error, textColor: Color = MaterialTheme.colorScheme.error, cornerRadius: Dp = 12.dp, ) - FunctionNaming:BaseButton.kt$@Composable fun BaseNeutralButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, textColor: Color = MaterialTheme.colorScheme.onPrimary, ) - FunctionNaming:BaseButton.kt$@Composable fun ButtonText( text: String, modifier: Modifier = Modifier, fontSize: TextUnit = 16.sp, fontWeight: FontWeight = FontWeight.SemiBold, color: Color = MaterialTheme.colorScheme.onPrimary ) - FunctionNaming:BaseButton.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CustomButtonPreview() - FunctionNaming:BaseButton.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CustomDestructiveButtonPreview() - FunctionNaming:BaseButton.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CustomNeutralButtonPreview() - FunctionNaming:BaseDialog.kt$@Composable fun BaseDialog( onDismiss: () -> Unit, icon: UiImage? = null, iconColor: Color? = null, title: String, message: String, hasCheckbox: Boolean = false, onCheckBoxStateChanged: (Boolean) -> Unit = {}, checkBoxHint: String = "Do not show me this again", positiveButton: ButtonData? = null, neutralButton: ButtonData? = null, destructiveButton: ButtonData? = null, backgroundColor: Color = MaterialTheme.colorScheme.surface ) - FunctionNaming:BaseDialog.kt$@Composable fun BaseDialogMessage( text: String, modifier: Modifier = Modifier ) - FunctionNaming:BaseDialog.kt$@Composable fun BaseDialogTitle( text: String, modifier: Modifier = Modifier ) - FunctionNaming:BaseDialog.kt$@Composable fun DialogHost(dialogStateManager: DialogStateManager) - FunctionNaming:BaseDialog.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun BaseDialogPreview() - FunctionNaming:BaseDialog.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun ErrorDialogPreview() - FunctionNaming:BaseDialog.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun WarningDialogPreview() - FunctionNaming:BrowseFolderScreen.kt$@Composable fun BrowseFolderItem( folder: Folder, onClick: () -> Unit ) - FunctionNaming:BrowseFolderScreen.kt$@Composable fun BrowseFolderScreen( viewModel: BrowseFoldersViewModel = koinViewModel() ) - FunctionNaming:BrowseFolderScreen.kt$@Composable fun BrowseFolderScreenContent( folders: List<Folder> ) - FunctionNaming:BrowseFolderScreen.kt$@Preview @Composable private fun BrowseFolderScreenPreview() - FunctionNaming:DefaultScaffold.kt$@Composable fun DefaultScaffold( modifier: Modifier = Modifier, topAppBar: (@Composable () -> Unit)? = null, content: @Composable () -> Unit ) - FunctionNaming:ExpandableSpaceList.kt$@Composable fun DrawerSpaceListItem( space: Space, ) - FunctionNaming:ExpandableSpaceList.kt$@Composable fun ExpandableSpaceList( serverAccordionState: AccordionState, selectedSpace: Space? = null, spaceList: List<Space> ) - FunctionNaming:ExpandableSpaceList.kt$@Composable fun SpaceIcon( type: Space.Type, modifier: Modifier = Modifier, tint: Color? = null ) - FunctionNaming:ExpandableSpaceList.kt$@Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun ExpandableSpaceListPreview() - FunctionNaming:FolderOptionsPopup.kt$@Composable fun FolderOptionsPopup( expanded: Boolean = false, onDismissRequest: () -> Unit, onRenameFolder: () -> Unit, onSelectMedia: () -> Unit, onRemoveFolder: () -> Unit ) - FunctionNaming:FolderOptionsPopup.kt$@Preview @Composable private fun FolderOptionsPopupPreview() - FunctionNaming:HomeAppBar.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeAppBar( openDrawer: () -> Unit, onExit: () -> Unit ) - FunctionNaming:HomeScreen.kt$@Composable fun HomeScreen( viewModel: HomeViewModel = koinViewModel(), onExit: () -> Unit, onNewFolder: () -> Unit, onFolderSelected: (Long) -> Unit, onAddMedia: (AddMediaType) -> Unit, onNavigateToCache: () -> Unit ) - FunctionNaming:HomeScreen.kt$@Composable fun HomeScreenContent( onExit: () -> Unit, state: HomeScreenState, onAction: (HomeScreenAction) -> Unit, onNavigateToCache: () -> Unit = {} ) - FunctionNaming:HomeScreen.kt$@Composable fun SaveNavGraph( context: Context, viewModel: HomeViewModel = koinViewModel(), onExit: () -> Unit, onNewFolder: () -> Unit, onFolderSelected: (Long) -> Unit, onAddMedia: (AddMediaType) -> Unit ) - FunctionNaming:HomeScreen.kt$@Preview @Composable private fun MainContentPreview() - FunctionNaming:InternetArchiveDetailsScreen.kt$@Composable @Preview(showBackground = true) @Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) private fun InternetArchiveScreenPreview() - FunctionNaming:InternetArchiveDetailsScreen.kt$@Composable fun InternetArchiveDetailsScreen(space: Space, onResult: (IAResult) -> Unit) - FunctionNaming:InternetArchiveDetailsScreen.kt$@Composable private fun InternetArchiveDetailsContent( state: InternetArchiveDetailsState, dispatch: Dispatch<Action>, dialogManager: DialogStateManager = koinViewModel() ) - FunctionNaming:InternetArchiveHeader.kt$@Composable @Preview(showBackground = true) @Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) private fun InternetArchiveHeaderPreview() - FunctionNaming:InternetArchiveHeader.kt$@Composable fun InternetArchiveHeader(modifier: Modifier = Modifier, titleSize: TextUnit = 18.sp) - FunctionNaming:InternetArchiveLoginScreen.kt$@Composable @Preview @Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) private fun InternetArchiveLoginPreview() - FunctionNaming:InternetArchiveLoginScreen.kt$@Composable fun CustomSecureField( modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit, label: String, placeholder: String, isError: Boolean = false, isLoading: Boolean = false, keyboardType: KeyboardType, imeAction: ImeAction, ) - FunctionNaming:InternetArchiveLoginScreen.kt$@Composable fun CustomTextField( modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit, label: String, enabled: Boolean = true, placeholder: String? = null, isError: Boolean = false, isLoading: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, imeAction: ImeAction = ImeAction.Next, ) - FunctionNaming:InternetArchiveLoginScreen.kt$@Composable fun InternetArchiveLoginScreen(space: Space, onResult: (IAResult) -> Unit) - FunctionNaming:InternetArchiveLoginScreen.kt$@Composable private fun InternetArchiveLoginContent( state: InternetArchiveLoginState, dispatch: Dispatch<Action> ) - FunctionNaming:InternetArchiveLoginScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ComposeAppBar( title: String = "Save App", onNavigationAction: () -> Unit = {} ) - FunctionNaming:InternetArchiveScreen.kt$@Composable fun InternetArchiveScreen(space: Space, isNewSpace: Boolean, onFinish: (IAResult) -> Unit) - FunctionNaming:MainBottomBar.kt$@Composable fun MainBottomBar( isSettings: Boolean, onMyMediaClick: () -> Unit, onSettingsClick: () -> Unit, onAddMediaClick: () -> Unit ) - FunctionNaming:MainBottomBar.kt$@Composable fun RowScope.BottomNavMenuItem( selectedIcon: ImageVector, unSelectedIcon: ImageVector, isSelected: Boolean, text: String, onClick: () -> Unit ) - FunctionNaming:MainDrawerContent.kt$@Composable fun MainDrawerContent( selectedSpace: Space? = null, spaceList: List<Space> = emptyList() ) - FunctionNaming:MainDrawerContent.kt$@Composable fun MainDrawerFolderListItem( project: Project, isSelected: Boolean = false, onSelected: () -> Unit ) - FunctionNaming:MainDrawerContent.kt$@Preview @Composable private fun MainDrawerContentPreview() - FunctionNaming:MainMediaScreen.kt$@Composable fun CollectionHeaderView(section: CollectionSection) - FunctionNaming:MainMediaScreen.kt$@Composable fun CollectionSectionView( section: CollectionSection, onMediaClick: (Media) -> Unit, onMediaLongPress: (Media) -> Unit ) - FunctionNaming:MainMediaScreen.kt$@Composable fun ErrorIndicator() - FunctionNaming:MainMediaScreen.kt$@Composable fun MainMediaScreen( projectId: Long, ) - FunctionNaming:MainMediaScreen.kt$@Composable fun MediaItemView( media: Media, isSelected: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier ) - FunctionNaming:MainMediaScreen.kt$@Composable fun UploadProgress(progress: Int) - FunctionNaming:MainMediaScreen.kt$@Composable fun WelcomeMessage() - FunctionNaming:MediaCacheScreen.kt$@Composable fun CacheFileItem(file: MediaFile) - FunctionNaming:MediaCacheScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaCacheScreen(context: Context, onNavigateBack: () -> Unit) - FunctionNaming:NumericKeypad.kt$@Composable fun NumericKeypad( isEnabled: Boolean = true, onNumberClick: (String) -> Unit, onDeleteClick: () -> Unit, onSubmitClick: () -> Unit ) - FunctionNaming:NumericKeypad.kt$@Composable private fun NumberButton( label: String, enabled: Boolean = true, onClick: () -> Unit, hapticManager: HapticManager = koinInject() ) - FunctionNaming:NumericKeypad.kt$@Preview @Composable private fun NumericKeypadPreview() - FunctionNaming:PasscodeDots.kt$@Composable fun PasscodeDots( passcodeLength: Int, currentPasscodeLength: Int, shouldShake: Boolean = false ) - FunctionNaming:PasscodeDots.kt$@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview @Composable private fun PasswordDotsPreview() - FunctionNaming:PasscodeEntryScreen.kt$@Composable fun PasscodeEntryScreen( onPasscodeSuccess: () -> Unit, onExit: () -> Unit, viewModel: PasscodeEntryViewModel = koinViewModel(), hapticManager: HapticManager = koinInject() ) - FunctionNaming:PasscodeEntryScreen.kt$@Composable fun PasscodeEntryScreenContent( state: PasscodeEntryScreenState, onAction: (PasscodeEntryScreenAction) -> Unit, onExit: () -> Unit, ) - FunctionNaming:PasscodeEntryScreen.kt$@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview @Composable private fun PasscodeEntryScreenPreview() - FunctionNaming:PasscodeSetupScreen.kt$@Composable fun PasscodeSetupScreen( onPasscodeSet: () -> Unit, onCancel: () -> Unit, viewModel: PasscodeSetupViewModel = koinViewModel(), hapticManager: HapticManager = koinInject() ) - FunctionNaming:PasscodeSetupScreen.kt$@Composable private fun PasscodeSetupScreenContent( state: PasscodeSetupUiState, onAction: (PasscodeSetupUiAction) -> Unit ) - FunctionNaming:PasscodeSetupScreen.kt$@Preview(uiMode = UI_MODE_NIGHT_YES) @Preview @Composable private fun PasscodeSetupScreenPreview() - FunctionNaming:Preview.kt$@Composable fun DefaultBoxPreview( content: @Composable () -> Unit ) - FunctionNaming:Preview.kt$@Composable fun DefaultEmptyScaffoldPreview( content: @Composable () -> Unit ) - FunctionNaming:Preview.kt$@Composable fun DefaultScaffoldPreview( content: @Composable () -> Unit ) - FunctionNaming:PrimaryButton.kt$@Composable fun PrimaryButton( modifier: Modifier = Modifier, icon: ImageVector? = null, text: String, onClick: () -> Unit ) - FunctionNaming:PrimaryButton.kt$@Preview @Composable private fun PrimaryButtonPreview() - FunctionNaming:ProofModeScreen.kt$@Composable fun ProofModeScreen( onNavigateBack: () -> Unit ) - FunctionNaming:ProofModeScreen.kt$@Composable fun ProofModeScreenContent() - FunctionNaming:ProofModeScreen.kt$@Preview @Composable private fun ProofModeScreenPreview() - FunctionNaming:ServerOptionItem.kt$@Composable fun ServerOptionItem( @DrawableRes iconRes: Int, title: String, subtitle: String, onClick: () -> Unit ) - FunctionNaming:ServerOptionItem.kt$@Preview @Composable private fun ServerOptionItemPreview() - FunctionNaming:SettingsScreen.kt$@Composable fun SettingsScreen( onNavigateToCache: () -> Unit = {} ) - FunctionNaming:SettingsScreen.kt$@Preview @Composable private fun SettingsScreenPreview() - FunctionNaming:SpaceListScreen.kt$@Composable fun SpaceListItem( space: Space, onClick: () -> Unit ) - FunctionNaming:SpaceListScreen.kt$@Composable fun SpaceListScreen( onSpaceClicked: (Space) -> Unit, ) - FunctionNaming:SpaceListScreen.kt$@Composable fun SpaceListScreenContent( onSpaceClicked: (Space) -> Unit, spaceList: List<Space> = emptyList() ) - FunctionNaming:SpaceListScreen.kt$@Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun SpaceListScreenPreview() - FunctionNaming:SpaceSetupScreen.kt$@Composable fun SpaceSetupScreen( onWebDavClick: () -> Unit, isInternetArchiveAllowed: Boolean, onInternetArchiveClick: () -> Unit, isDwebEnabled: Boolean, onDwebClicked: () -> Unit ) - FunctionNaming:SpaceSetupScreen.kt$@Preview @Composable private fun SpaceSetupScreenPreview() - FunctionNaming:Theme.kt$@Composable fun SaveAppTheme( content: @Composable () -> Unit ) - FunctionNaming:TwoLetterDrawable.kt$TwoLetterDrawable.Companion$fun ReadOnly(context: Context) - FunctionNaming:TwoLetterDrawable.kt$TwoLetterDrawable.Companion$fun ReadWrite(context: Context) - FunctionOnlyReturningConstant:HomeActivity.kt$HomeActivity$private fun getCurrentProject(): Project? - FunctionStartOfBodySpacing:InternetArchiveLocalSource.kt$InternetArchiveLocalSource$fun set(value: InternetArchive) - ImportOrdering:ApplicationExtensions.kt$import android.app.Application import androidx.activity.ComponentActivity import androidx.fragment.app.Fragment import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import org.koin.android.ext.android.getKoin import org.koin.core.parameter.parametersOf import org.koin.androidx.viewmodel.ext.android.viewModel - ImportOrdering:FeaturesModule.kt$import android.app.Application import android.content.Context import net.opendasharchive.openarchive.features.internetarchive.internetArchiveModule import net.opendasharchive.openarchive.features.settings.passcode.AppConfig import net.opendasharchive.openarchive.features.settings.passcode.HapticManager import net.opendasharchive.openarchive.features.settings.passcode.HashingStrategy import net.opendasharchive.openarchive.features.settings.passcode.PBKDF2HashingStrategy import net.opendasharchive.openarchive.features.settings.passcode.passcode_entry.PasscodeEntryViewModel import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository import net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupViewModel import net.opendasharchive.openarchive.services.snowbird.ISnowbirdFileRepository import net.opendasharchive.openarchive.services.snowbird.ISnowbirdGroupRepository import net.opendasharchive.openarchive.services.snowbird.ISnowbirdRepoRepository import net.opendasharchive.openarchive.services.snowbird.SnowbirdFileRepository import net.opendasharchive.openarchive.services.snowbird.SnowbirdFileViewModel import net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupRepository import net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupViewModel import net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoRepository import net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoViewModel import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module - ImportOrdering:InternetArchiveDetailsScreen.kt$import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions import net.opendasharchive.openarchive.core.state.Dispatch import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult import net.opendasharchive.openarchive.features.internetarchive.presentation.components.InternetArchiveHeader import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsViewModel.Action import net.opendasharchive.openarchive.features.internetarchive.presentation.login.CustomTextField import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview import net.opendasharchive.openarchive.features.core.UiImage import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager import net.opendasharchive.openarchive.features.core.dialog.showDialog import net.opendasharchive.openarchive.features.core.dialog.showSuccessDialog import net.opendasharchive.openarchive.features.core.dialog.showWarningDialog import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf - ImportOrdering:InternetArchiveFragment.kt$import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.navigation.fragment.findNavController import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult import net.opendasharchive.openarchive.features.internetarchive.presentation.components.bundleWithNewSpace import net.opendasharchive.openarchive.features.internetarchive.presentation.components.bundleWithSpaceId import net.opendasharchive.openarchive.features.internetarchive.presentation.components.getSpace import net.opendasharchive.openarchive.features.core.BaseFragment import net.opendasharchive.openarchive.features.core.ToolbarConfigurable - ImportOrdering:SnowbirdFragment.kt$import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.google.zxing.integration.android.IntentIntegrator import kotlinx.coroutines.launch import net.opendasharchive.openarchive.databinding.FragmentSnowbirdBinding import net.opendasharchive.openarchive.db.SnowbirdGroup import net.opendasharchive.openarchive.extensions.getQueryParameter import net.opendasharchive.openarchive.features.main.QRScannerActivity import net.opendasharchive.openarchive.features.core.BaseFragment import net.opendasharchive.openarchive.util.Utility import timber.log.Timber - ImportOrdering:StatefulViewModel.kt$import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import net.opendasharchive.openarchive.core.state.StateDispatcher import net.opendasharchive.openarchive.core.state.StoreObserver import net.opendasharchive.openarchive.core.state.Stateful import net.opendasharchive.openarchive.core.state.Store - ImportOrdering:VideoRequestHandler.kt$import android.content.Context import android.graphics.Bitmap import com.squareup.picasso.Picasso import android.media.MediaMetadataRetriever import android.net.Uri import com.squareup.picasso.Request import com.squareup.picasso.RequestHandler import java.io.IOException import java.lang.Exception import androidx.core.net.toUri - Indentation:Accordion.kt$ - Indentation:BaseButton.kt$ - Indentation:BaseDialog.kt$ - Indentation:BrowseFoldersFragment.kt$BrowseFoldersFragment$ - Indentation:CleanInsightsManager.kt$CleanInsightsManager.<no name provided>$ - Indentation:EditFolderActivity.kt$EditFolderActivity$ - Indentation:FileUtils.kt$FileUtils$ - Indentation:GDriveActivity.kt$GDriveActivity$ - Indentation:GDriveFragment.kt$GDriveFragment$ - Indentation:Hbks.kt$Hbks.<no name provided>$ - Indentation:HomeScreen.kt$ - Indentation:InternetArchiveActivity.kt$InternetArchiveActivity$ - Indentation:InternetArchiveDetailsViewModel.kt$InternetArchiveDetailsViewModel$ - Indentation:InternetArchiveHeader.kt$ - Indentation:MainMediaAdapter.kt$MediaDiffCallback$ - Indentation:Media.kt$Media$ - Indentation:MediaAdapter.kt$MediaAdapter$ - Indentation:MediaAdapter.kt$MediaDiffCallback$ - Indentation:MediaCacheScreen.kt$ - Indentation:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity.<no name provided>$ - Indentation:PasscodeManager.kt$PasscodeManager$ - Indentation:PasscodeSetupActivity.kt$PasscodeSetupActivity$ - Indentation:Picker.kt$Picker$ - Indentation:PreviewAdapter.kt$PreviewAdapter.Companion.<no name provided>$ - Indentation:Project.kt$Project$ - Indentation:ProofModeScreen.kt$ - Indentation:RequestBodyUtil.kt$RequestBodyUtil$ - Indentation:RequestBodyUtil.kt$RequestBodyUtil.<no name provided>$ - Indentation:SnowbirdFileListFragment.kt$SnowbirdFileListFragment$ - Indentation:SnowbirdFileListFragment.kt$SnowbirdFileListFragment.<no name provided>$ - Indentation:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment.<no name provided>$ - Indentation:SnowbirdJoinGroupFragment.kt$SnowbirdJoinGroupFragment$ - Indentation:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment.<no name provided>$ - Indentation:SpaceAdapter.kt$SpaceAdapter$ - Indentation:SpaceListScreen.kt$ - Indentation:TextView.kt$ - Indentation:UnixSocketAPI.kt$UnixSocketAPI$ - Indentation:UriExtensions.kt$ - Indentation:ValidateLoginCredentialsUseCase.kt$ValidateLoginCredentialsUseCase$ - Indentation:WebDavFragment.kt$WebDavFragment$ - LambdaParameterEventTrailing:PrimaryButton.kt$onClick - LambdaParameterInRestartableEffect:HomeScreen.kt$onAction - LambdaParameterInRestartableEffect:InternetArchiveDetailsScreen.kt$onResult - LambdaParameterInRestartableEffect:InternetArchiveLoginScreen.kt$onResult - LambdaParameterInRestartableEffect:PasscodeEntryScreen.kt$onExit - LambdaParameterInRestartableEffect:PasscodeEntryScreen.kt$onPasscodeSuccess - LambdaParameterInRestartableEffect:PasscodeSetupScreen.kt$onCancel - LambdaParameterInRestartableEffect:PasscodeSetupScreen.kt$onPasscodeSet - LibraryEntitiesShouldNotBePublic:Accordion.kt$@Composable fun Accordion( modifier: Modifier = Modifier, headerModifier: Modifier = Modifier, state: AccordionState = rememberAccordionState(), animate: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, headerContent: @Composable () -> Unit, bodyContent: @Composable () -> Unit, ) - LibraryEntitiesShouldNotBePublic:Accordion.kt$@Composable fun rememberAccordionGroupState( count: Int, allowMultipleOpen: Boolean = false, ): AccordionGroupState - LibraryEntitiesShouldNotBePublic:Accordion.kt$@Composable fun rememberAccordionState( expanded: Boolean = false, enabled: Boolean = true, clickable: Boolean = true, onExpandedChange: ((Boolean) -> Unit)? = null, ) - LibraryEntitiesShouldNotBePublic:Accordion.kt$AccordionGroupState - LibraryEntitiesShouldNotBePublic:Accordion.kt$AccordionState - LibraryEntitiesShouldNotBePublic:ActivityExtension.kt$fun Activity.onBackButtonPressed(callback: () -> Boolean) - LibraryEntitiesShouldNotBePublic:AddFolderActivity.kt$AddFolderActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:AddFolderScreen.kt$@Composable fun AddFolderScreen() - LibraryEntitiesShouldNotBePublic:AddFolderScreen.kt$@Composable fun AddFolderScreenContent( onCreateFolder: () -> Unit, onBrowseFolders: () -> Unit ) - LibraryEntitiesShouldNotBePublic:AddFolderScreen.kt$@Composable fun FolderOption(iconRes: Int, text: String, onClick: () -> Unit) - LibraryEntitiesShouldNotBePublic:AddMediaDialogFragment.kt$AddMediaDialogFragment : DialogFragment - LibraryEntitiesShouldNotBePublic:AddMediaType.kt$AddMediaType - LibraryEntitiesShouldNotBePublic:AlertHelper.kt$AlertHelper - LibraryEntitiesShouldNotBePublic:ApiError.kt$ApiError : SerializableMarker - LibraryEntitiesShouldNotBePublic:ApiResponse.kt$ApiResponse<out T> - LibraryEntitiesShouldNotBePublic:AppConfig.kt$AppConfig - LibraryEntitiesShouldNotBePublic:ApplicationExtensions.kt$inline fun <reified T : AndroidViewModel> ComponentActivity.androidViewModel(): Lazy<T> - LibraryEntitiesShouldNotBePublic:ApplicationExtensions.kt$inline fun <reified T : AndroidViewModel> Fragment.androidViewModel(): Lazy<T> - LibraryEntitiesShouldNotBePublic:ApplicationExtensions.kt$inline fun <reified T : ViewModel> Application.getViewModel(vararg parameters: Any): T - LibraryEntitiesShouldNotBePublic:BackoffStrategy.kt$BackoffStrategy - LibraryEntitiesShouldNotBePublic:BadgeDrawable.kt$BadgeDrawable : Drawable - LibraryEntitiesShouldNotBePublic:BaseActivity.kt$BaseActivity : AppCompatActivity - LibraryEntitiesShouldNotBePublic:BaseButton.kt$@Composable fun BaseButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, backgroundColor: Color = MaterialTheme.colorScheme.primary, textColor: Color = MaterialTheme.colorScheme.onPrimary, cornerRadius: Dp = 12.dp, ) - LibraryEntitiesShouldNotBePublic:BaseButton.kt$@Composable fun BaseDestructiveButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, borderColor: Color = MaterialTheme.colorScheme.error, textColor: Color = MaterialTheme.colorScheme.error, cornerRadius: Dp = 12.dp, ) - LibraryEntitiesShouldNotBePublic:BaseButton.kt$@Composable fun BaseNeutralButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, textColor: Color = MaterialTheme.colorScheme.onPrimary, ) - LibraryEntitiesShouldNotBePublic:BaseButton.kt$@Composable fun ButtonText( text: String, modifier: Modifier = Modifier, fontSize: TextUnit = 16.sp, fontWeight: FontWeight = FontWeight.SemiBold, color: Color = MaterialTheme.colorScheme.onPrimary ) - LibraryEntitiesShouldNotBePublic:BaseComposeActivity.kt$BaseComposeActivity : AppCompatActivity - LibraryEntitiesShouldNotBePublic:BaseDialog.kt$@Composable fun BaseDialog( onDismiss: () -> Unit, icon: UiImage? = null, iconColor: Color? = null, title: String, message: String, hasCheckbox: Boolean = false, onCheckBoxStateChanged: (Boolean) -> Unit = {}, checkBoxHint: String = "Do not show me this again", positiveButton: ButtonData? = null, neutralButton: ButtonData? = null, destructiveButton: ButtonData? = null, backgroundColor: Color = MaterialTheme.colorScheme.surface ) - LibraryEntitiesShouldNotBePublic:BaseDialog.kt$@Composable fun BaseDialogMessage( text: String, modifier: Modifier = Modifier ) - LibraryEntitiesShouldNotBePublic:BaseDialog.kt$@Composable fun BaseDialogTitle( text: String, modifier: Modifier = Modifier ) - LibraryEntitiesShouldNotBePublic:BaseDialog.kt$@Composable fun DialogHost(dialogStateManager: DialogStateManager) - LibraryEntitiesShouldNotBePublic:BaseDialog.kt$DialogStateManager : ViewModel - LibraryEntitiesShouldNotBePublic:BaseFragment.kt$BaseFragment : FragmentToolbarConfigurable - LibraryEntitiesShouldNotBePublic:BaseSnowbirdFragment.kt$BaseSnowbirdFragment : Fragment - LibraryEntitiesShouldNotBePublic:BaseViewModel.kt$BaseViewModel : AndroidViewModel - LibraryEntitiesShouldNotBePublic:BasicAuthInterceptor.kt$BasicAuthInterceptor : Interceptor - LibraryEntitiesShouldNotBePublic:BiometricAuthenticator.kt$BiometricAuthenticator - LibraryEntitiesShouldNotBePublic:BottomSheetExtensions.kt$fun Fragment.showBottomSheetDialog( @LayoutRes layout: Int, @IdRes textViewToSet: Int? = null, textToSet: String? = null, fullScreen: Boolean = true, expand: Boolean = true ) - LibraryEntitiesShouldNotBePublic:BroadcastManager.kt$BroadcastManager$Action - LibraryEntitiesShouldNotBePublic:BrowseFolderScreen.kt$@Composable fun BrowseFolderItem( folder: Folder, onClick: () -> Unit ) - LibraryEntitiesShouldNotBePublic:BrowseFolderScreen.kt$@Composable fun BrowseFolderScreen( viewModel: BrowseFoldersViewModel = koinViewModel() ) - LibraryEntitiesShouldNotBePublic:BrowseFolderScreen.kt$@Composable fun BrowseFolderScreenContent( folders: List<Folder> ) - LibraryEntitiesShouldNotBePublic:BrowseFoldersAdapter.kt$BrowseFoldersAdapter : Adapter - LibraryEntitiesShouldNotBePublic:BrowseFoldersFragment.kt$BrowseFoldersFragment : BaseFragmentMenuProvider - LibraryEntitiesShouldNotBePublic:BrowseFoldersViewModel.kt$BrowseFoldersViewModel : ViewModel - LibraryEntitiesShouldNotBePublic:BrowseFoldersViewModel.kt$Folder - LibraryEntitiesShouldNotBePublic:BundleExt.kt$@Deprecated("only for use with fragments and activities") fun Bundle?.getSpace(type: Space.Type): Pair<Space, Boolean> - LibraryEntitiesShouldNotBePublic:BundleExt.kt$@Deprecated("only for use with fragments and activities") fun bundleWithNewSpace() - LibraryEntitiesShouldNotBePublic:BundleExt.kt$@Deprecated("only for use with fragments and activities") fun bundleWithSpaceId(spaceId: Long) - LibraryEntitiesShouldNotBePublic:BundleExt.kt$IAResult - LibraryEntitiesShouldNotBePublic:ClientResult.kt$suspend fun <T> OkHttpClient.enqueueResult( request: Request, onResume: (Response) -> T ) - LibraryEntitiesShouldNotBePublic:Collection.kt$Collection : SugarRecord - LibraryEntitiesShouldNotBePublic:Colors.kt$ColorTheme - LibraryEntitiesShouldNotBePublic:Conduit.kt$Conduit - LibraryEntitiesShouldNotBePublic:ConsentActivity.kt$ConsentActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:ContentPickerFragment.kt$ContentPickerFragment : BottomSheetDialogFragment - LibraryEntitiesShouldNotBePublic:Context.kt$fun Context.openBrowser(link: String) - LibraryEntitiesShouldNotBePublic:CreateNewFolderFragment.kt$CreateNewFolderFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:CustomBottomNavBar.kt$CustomBottomNavBar : LinearLayout - LibraryEntitiesShouldNotBePublic:CustomButton.kt$CustomButton : FrameLayout - LibraryEntitiesShouldNotBePublic:DefaultScaffold.kt$@Composable fun DefaultScaffold( modifier: Modifier = Modifier, topAppBar: (@Composable () -> Unit)? = null, content: @Composable () -> Unit ) - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$@Composable fun DialogStateManager.showDialog(block: DialogBuilder.() -> Unit) - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$@Composable fun DialogStateManager.showSuccessDialog( message: String, title: String = "", // if empty, default title is used onPositive: () -> Unit = {} ) - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$ButtonBuilder - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$ButtonData - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$DefaultResourceProvider : ResourceProvider - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$DialogBuilder - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$DialogConfig - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$DialogDsl - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$DialogType - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$ResourceProvider - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$fun DialogStateManager.showDestructiveDialog( title: UiText?, message: UiText, icon: UiImage? = null, positiveButtonText: UiText? = null, onDone: () -> Unit = {}, onCancel: () -> Unit = {} ) - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$fun DialogStateManager.showDialog( resourceProvider: ResourceProvider = this.requireResourceProvider(), block: DialogBuilder.() -> Unit ) - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$fun DialogStateManager.showErrorDialog( message: String, title: String = "", onRetry: () -> Unit = {}, onCancel: () -> Unit = {} ) - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$fun DialogStateManager.showInfoDialog( message: UiText, title: UiText?, icon: UiImage? = null, onDone: () -> Unit = {}, ) - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$fun DialogStateManager.showSuccessDialog( @StringRes title: Int?, @StringRes message: Int, @StringRes positiveButtonText: Int? = null, icon: UiImage? = null, onDone: () -> Unit = {}, ) - LibraryEntitiesShouldNotBePublic:DialogConfigBuilder.kt$fun DialogStateManager.showWarningDialog( title: UiText?, message: UiText, icon: UiImage? = null, positiveButtonText: UiText? = null, onDone: () -> Unit = {}, onCancel: () -> Unit = {} ) - LibraryEntitiesShouldNotBePublic:Dimensions.kt$DimensionsTheme - LibraryEntitiesShouldNotBePublic:Dimensions.kt$Elevations - LibraryEntitiesShouldNotBePublic:Dimensions.kt$Icons - LibraryEntitiesShouldNotBePublic:Dimensions.kt$Spacing - LibraryEntitiesShouldNotBePublic:Dimensions.kt$fun getThemeDimensions(isDarkTheme: Boolean) - LibraryEntitiesShouldNotBePublic:Dispatcher.kt$Dispatcher<Action> - LibraryEntitiesShouldNotBePublic:Dispatcher.kt$typealias Dispatch<A> = (A) -> Unit - LibraryEntitiesShouldNotBePublic:Drawable.kt$fun Drawable.clone(): Drawable? - LibraryEntitiesShouldNotBePublic:Drawable.kt$fun Drawable.scaled(biggerSideDipLength: Int, context: Context): Drawable - LibraryEntitiesShouldNotBePublic:Drawable.kt$fun Drawable.scaled(factor: Double, context: Context): Drawable - LibraryEntitiesShouldNotBePublic:Drawable.kt$fun Drawable.scaled(width: Int, height: Int, context: Context): Drawable - LibraryEntitiesShouldNotBePublic:Drawable.kt$fun Drawable.tint(color: Int): Drawable - LibraryEntitiesShouldNotBePublic:DrawableExtensions.kt$fun Drawable.clone(): Drawable? - LibraryEntitiesShouldNotBePublic:DrawableExtensions.kt$fun Drawable.scaled(biggerSideDipLength: Int, context: Context): Drawable - LibraryEntitiesShouldNotBePublic:DrawableExtensions.kt$fun Drawable.scaled(factor: Double, context: Context): Drawable - LibraryEntitiesShouldNotBePublic:DrawableExtensions.kt$fun Drawable.scaled(width: Int, height: Int, context: Context): Drawable - LibraryEntitiesShouldNotBePublic:DrawableExtensions.kt$fun Drawable.tint(color: Int): Drawable - LibraryEntitiesShouldNotBePublic:DurationExtensions.kt$fun Duration.formatToDecimalPlaces(decimals: Int = 1): String - LibraryEntitiesShouldNotBePublic:EditFolderActivity.kt$EditFolderActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:Effects.kt$typealias Effects<T, A> = suspend (T, A) -> Unit - LibraryEntitiesShouldNotBePublic:EmptyableRecyclerView.kt$EmptyableRecyclerView : RecyclerView - LibraryEntitiesShouldNotBePublic:ExpandableSpaceList.kt$@Composable fun DrawerSpaceListItem( space: Space, ) - LibraryEntitiesShouldNotBePublic:ExpandableSpaceList.kt$@Composable fun ExpandableSpaceList( serverAccordionState: AccordionState, selectedSpace: Space? = null, spaceList: List<Space> ) - LibraryEntitiesShouldNotBePublic:ExpandableSpaceList.kt$@Composable fun SpaceIcon( type: Space.Type, modifier: Modifier = Modifier, tint: Color? = null ) - LibraryEntitiesShouldNotBePublic:FileUploadResult.kt$FileUploadResult : SerializableMarker - LibraryEntitiesShouldNotBePublic:FolderAdapter.kt$FolderAdapter : ListAdapterFolderAdapterListener - LibraryEntitiesShouldNotBePublic:FolderAdapter.kt$FolderAdapterListener - LibraryEntitiesShouldNotBePublic:FolderDrawerAdapter.kt$FolderDrawerAdapter : ListAdapter - LibraryEntitiesShouldNotBePublic:FolderDrawerAdapter.kt$FolderDrawerAdapterListener - LibraryEntitiesShouldNotBePublic:FolderOptionsPopup.kt$@Composable fun FolderOptionsPopup( expanded: Boolean = false, onDismissRequest: () -> Unit, onRenameFolder: () -> Unit, onSelectMedia: () -> Unit, onRemoveFolder: () -> Unit ) - LibraryEntitiesShouldNotBePublic:FoldersActivity.kt$FoldersActivity : BaseActivityFolderAdapterListener - LibraryEntitiesShouldNotBePublic:FullscreenDimmingOverlay.kt$FullScreenCreateGroupDimmingOverlay : FrameLayout - LibraryEntitiesShouldNotBePublic:FullscreenDimmingOverlay.kt$FullScreenDimmingOverlay : FrameLayout - LibraryEntitiesShouldNotBePublic:GDriveActivity.kt$GDriveActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:GDriveConduit.kt$GDriveConduit : Conduit - LibraryEntitiesShouldNotBePublic:GDriveFragment.kt$GDriveFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:GeneralSettingsActivity.kt$GeneralSettingsActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:HapticManager.kt$AppHapticFeedbackType - LibraryEntitiesShouldNotBePublic:HapticManager.kt$HapticManager - LibraryEntitiesShouldNotBePublic:HashingStrategy.kt$HashingStrategy - LibraryEntitiesShouldNotBePublic:Hbks.kt$Hbks$Availability - LibraryEntitiesShouldNotBePublic:Hbks.kt$Hbks$BiometryType - LibraryEntitiesShouldNotBePublic:HomeActivity.kt$HomeActivity : FragmentActivity - LibraryEntitiesShouldNotBePublic:HomeAppBar.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeAppBar( openDrawer: () -> Unit, onExit: () -> Unit ) - LibraryEntitiesShouldNotBePublic:HomeScreen.kt$@Composable fun HomeScreen( viewModel: HomeViewModel = koinViewModel(), onExit: () -> Unit, onNewFolder: () -> Unit, onFolderSelected: (Long) -> Unit, onAddMedia: (AddMediaType) -> Unit, onNavigateToCache: () -> Unit ) - LibraryEntitiesShouldNotBePublic:HomeScreen.kt$@Composable fun HomeScreenContent( onExit: () -> Unit, state: HomeScreenState, onAction: (HomeScreenAction) -> Unit, onNavigateToCache: () -> Unit = {} ) - LibraryEntitiesShouldNotBePublic:HomeScreen.kt$@Composable fun SaveNavGraph( context: Context, viewModel: HomeViewModel = koinViewModel(), onExit: () -> Unit, onNewFolder: () -> Unit, onFolderSelected: (Long) -> Unit, onAddMedia: (AddMediaType) -> Unit ) - LibraryEntitiesShouldNotBePublic:HomeScreen.kt$HomeScreenAction - LibraryEntitiesShouldNotBePublic:HomeScreen.kt$HomeScreenState - LibraryEntitiesShouldNotBePublic:HomeScreen.kt$HomeViewModel : ViewModel - LibraryEntitiesShouldNotBePublic:HttpLikeException.kt$HttpLikeException : Exception - LibraryEntitiesShouldNotBePublic:ISnowbirdAPI.kt$ISnowbirdAPI - LibraryEntitiesShouldNotBePublic:IaConduit.kt$IaConduit : Conduit - LibraryEntitiesShouldNotBePublic:InternetArchive.kt$InternetArchive - LibraryEntitiesShouldNotBePublic:InternetArchiveActivity.kt$InternetArchiveActivity : AppCompatActivity - LibraryEntitiesShouldNotBePublic:InternetArchiveDetailsScreen.kt$@Composable fun InternetArchiveDetailsScreen(space: Space, onResult: (IAResult) -> Unit) - LibraryEntitiesShouldNotBePublic:InternetArchiveDetailsState.kt$InternetArchiveDetailsState - LibraryEntitiesShouldNotBePublic:InternetArchiveDetailsViewModel.kt$InternetArchiveDetailsViewModel : StatefulViewModel - LibraryEntitiesShouldNotBePublic:InternetArchiveFragment.kt$InternetArchiveFragment : BaseFragmentToolbarConfigurable - LibraryEntitiesShouldNotBePublic:InternetArchiveHeader.kt$@Composable fun InternetArchiveHeader(modifier: Modifier = Modifier, titleSize: TextUnit = 18.sp) - LibraryEntitiesShouldNotBePublic:InternetArchiveLocalSource.kt$InternetArchiveLocalSource - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginRequest.kt$InternetArchiveLoginRequest - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginResponse.kt$InternetArchiveLoginResponse - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginScreen.kt$@Composable fun CustomSecureField( modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit, label: String, placeholder: String, isError: Boolean = false, isLoading: Boolean = false, keyboardType: KeyboardType, imeAction: ImeAction, ) - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginScreen.kt$@Composable fun CustomTextField( modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit, label: String, enabled: Boolean = true, placeholder: String? = null, isError: Boolean = false, isLoading: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, imeAction: ImeAction = ImeAction.Next, ) - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginScreen.kt$@Composable fun InternetArchiveLoginScreen(space: Space, onResult: (IAResult) -> Unit) - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ComposeAppBar( title: String = "Save App", onNavigationAction: () -> Unit = {} ) - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginState.kt$InternetArchiveLoginAction - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginState.kt$InternetArchiveLoginState - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginUseCase.kt$InternetArchiveLoginUseCase - LibraryEntitiesShouldNotBePublic:InternetArchiveLoginViewModel.kt$InternetArchiveLoginViewModel : StatefulViewModelKoinComponent - LibraryEntitiesShouldNotBePublic:InternetArchiveMapper.kt$InternetArchiveMapper - LibraryEntitiesShouldNotBePublic:InternetArchiveRemoteSource.kt$InternetArchiveRemoteSource - LibraryEntitiesShouldNotBePublic:InternetArchiveRepository.kt$InternetArchiveRepository - LibraryEntitiesShouldNotBePublic:InternetArchiveScreen.kt$@Composable fun InternetArchiveScreen(space: Space, isNewSpace: Boolean, onFinish: (IAResult) -> Unit) - LibraryEntitiesShouldNotBePublic:JoinGroupResponse.kt$JoinGroupResponse : SerializableMarker - LibraryEntitiesShouldNotBePublic:Listener.kt$Listener<Action> - LibraryEntitiesShouldNotBePublic:MainActivity.kt$MainActivity : BaseActivitySpaceDrawerAdapterListenerFolderDrawerAdapterListener - LibraryEntitiesShouldNotBePublic:MainBottomBar.kt$@Composable fun MainBottomBar( isSettings: Boolean, onMyMediaClick: () -> Unit, onSettingsClick: () -> Unit, onAddMediaClick: () -> Unit ) - LibraryEntitiesShouldNotBePublic:MainBottomBar.kt$@Composable fun RowScope.BottomNavMenuItem( selectedIcon: ImageVector, unSelectedIcon: ImageVector, isSelected: Boolean, text: String, onClick: () -> Unit ) - LibraryEntitiesShouldNotBePublic:MainDrawerContent.kt$@Composable fun MainDrawerContent( selectedSpace: Space? = null, spaceList: List<Space> = emptyList() ) - LibraryEntitiesShouldNotBePublic:MainDrawerContent.kt$@Composable fun MainDrawerFolderListItem( project: Project, isSelected: Boolean = false, onSelected: () -> Unit ) - LibraryEntitiesShouldNotBePublic:MainMediaAdapter.kt$MainMediaAdapter : Adapter - LibraryEntitiesShouldNotBePublic:MainMediaAdapterTest.kt$MainMediaAdapterTest - LibraryEntitiesShouldNotBePublic:MainMediaAdapterTest.kt$fun createTestMedia( id: Long, uri: String, status: Media.Status, progress: Int? = 0, selected: Boolean = false, title: String = "Test Media" ): Media - LibraryEntitiesShouldNotBePublic:MainMediaFragment.kt$MainMediaFragment : Fragment - LibraryEntitiesShouldNotBePublic:MainMediaScreen.kt$@Composable fun CollectionHeaderView(section: CollectionSection) - LibraryEntitiesShouldNotBePublic:MainMediaScreen.kt$@Composable fun CollectionSectionView( section: CollectionSection, onMediaClick: (Media) -> Unit, onMediaLongPress: (Media) -> Unit ) - LibraryEntitiesShouldNotBePublic:MainMediaScreen.kt$@Composable fun ErrorIndicator() - LibraryEntitiesShouldNotBePublic:MainMediaScreen.kt$@Composable fun MainMediaScreen( projectId: Long, ) - LibraryEntitiesShouldNotBePublic:MainMediaScreen.kt$@Composable fun MediaItemView( media: Media, isSelected: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier ) - LibraryEntitiesShouldNotBePublic:MainMediaScreen.kt$@Composable fun UploadProgress(progress: Int) - LibraryEntitiesShouldNotBePublic:MainMediaScreen.kt$@Composable fun WelcomeMessage() - LibraryEntitiesShouldNotBePublic:MainMediaScreen.kt$CollectionSection - LibraryEntitiesShouldNotBePublic:MainMediaViewHolder.kt$MainMediaViewHolder : ViewHolder - LibraryEntitiesShouldNotBePublic:MainMediaViewModel.kt$MainMediaViewModel : ViewModel - LibraryEntitiesShouldNotBePublic:MainViewModel.kt$MainUiState - LibraryEntitiesShouldNotBePublic:MainViewModel.kt$MainViewModel : ViewModel - LibraryEntitiesShouldNotBePublic:Media.kt$Media : SugarRecord - LibraryEntitiesShouldNotBePublic:MediaAdapter.kt$MediaAdapter : Adapter - LibraryEntitiesShouldNotBePublic:MediaAdapter.kt$MediaDiffCallback : Callback - LibraryEntitiesShouldNotBePublic:MediaCacheScreen.kt$@Composable fun CacheFileItem(file: MediaFile) - LibraryEntitiesShouldNotBePublic:MediaCacheScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaCacheScreen(context: Context, onNavigateBack: () -> Unit) - LibraryEntitiesShouldNotBePublic:MediaCacheScreen.kt$FileType - LibraryEntitiesShouldNotBePublic:MediaCacheScreen.kt$MediaFile - LibraryEntitiesShouldNotBePublic:MediaCacheScreen.kt$fun File.toMediaFile(): MediaFile - LibraryEntitiesShouldNotBePublic:MediaLaunchers.kt$MediaLaunchers - LibraryEntitiesShouldNotBePublic:MediaViewHolder.kt$MediaViewHolder : ViewHolder - LibraryEntitiesShouldNotBePublic:Module.kt$typealias InternetArchiveGson = Gson - LibraryEntitiesShouldNotBePublic:Notifier.kt$Notifier<Action> - LibraryEntitiesShouldNotBePublic:Notifier.kt$typealias Notify<A> = suspend (A) -> Unit - LibraryEntitiesShouldNotBePublic:NumericKeypad.kt$@Composable fun NumericKeypad( isEnabled: Boolean = true, onNumberClick: (String) -> Unit, onDeleteClick: () -> Unit, onSubmitClick: () -> Unit ) - LibraryEntitiesShouldNotBePublic:Onboarding23Activity.kt$Onboarding23Activity : BaseActivity - LibraryEntitiesShouldNotBePublic:Onboarding23FragmentStateAdapter.kt$Onboarding23FragmentStateAdapter : FragmentStateAdapter - LibraryEntitiesShouldNotBePublic:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:Onboarding23SlideFragment.kt$Onboarding23SlideFragment : Fragment - LibraryEntitiesShouldNotBePublic:PBKDF2HashingStrategy.kt$PBKDF2HashingStrategy : HashingStrategy - LibraryEntitiesShouldNotBePublic:PackageManager.kt$fun PackageManager.getVersionName(packageName: String): String - LibraryEntitiesShouldNotBePublic:PasscodeDots.kt$@Composable fun PasscodeDots( passcodeLength: Int, currentPasscodeLength: Int, shouldShake: Boolean = false ) - LibraryEntitiesShouldNotBePublic:PasscodeEntryActivity.kt$PasscodeEntryActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:PasscodeEntryScreen.kt$@Composable fun PasscodeEntryScreen( onPasscodeSuccess: () -> Unit, onExit: () -> Unit, viewModel: PasscodeEntryViewModel = koinViewModel(), hapticManager: HapticManager = koinInject() ) - LibraryEntitiesShouldNotBePublic:PasscodeEntryScreen.kt$@Composable fun PasscodeEntryScreenContent( state: PasscodeEntryScreenState, onAction: (PasscodeEntryScreenAction) -> Unit, onExit: () -> Unit, ) - LibraryEntitiesShouldNotBePublic:PasscodeEntryViewModel.kt$PasscodeEntryScreenAction - LibraryEntitiesShouldNotBePublic:PasscodeEntryViewModel.kt$PasscodeEntryScreenState - LibraryEntitiesShouldNotBePublic:PasscodeEntryViewModel.kt$PasscodeEntryUiEvent - LibraryEntitiesShouldNotBePublic:PasscodeEntryViewModel.kt$PasscodeEntryViewModel : ViewModel - LibraryEntitiesShouldNotBePublic:PasscodeManager.kt$PasscodeManager : ActivityLifecycleCallbacks - LibraryEntitiesShouldNotBePublic:PasscodeRepository.kt$PasscodeRepository - LibraryEntitiesShouldNotBePublic:PasscodeSetupActivity.kt$PasscodeSetupActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:PasscodeSetupScreen.kt$@Composable fun PasscodeSetupScreen( onPasscodeSet: () -> Unit, onCancel: () -> Unit, viewModel: PasscodeSetupViewModel = koinViewModel(), hapticManager: HapticManager = koinInject() ) - LibraryEntitiesShouldNotBePublic:PasscodeSetupViewModel.kt$PasscodeSetupUiAction - LibraryEntitiesShouldNotBePublic:PasscodeSetupViewModel.kt$PasscodeSetupUiEvent - LibraryEntitiesShouldNotBePublic:PasscodeSetupViewModel.kt$PasscodeSetupUiState - LibraryEntitiesShouldNotBePublic:PasscodeSetupViewModel.kt$PasscodeSetupViewModel : ViewModel - LibraryEntitiesShouldNotBePublic:Preview.kt$@Composable fun DefaultBoxPreview( content: @Composable () -> Unit ) - LibraryEntitiesShouldNotBePublic:Preview.kt$@Composable fun DefaultEmptyScaffoldPreview( content: @Composable () -> Unit ) - LibraryEntitiesShouldNotBePublic:Preview.kt$@Composable fun DefaultScaffoldPreview( content: @Composable () -> Unit ) - LibraryEntitiesShouldNotBePublic:PreviewActivity.kt$PreviewActivity : BaseActivityOnClickListenerListener - LibraryEntitiesShouldNotBePublic:PreviewAdapter.kt$PreviewAdapter : ListAdapter - LibraryEntitiesShouldNotBePublic:PreviewViewHolder.kt$PreviewViewHolder : ViewHolder - LibraryEntitiesShouldNotBePublic:PrimaryButton.kt$@Composable fun PrimaryButton( modifier: Modifier = Modifier, icon: ImageVector? = null, text: String, onClick: () -> Unit ) - LibraryEntitiesShouldNotBePublic:ProcessingTracker.kt$ProcessingTracker - LibraryEntitiesShouldNotBePublic:ProcessingTracker.kt$suspend fun <T> ProcessingTracker.trackProcessing( taskName: String = "Unnamed task", block: suspend () -> T ): T - LibraryEntitiesShouldNotBePublic:ProcessingTracker.kt$suspend fun <T> ProcessingTracker.trackProcessingWithTimeout( timeoutMs: Long, taskName: String = "Unnamed task", block: suspend () -> T ): T - LibraryEntitiesShouldNotBePublic:Project.kt$Project : SugarRecord - LibraryEntitiesShouldNotBePublic:ProjectAdapter.kt$ProjectAdapter : FragmentStateAdapter - LibraryEntitiesShouldNotBePublic:ProofModeScreen.kt$@Composable fun ProofModeScreen( onNavigateBack: () -> Unit ) - LibraryEntitiesShouldNotBePublic:ProofModeScreen.kt$@Composable fun ProofModeScreenContent() - LibraryEntitiesShouldNotBePublic:ProofModeSettingsActivity.kt$ProofModeSettingsActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:QRScannerActivity.kt$QRScannerActivity : CaptureActivity - LibraryEntitiesShouldNotBePublic:Reducer.kt$fun <T, A> MutableStateFlow<T>.apply(action: A, reducer: Reducer<T, A>) - LibraryEntitiesShouldNotBePublic:Reducer.kt$typealias Reducer<T, A> = (T, A) -> T - LibraryEntitiesShouldNotBePublic:RequestBodyUtil.kt$fun createListener( cancellable: () -> Boolean, onProgress: (Long) -> Unit = { }, onComplete: () -> Unit = {} ) - LibraryEntitiesShouldNotBePublic:RequestListener.kt$RequestListener - LibraryEntitiesShouldNotBePublic:RequestNameDTO.kt$MembershipRequest : SerializableMarker - LibraryEntitiesShouldNotBePublic:RequestNameDTO.kt$RequestName : SerializableMarker - LibraryEntitiesShouldNotBePublic:RestEndpointTask.kt$RestEndpointTask : Runnable - LibraryEntitiesShouldNotBePublic:RetrofitAPI.kt$RetrofitAPI : ISnowbirdAPI - LibraryEntitiesShouldNotBePublic:RetrofitClient.kt$RetrofitClient - LibraryEntitiesShouldNotBePublic:RetryConfig.kt$RetryConfig - LibraryEntitiesShouldNotBePublic:ReviewActivity.kt$ReviewActivity : BaseActivityOnClickListener - LibraryEntitiesShouldNotBePublic:SaveApp.kt$SaveApp : SugarAppFactory - LibraryEntitiesShouldNotBePublic:SaveClient.kt$SaveClient : StrongBuilderBase - LibraryEntitiesShouldNotBePublic:ScryptHashingStrategy.kt$ScryptHashingStrategy : HashingStrategy - LibraryEntitiesShouldNotBePublic:SectionViewHolder.kt$SectionViewHolder - LibraryEntitiesShouldNotBePublic:SerializableMarker.kt$SerializableMarker - LibraryEntitiesShouldNotBePublic:ServerOptionItem.kt$@Composable fun ServerOptionItem( @DrawableRes iconRes: Int, title: String, subtitle: String, onClick: () -> Unit ) - LibraryEntitiesShouldNotBePublic:SettingsFragment.kt$SettingsFragment : PreferenceFragmentCompat - LibraryEntitiesShouldNotBePublic:SettingsScreen.kt$@Composable fun SettingsScreen( onNavigateToCache: () -> Unit = {} ) - LibraryEntitiesShouldNotBePublic:SmartFragmentStatePagerAdapter.kt$SmartFragmentStatePagerAdapter : FragmentStatePagerAdapter - LibraryEntitiesShouldNotBePublic:SnowbirdBridge.kt$SnowbirdBridge - LibraryEntitiesShouldNotBePublic:SnowbirdConduit.kt$SnowbirdConduit : Conduit - LibraryEntitiesShouldNotBePublic:SnowbirdCreateGroupFragment.kt$SnowbirdCreateGroupFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SnowbirdError.kt$SnowbirdError : SerializableMarker - LibraryEntitiesShouldNotBePublic:SnowbirdFileItem.kt$SnowbirdFileItem : SugarRecordSerializableMarker - LibraryEntitiesShouldNotBePublic:SnowbirdFileItem.kt$SnowbirdFileList : SerializableMarker - LibraryEntitiesShouldNotBePublic:SnowbirdFileListAdapter.kt$SnowbirdFileDiffCallback : ItemCallback - LibraryEntitiesShouldNotBePublic:SnowbirdFileListAdapter.kt$SnowbirdFileListAdapter : ListAdapter - LibraryEntitiesShouldNotBePublic:SnowbirdFileListAdapter.kt$SnowbirdFileViewHolder : ViewHolder - LibraryEntitiesShouldNotBePublic:SnowbirdFileListFragment.kt$SnowbirdFileListFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SnowbirdFileRepository.kt$ISnowbirdFileRepository - LibraryEntitiesShouldNotBePublic:SnowbirdFileRepository.kt$SnowbirdFileRepository : ISnowbirdFileRepository - LibraryEntitiesShouldNotBePublic:SnowbirdFileViewModel.kt$SnowbirdFileViewModel : BaseViewModel - LibraryEntitiesShouldNotBePublic:SnowbirdFragment.kt$SnowbirdFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SnowbirdGroup.kt$SnowbirdGroup : SugarRecordSerializableMarker - LibraryEntitiesShouldNotBePublic:SnowbirdGroup.kt$SnowbirdGroupList : SerializableMarker - LibraryEntitiesShouldNotBePublic:SnowbirdGroup.kt$fun SnowbirdGroup.shortHash(): String - LibraryEntitiesShouldNotBePublic:SnowbirdGroupListAdapter.kt$SnowbirdGroupsAdapter : ListAdapter - LibraryEntitiesShouldNotBePublic:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SnowbirdGroupOverviewFragment.kt$SnowbirdGroupOverviewFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SnowbirdGroupRepository.kt$ISnowbirdGroupRepository - LibraryEntitiesShouldNotBePublic:SnowbirdGroupRepository.kt$SnowbirdGroupRepository : ISnowbirdGroupRepository - LibraryEntitiesShouldNotBePublic:SnowbirdGroupViewModel.kt$SnowbirdGroupViewModel : BaseViewModel - LibraryEntitiesShouldNotBePublic:SnowbirdJoinGroupFragment.kt$SnowbirdJoinGroupFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SnowbirdRepo.kt$SnowbirdRepo : SugarRecordSerializableMarker - LibraryEntitiesShouldNotBePublic:SnowbirdRepo.kt$SnowbirdRepoList : SerializableMarker - LibraryEntitiesShouldNotBePublic:SnowbirdRepo.kt$fun SnowbirdRepo.shortHash(): String - LibraryEntitiesShouldNotBePublic:SnowbirdRepoListAdapter.kt$SnowbirdRepoListAdapter : ListAdapter - LibraryEntitiesShouldNotBePublic:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SnowbirdRepoRepository.kt$ISnowbirdRepoRepository - LibraryEntitiesShouldNotBePublic:SnowbirdRepoRepository.kt$SnowbirdRepoRepository : ISnowbirdRepoRepository - LibraryEntitiesShouldNotBePublic:SnowbirdRepoViewModel.kt$SnowbirdRepoViewModel : BaseViewModel - LibraryEntitiesShouldNotBePublic:SnowbirdResult.kt$SnowbirdResult<out T> - LibraryEntitiesShouldNotBePublic:SnowbirdService.kt$ServiceStatus - LibraryEntitiesShouldNotBePublic:SnowbirdService.kt$SnowbirdService : Service - LibraryEntitiesShouldNotBePublic:SnowbirdServiceStatus.kt$SnowbirdServiceStatus - LibraryEntitiesShouldNotBePublic:SnowbirdShareFragment.kt$SnowbirdShareFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:Space.kt$Space : SugarRecord - LibraryEntitiesShouldNotBePublic:SpaceAdapter.kt$SpaceAdapter : ListAdapterSpaceAdapterListener - LibraryEntitiesShouldNotBePublic:SpaceAdapter.kt$SpaceAdapterListener - LibraryEntitiesShouldNotBePublic:SpaceAdapter.kt$SpaceItemDecoration : ItemDecoration - LibraryEntitiesShouldNotBePublic:SpaceDrawerAdapter.kt$SpaceDrawerAdapter : ListAdapter - LibraryEntitiesShouldNotBePublic:SpaceDrawerAdapter.kt$SpaceDrawerAdapterListener - LibraryEntitiesShouldNotBePublic:SpaceListFragment.kt$SpaceListFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SpaceListScreen.kt$@Composable fun SpaceListItem( space: Space, onClick: () -> Unit ) - LibraryEntitiesShouldNotBePublic:SpaceListScreen.kt$@Composable fun SpaceListScreen( onSpaceClicked: (Space) -> Unit, ) - LibraryEntitiesShouldNotBePublic:SpaceListScreen.kt$@Composable fun SpaceListScreenContent( onSpaceClicked: (Space) -> Unit, spaceList: List<Space> = emptyList() ) - LibraryEntitiesShouldNotBePublic:SpaceSetupActivity.kt$SpaceSetupActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:SpaceSetupActivity.kt$StartDestination - LibraryEntitiesShouldNotBePublic:SpaceSetupFragment.kt$SpaceSetupFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SpaceSetupScreen.kt$@Composable fun SpaceSetupScreen( onWebDavClick: () -> Unit, isInternetArchiveAllowed: Boolean, onInternetArchiveClick: () -> Unit, isDwebEnabled: Boolean, onDwebClicked: () -> Unit ) - LibraryEntitiesShouldNotBePublic:SpaceSetupSuccessFragment.kt$SpaceSetupSuccessFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:SpacingItemDecoration.kt$SpacingItemDecoration : ItemDecoration - LibraryEntitiesShouldNotBePublic:StateDispatcher.kt$StateDispatcher<T, A> : DispatcherStateful - LibraryEntitiesShouldNotBePublic:Stateful.kt$Stateful<T> - LibraryEntitiesShouldNotBePublic:StatefulViewModel.kt$StatefulViewModel<State, Action> : ViewModelStoreStateful - LibraryEntitiesShouldNotBePublic:Store.kt$Store<Action> : DispatcherListenerNotifier - LibraryEntitiesShouldNotBePublic:StoreObserver.kt$StoreObserver<T> : NotifierListener - LibraryEntitiesShouldNotBePublic:StringExtensions.kt$fun String.asQRCode(size: Int = 512, quietZone: Int = 4): Bitmap - LibraryEntitiesShouldNotBePublic:StringExtensions.kt$fun String.createInputStream(): InputStream? - LibraryEntitiesShouldNotBePublic:StringExtensions.kt$fun String.getQueryParameter(paramName: String): String? - LibraryEntitiesShouldNotBePublic:StringExtensions.kt$fun String.isValidUrl() - LibraryEntitiesShouldNotBePublic:StringExtensions.kt$fun String.uriToPath(): String - LibraryEntitiesShouldNotBePublic:StringExtensions.kt$fun String.urlEncode(): String - LibraryEntitiesShouldNotBePublic:SuspendableExtensions.kt$RetryAttempt<out T> - LibraryEntitiesShouldNotBePublic:SuspendableExtensions.kt$RetryResult<out T> - LibraryEntitiesShouldNotBePublic:SuspendableExtensions.kt$fun <T> (suspend () -> T).retryWithScope( scope: CoroutineScope, config: RetryConfig, shouldRetry: (Throwable) -> Boolean = { true }, onEach: (RetryAttempt<T>) -> Unit ): Job - LibraryEntitiesShouldNotBePublic:SuspendableExtensions.kt$fun <T> (suspend () -> T).withRetry( config: RetryConfig, shouldRetry: (Throwable) -> Boolean = { true } ): Flow<RetryAttempt<T>> - LibraryEntitiesShouldNotBePublic:SuspendableExtensions.kt$fun <T> suspendToRetry(block: suspend () -> T): suspend () -> T - LibraryEntitiesShouldNotBePublic:SwipeToDeleteCallback.kt$SwipeToDeleteCallback : Callback - LibraryEntitiesShouldNotBePublic:TextView.kt$Position - LibraryEntitiesShouldNotBePublic:TextView.kt$fun TextView.scaleAndTintDrawable(position: Position, scale: Double = 1.0, tint: Boolean = true) - LibraryEntitiesShouldNotBePublic:TextView.kt$fun TextView.setCompoundDrawablesRelativeWithIntrinsicBounds(drawables: List<Drawable?>) - LibraryEntitiesShouldNotBePublic:TextView.kt$fun TextView.setDrawable(drawable: Drawable?, position: Position, scale: Double = 1.0, tint: Boolean = true) - LibraryEntitiesShouldNotBePublic:TextView.kt$fun TextView.setDrawable(id: Int, position: Position, scale: Double = 1.0, tint: Boolean = true) - LibraryEntitiesShouldNotBePublic:TextView.kt$fun TextView.styleAsLink() - LibraryEntitiesShouldNotBePublic:Theme.kt$@Composable fun SaveAppTheme( content: @Composable () -> Unit ) - LibraryEntitiesShouldNotBePublic:Theme.kt$Theme - LibraryEntitiesShouldNotBePublic:ThrowableExceptions.kt$fun Throwable.toSnowbirdError(): SnowbirdError - LibraryEntitiesShouldNotBePublic:ToolbarConfigurable.kt$ToolbarConfigurable - LibraryEntitiesShouldNotBePublic:TorStatusContentProvider.kt$TorStatusContentProvider : ContentProvider - LibraryEntitiesShouldNotBePublic:TorStatusDatabase.kt$TorStatusDatabase : SQLiteOpenHelper - LibraryEntitiesShouldNotBePublic:TwoLetterDrawable.kt$TwoLetterDrawable : Drawable - LibraryEntitiesShouldNotBePublic:UiImage.kt$UiImage - LibraryEntitiesShouldNotBePublic:UiImage.kt$fun @receiver:DrawableRes Int.asUiImage(): UiImage.DrawableResource - LibraryEntitiesShouldNotBePublic:UiImage.kt$fun ImageVector.asUiImage(): UiImage - LibraryEntitiesShouldNotBePublic:UiText.kt$UiText - LibraryEntitiesShouldNotBePublic:UiText.kt$fun @receiver:StringRes Int.asUiText(): UiText - LibraryEntitiesShouldNotBePublic:UiText.kt$fun String.asUiText(): UiText - LibraryEntitiesShouldNotBePublic:UnauthenticatedException.kt$UnauthenticatedException : RuntimeException - LibraryEntitiesShouldNotBePublic:UnitTests.kt$UnitTests - LibraryEntitiesShouldNotBePublic:UnixSocketAPI.kt$UnixSocketAPI : ISnowbirdAPI - LibraryEntitiesShouldNotBePublic:UnixSocketClient.kt$HttpMethod - LibraryEntitiesShouldNotBePublic:UnixSocketClient.kt$UnixSocketClient - LibraryEntitiesShouldNotBePublic:UnixSocketClientFileExtensions.kt$suspend fun UnixSocketClient.downloadFile(endpoint: String): ByteArray - LibraryEntitiesShouldNotBePublic:UnixSocketClientFileExtensions.kt$suspend inline fun <reified RESPONSE : SerializableMarker> UnixSocketClient.uploadFile( endpoint: String, imageData: ByteArray ): RESPONSE - LibraryEntitiesShouldNotBePublic:UnixSocketClientFileExtensions.kt$suspend inline fun <reified RESPONSE : SerializableMarker> UnixSocketClient.uploadFile( endpoint: String, inputStream: InputStream, ): RESPONSE - LibraryEntitiesShouldNotBePublic:UnixSocketClientUtilityExtensions.kt$suspend fun UnixSocketClient.readBinaryResponseWithCancellation( inputStream: InputStream, onProgress: ((Long) -> Unit)? = null ): Triple<Int, Map<String, String>, ByteArray> - LibraryEntitiesShouldNotBePublic:UploadManagerActivity.kt$UploadManagerActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:UploadManagerFragment.kt$UploadManagerFragment : BottomSheetDialogFragment - LibraryEntitiesShouldNotBePublic:UploadService.kt$UploadService : JobService - LibraryEntitiesShouldNotBePublic:UriExtensions.kt$fun Uri.createInputStream(applicationContext: Context): InputStream? - LibraryEntitiesShouldNotBePublic:UriExtensions.kt$fun Uri.getFilename(applicationContext: Context): String? - LibraryEntitiesShouldNotBePublic:Util.kt$Util$RandomString - LibraryEntitiesShouldNotBePublic:ValidateLoginCredentialsUseCase.kt$ValidateLoginCredentialsUseCase - LibraryEntitiesShouldNotBePublic:VideoRequestHandler.kt$VideoRequestHandler : RequestHandler - LibraryEntitiesShouldNotBePublic:View.kt$fun View.cloak(animate: Boolean = false) - LibraryEntitiesShouldNotBePublic:View.kt$fun View.disableAnimation(around: () -> Unit) - LibraryEntitiesShouldNotBePublic:View.kt$fun View.hide(animate: Boolean = false) - LibraryEntitiesShouldNotBePublic:View.kt$fun View.makeSnackBar(message: CharSequence, duration: Int = Snackbar.LENGTH_INDEFINITE): Snackbar - LibraryEntitiesShouldNotBePublic:View.kt$fun View.show(animate: Boolean = false) - LibraryEntitiesShouldNotBePublic:View.kt$fun View.toggle(state: Boolean? = null, animate: Boolean = false) - LibraryEntitiesShouldNotBePublic:ViewExtension.kt$fun View.cloak(animate: Boolean = false) - LibraryEntitiesShouldNotBePublic:ViewExtension.kt$fun View.disableAnimation(around: () -> Unit) - LibraryEntitiesShouldNotBePublic:ViewExtension.kt$fun View.getMeasurments(): Pair<Int, Int> - LibraryEntitiesShouldNotBePublic:ViewExtension.kt$fun View.hide(animate: Boolean = false) - LibraryEntitiesShouldNotBePublic:ViewExtension.kt$fun View.makeSnackBar(message: CharSequence, duration: Int = Snackbar.LENGTH_INDEFINITE): Snackbar - LibraryEntitiesShouldNotBePublic:ViewExtension.kt$fun View.propagateClickToParent() - LibraryEntitiesShouldNotBePublic:ViewExtension.kt$fun View.show(animate: Boolean = false) - LibraryEntitiesShouldNotBePublic:ViewExtension.kt$fun View.showKeyboard() - LibraryEntitiesShouldNotBePublic:ViewExtension.kt$fun View.toggle(state: Boolean? = null, animate: Boolean = false) - LibraryEntitiesShouldNotBePublic:WebDAVModel.kt$BackendCapabilities - LibraryEntitiesShouldNotBePublic:WebDAVModel.kt$Data - LibraryEntitiesShouldNotBePublic:WebDAVModel.kt$Meta - LibraryEntitiesShouldNotBePublic:WebDAVModel.kt$Ocs - LibraryEntitiesShouldNotBePublic:WebDAVModel.kt$Quota - LibraryEntitiesShouldNotBePublic:WebDAVModel.kt$WebDAVModel - LibraryEntitiesShouldNotBePublic:WebDavActivity.kt$WebDavActivity : BaseActivity - LibraryEntitiesShouldNotBePublic:WebDavConduit.kt$WebDavConduit : Conduit - LibraryEntitiesShouldNotBePublic:WebDavFragment.kt$WebDavFragment : BaseFragment - LibraryEntitiesShouldNotBePublic:WebDavSetupLicenseFragment.kt$WebDavSetupLicenseFragment : BaseFragment - LongMethod:BaseDialog.kt$@Composable fun BaseDialog( onDismiss: () -> Unit, icon: UiImage? = null, iconColor: Color? = null, title: String, message: String, hasCheckbox: Boolean = false, onCheckBoxStateChanged: (Boolean) -> Unit = {}, checkBoxHint: String = "Do not show me this again", positiveButton: ButtonData? = null, neutralButton: ButtonData? = null, destructiveButton: ButtonData? = null, backgroundColor: Color = MaterialTheme.colorScheme.surface ) - LongMethod:FileUtils.kt$FileUtils$@SuppressLint("NewAPI", "LogNotTimber") fun getPath(context: Context, uri: Uri): String? - LongMethod:HomeScreen.kt$@Composable fun HomeScreenContent( onExit: () -> Unit, state: HomeScreenState, onAction: (HomeScreenAction) -> Unit, onNavigateToCache: () -> Unit = {} ) - LongMethod:InternetArchiveDetailsScreen.kt$@Composable private fun InternetArchiveDetailsContent( state: InternetArchiveDetailsState, dispatch: Dispatch<Action>, dialogManager: DialogStateManager = koinViewModel() ) - LongMethod:InternetArchiveLoginScreen.kt$@Composable private fun InternetArchiveLoginContent( state: InternetArchiveLoginState, dispatch: Dispatch<Action> ) - LongMethod:MainDrawerContent.kt$@Composable fun MainDrawerContent( selectedSpace: Space? = null, spaceList: List<Space> = emptyList() ) - LongMethod:MainMediaViewHolder.kt$MainMediaViewHolder$fun bind(media: Media? = null, isInSelectionMode: Boolean = false, doImageFade: Boolean = true) - LongMethod:MediaAdapter.kt$MediaAdapter$override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder - LongMethod:MediaViewHolder.kt$MediaViewHolder$@SuppressLint("SetTextI18n") fun bind(media: Media? = null, batchMode: Boolean = false, doImageFade: Boolean = true) - LongMethod:NumericKeypad.kt$@Composable private fun NumberButton( label: String, enabled: Boolean = true, onClick: () -> Unit, hapticManager: HapticManager = koinInject() ) - LongMethod:PasscodeSetupScreen.kt$@Composable private fun PasscodeSetupScreenContent( state: PasscodeSetupUiState, onAction: (PasscodeSetupUiAction) -> Unit ) - LongMethod:PreviewViewHolder.kt$PreviewViewHolder$@SuppressLint("SetTextI18n") fun bind(media: Media? = null, batchMode: Boolean = false, doImageFade: Boolean = true) - LongMethod:ProofModeScreen.kt$@Composable fun ProofModeScreenContent() - LongMethod:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Fragment$override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) - LongMethod:SettingsFragment.kt$SettingsFragment$override fun onCreatePreferences( savedInstanceState: Bundle?, rootKey: String? ) - LongMethod:SettingsScreen.kt$@Composable fun SettingsScreen( onNavigateToCache: () -> Unit = {} ) - LongMethod:UnixSocketClientUtilityExtensions.kt$suspend fun UnixSocketClient.readBinaryResponseWithCancellation( inputStream: InputStream, onProgress: ((Long) -> Unit)? = null ): Triple<Int, Map<String, String>, ByteArray> - LongMethod:WebDavConduit.kt$WebDavConduit$@Throws(IOException::class) private suspend fun uploadChunked(base: HttpUrl, path: List<String>, fileName: String): Boolean - LongMethod:WebDavFragment.kt$WebDavFragment$override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View - LongParameterList:Accordion.kt$( modifier: Modifier = Modifier, headerModifier: Modifier = Modifier, state: AccordionState = rememberAccordionState(), animate: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, headerContent: @Composable () -> Unit, bodyContent: @Composable () -> Unit, ) - LongParameterList:BaseButton.kt$( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, backgroundColor: Color = MaterialTheme.colorScheme.primary, textColor: Color = MaterialTheme.colorScheme.onPrimary, cornerRadius: Dp = 12.dp, ) - LongParameterList:BaseButton.kt$( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, borderColor: Color = MaterialTheme.colorScheme.error, textColor: Color = MaterialTheme.colorScheme.error, cornerRadius: Dp = 12.dp, ) - LongParameterList:BaseDialog.kt$( onDismiss: () -> Unit, icon: UiImage? = null, iconColor: Color? = null, title: String, message: String, hasCheckbox: Boolean = false, onCheckBoxStateChanged: (Boolean) -> Unit = {}, checkBoxHint: String = "Do not show me this again", positiveButton: ButtonData? = null, neutralButton: ButtonData? = null, destructiveButton: ButtonData? = null, backgroundColor: Color = MaterialTheme.colorScheme.surface ) - LongParameterList:DialogConfigBuilder.kt$( title: UiText?, message: UiText, icon: UiImage? = null, positiveButtonText: UiText? = null, onDone: () -> Unit = {}, onCancel: () -> Unit = {} ) - LongParameterList:HomeScreen.kt$( context: Context, viewModel: HomeViewModel = koinViewModel(), onExit: () -> Unit, onNewFolder: () -> Unit, onFolderSelected: (Long) -> Unit, onAddMedia: (AddMediaType) -> Unit ) - LongParameterList:HomeScreen.kt$( viewModel: HomeViewModel = koinViewModel(), onExit: () -> Unit, onNewFolder: () -> Unit, onFolderSelected: (Long) -> Unit, onAddMedia: (AddMediaType) -> Unit, onNavigateToCache: () -> Unit ) - LongParameterList:InternetArchiveLoginScreen.kt$( modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit, label: String, enabled: Boolean = true, placeholder: String? = null, isError: Boolean = false, isLoading: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, imeAction: ImeAction = ImeAction.Next, ) - LongParameterList:InternetArchiveLoginScreen.kt$( modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit, label: String, placeholder: String, isError: Boolean = false, isLoading: Boolean = false, keyboardType: KeyboardType, imeAction: ImeAction, ) - LongParameterList:MainMediaAdapterTest.kt$( id: Long, uri: String, status: Media.Status, progress: Int? = 0, selected: Boolean = false, title: String = "Test Media" ) - LongParameterList:Utility.kt$Utility$( context: Context, title: String, message: String? = null, positiveButtonText: String, negativeButtonText: String, completion: (Boolean) -> Unit ) - LoopWithTooManyJumpStatements:SuspendableExtensions.kt$while - LoopWithTooManyJumpStatements:UnixSocketClientUtilityExtensions.kt$while - MagicNumber:BadgeDrawable.kt$BadgeDrawable$4 - MagicNumber:BadgeDrawable.kt$BadgeDrawable$5f - MagicNumber:Colors.kt$0xff000A0A - MagicNumber:Colors.kt$0xff001b19 - MagicNumber:Colors.kt$0xff003530 - MagicNumber:Colors.kt$0xff004e48 - MagicNumber:Colors.kt$0xff00685f - MagicNumber:Colors.kt$0xff008177 - MagicNumber:Colors.kt$0xff009b8f - MagicNumber:Colors.kt$0xff00b4a6 - MagicNumber:Colors.kt$0xff00cebe - MagicNumber:Colors.kt$0xff00e7d5 - MagicNumber:Colors.kt$0xff00ffeb - MagicNumber:Colors.kt$0xff101010 - MagicNumber:Colors.kt$0xff212021 - MagicNumber:Colors.kt$0xff333333 - MagicNumber:Colors.kt$0xff434343 - MagicNumber:Colors.kt$0xff696666 - MagicNumber:Colors.kt$0xff777979 - MagicNumber:Colors.kt$0xff9f9f9f - MagicNumber:Colors.kt$0xffaae6e1 - MagicNumber:Colors.kt$0xffe3e3e4 - MagicNumber:Colors.kt$0xfffffbf0 - MagicNumber:Conduit.kt$Conduit$100 - MagicNumber:DrawableUtil.kt$DrawableUtil$100 - MagicNumber:DrawableUtil.kt$DrawableUtil$40f - MagicNumber:DurationExtensions.kt$1e9 - MagicNumber:EditFolderActivity.kt$EditFolderActivity$0.5 - MagicNumber:ExpandableSpaceList.kt$180 - MagicNumber:FullscreenDimmingOverlay.kt$FullScreenCreateGroupDimmingOverlay$200 - MagicNumber:FullscreenDimmingOverlay.kt$FullScreenDimmingOverlay$200 - MagicNumber:GDriveConduit.kt$GDriveConduit$262144 - MagicNumber:GDriveConduit.kt$GDriveConduit.Companion$1000 - MagicNumber:GDriveConduit.kt$GDriveConduit.Companion$20 - MagicNumber:GDriveConduit.kt$GDriveConduit.Companion$200 - MagicNumber:GDriveConduit.kt$GDriveConduit.Companion$443 - MagicNumber:GDriveConduit.kt$GDriveConduit.Companion$80 - MagicNumber:GDriveConduit.kt$GDriveConduit.Companion$8192 - MagicNumber:Hbks.kt$Hbks$12 - MagicNumber:Hbks.kt$Hbks$128 - MagicNumber:Hbks.kt$Hbks$60 - MagicNumber:IaConduit.kt$IaConduit$4 - MagicNumber:InternetArchiveLoginScreen.kt$3000 - MagicNumber:MainActivity.kt$MainActivity$0.3f - MagicNumber:MainActivity.kt$MainActivity$0.75 - MagicNumber:MainActivity.kt$MainActivity$200 - MagicNumber:MainActivity.kt$MainActivity$60 - MagicNumber:MainActivity.kt$MainActivity$8f - MagicNumber:MainDrawerContent.kt$0.65f - MagicNumber:MainDrawerContent.kt$0.7f - MagicNumber:MainDrawerContent.kt$8f - MagicNumber:MainMediaScreen.kt$4 - MagicNumber:MainMediaViewHolder.kt$MainMediaViewHolder$0.5f - MagicNumber:MainMediaViewHolder.kt$MainMediaViewHolder$1000 - MagicNumber:MainMediaViewHolder.kt$MainMediaViewHolder$300 - MagicNumber:MainMediaViewHolder.kt$MainMediaViewHolder$30f - MagicNumber:MainMediaViewHolder.kt$MainMediaViewHolder$5f - MagicNumber:Media.kt$Media.Status.DeleteRemote$7 - MagicNumber:Media.kt$Media.Status.Error$9 - MagicNumber:Media.kt$Media.Status.Published$3 - MagicNumber:Media.kt$Media.Status.Uploaded$5 - MagicNumber:Media.kt$Media.Status.Uploading$4 - MagicNumber:MediaViewHolder.kt$MediaViewHolder$0.5f - MagicNumber:MediaViewHolder.kt$MediaViewHolder$30f - MagicNumber:MediaViewHolder.kt$MediaViewHolder$5f - MagicNumber:NumericKeypad.kt$3 - MagicNumber:Onboarding23Activity.kt$Onboarding23Activity$0xffffff - MagicNumber:Onboarding23Activity.kt$Onboarding23Activity$2000 - MagicNumber:Onboarding23Activity.kt$Onboarding23Activity$25F - MagicNumber:Onboarding23Activity.kt$Onboarding23Activity$3000 - MagicNumber:Onboarding23Activity.kt$Onboarding23Activity$999999 - MagicNumber:Onboarding23FragmentStateAdapter.kt$Onboarding23FragmentStateAdapter$3 - MagicNumber:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity$200L - MagicNumber:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity$3 - MagicNumber:PasscodeDots.kt$10f - MagicNumber:PasscodeDots.kt$15f - MagicNumber:PasscodeDots.kt$25f - MagicNumber:PasscodeEntryViewModel.kt$PasscodeEntryViewModel$200 - MagicNumber:PasscodeEntryViewModel.kt$PasscodeEntryViewModel$500 - MagicNumber:PasscodeSetupViewModel.kt$PasscodeSetupViewModel$100 - MagicNumber:PasscodeSetupViewModel.kt$PasscodeSetupViewModel$500 - MagicNumber:Picker.kt$Picker$99 - MagicNumber:PreviewViewHolder.kt$PreviewViewHolder$0.5f - MagicNumber:PreviewViewHolder.kt$PreviewViewHolder$30f - MagicNumber:PreviewViewHolder.kt$PreviewViewHolder$5f - MagicNumber:PrimaryButton.kt$8f - MagicNumber:ProcessingTracker.kt$ProcessingTracker$3 - MagicNumber:RestEndpointTask.kt$RestEndpointTask$9050 - MagicNumber:RetrofitModule.kt$60 - MagicNumber:SaveClient.kt$SaveClient$40L - MagicNumber:SnowbirdFileListAdapter.kt$SnowbirdFileListAdapter$40 - MagicNumber:SnowbirdFileViewModel.kt$SnowbirdFileViewModel$30_000 - MagicNumber:SnowbirdFileViewModel.kt$SnowbirdFileViewModel$60_000 - MagicNumber:SnowbirdGroup.kt$10 - MagicNumber:SnowbirdGroupListAdapter.kt$SnowbirdGroupsAdapter.ViewHolder$40 - MagicNumber:SnowbirdGroupRepository.kt$SnowbirdGroupRepository$1000 - MagicNumber:SnowbirdGroupRepository.kt$SnowbirdGroupRepository$5 - MagicNumber:SnowbirdGroupRepository.kt$SnowbirdGroupRepository$60 - MagicNumber:SnowbirdGroupViewModel.kt$SnowbirdGroupViewModel$30_000 - MagicNumber:SnowbirdGroupViewModel.kt$SnowbirdGroupViewModel$60_000 - MagicNumber:SnowbirdRepo.kt$10 - MagicNumber:SnowbirdRepoListAdapter.kt$SnowbirdRepoListAdapter.SnowbirdRepoListViewHolder$40 - MagicNumber:SnowbirdRepoViewModel.kt$SnowbirdRepoViewModel$30_000 - MagicNumber:SnowbirdRepoViewModel.kt$SnowbirdRepoViewModel$60_000 - MagicNumber:SnowbirdServiceStatus.kt$SnowbirdServiceStatus$3 - MagicNumber:SnowbirdServiceStatus.kt$SnowbirdServiceStatus$4 - MagicNumber:SnowbirdServiceStatus.kt$SnowbirdServiceStatus$5 - MagicNumber:SnowbirdServiceStatus.kt$SnowbirdServiceStatus$6 - MagicNumber:SnowbirdServiceStatus.kt$SnowbirdServiceStatus.Companion$3 - MagicNumber:SnowbirdServiceStatus.kt$SnowbirdServiceStatus.Companion$4 - MagicNumber:SnowbirdServiceStatus.kt$SnowbirdServiceStatus.Companion$5 - MagicNumber:SnowbirdServiceStatus.kt$SnowbirdServiceStatus.Companion$6 - MagicNumber:Space.kt$Space.Type.RAVEN$5 - MagicNumber:SpaceAdapter.kt$SpaceAdapter.ViewHolder$32 - MagicNumber:SpaceDrawerAdapter.kt$SpaceDrawerAdapter.SpaceViewHolder$21 - MagicNumber:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$0.75 - MagicNumber:TwoLetterDrawable.kt$TwoLetterDrawable$0.5f - MagicNumber:TwoLetterDrawable.kt$TwoLetterDrawable$0.8f - MagicNumber:UnixSocketClient.kt$UnixSocketClient$200 - MagicNumber:UnixSocketClient.kt$UnixSocketClient$299 - MagicNumber:UnixSocketClientFileExtensions.kt$200 - MagicNumber:UnixSocketClientFileExtensions.kt$299 - MagicNumber:UnixSocketClientUtilityExtensions.kt$16 - MagicNumber:UnixSocketClientUtilityExtensions.kt$8192 - MagicNumber:UploadService.kt$UploadService$7918 - MagicNumber:Utility.kt$Utility$1024 - MagicNumber:Utility.kt$Utility$4 - MagicNumber:VideoRequestHandler.kt$VideoRequestHandler$6 - MagicNumber:WebDavFragment.kt$WebDavFragment.<no name provided>$200 - MagicNumber:WebDavFragment.kt$WebDavFragment.<no name provided>$204 - MatchingDeclarationName:DefaultScaffold.kt$MessageManager - MatchingDeclarationName:MainMediaScreen.kt$CollectionSection - MatchingDeclarationName:SnowbirdGroupListAdapter.kt$SnowbirdGroupsAdapter : ListAdapter - MatchingDeclarationName:TextView.kt$Position - MaxLineLength:BiometricAuthenticator.kt$BiometricAuthenticator$return config.biometricAuthEnabled && biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS - MaxLineLength:BottomSheetExtensions.kt$val bottomSheet: FrameLayout = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return@setOnShowListener - MaxLineLength:BrowseFoldersAdapter.kt$BrowseFoldersAdapter.FolderViewHolder$inner - MaxLineLength:ConsentActivity.kt$ConsentActivity$R.string.by_allowing_health_checks_you_give_permission_for_the_app_to_securely_send_health_check_data_to_the_s_team - MaxLineLength:CreativeCommonsLicenseManager.kt$CreativeCommonsLicenseManager$swRequireShareAlike.isChecked = isActive && binding.swAllowRemix.isChecked && currentLicense?.contains("-sa", true) ?: false - MaxLineLength:DriveServiceHelper.kt$DriveServiceHelper$suspend - MaxLineLength:DurationExtensions.kt$* - MaxLineLength:GDriveConduit.kt$GDriveConduit.Companion$"mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed = false" - MaxLineLength:GDriveConduit.kt$GDriveConduit.Companion$"mimeType='application/vnd.google-apps.folder' and name = '$folderName' and trashed = false and '$parentId' in parents" - MaxLineLength:Hbks.kt$Hbks$} - MaxLineLength:IaConduit.kt$IaConduit$// TODO this should make sure we aren't accidentally using one of archive.org's metadata fields by accident - MaxLineLength:InternetArchiveDetailsScreen.kt$message = UiText.StringResource(R.string.are_you_sure_you_want_to_remove_this_server_from_the_app) - MaxLineLength:InternetArchiveFragment.kt$InternetArchiveFragment$val - MaxLineLength:MediaAdapter.kt$MediaAdapter$// CleanInsightsManager.measureEvent("backend", "upload-error", media[pos].space?.friendlyName) - MaxLineLength:PasscodeSetupScreen.kt$text = "Make sure you remember this pin. If you forget it, you will need to reset the app, and all data will be erased." - MaxLineLength:Prefs.kt$Prefs$get() = prefs?.getString(ProofModeConstants.PREFS_KEY_PASSPHRASE, null) ?: ProofModeConstants.PREFS_KEY_PASSPHRASE_DEFAULT - MaxLineLength:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Fragment$if - MaxLineLength:ReviewActivity.kt$ReviewActivity.Companion$fun - MaxLineLength:SnowbirdCreateGroupFragment.kt$SnowbirdCreateGroupFragment$SnowbirdCreateGroupFragmentDirections - MaxLineLength:SnowbirdFileListFragment.kt$SnowbirdFileListFragment$// viewBinding.snowbirdMediaRecyclerView.layoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) - MaxLineLength:SnowbirdFileRepository.kt$SnowbirdFileRepository$private suspend - MaxLineLength:SnowbirdFragment.kt$SnowbirdFragment$"save+dweb::?dht=82fd345d484393a96b6e0c5d5e17a85a61c9184cc5a3311ab069d6efa0bf1410&enc=6fa27396fe298f92c91013ac54d8f316c2d45dc3bed0edec73078040aa10feed&pk=f4b404d294817cf11ea7f8ef7231626e03b74f6fafe3271b53918608afa82d12&sk=5482a8f490081be684fbadb8bde7f0a99bab8acdcf1ec094826f0f18e327e399" - MaxLineLength:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment$// findNavController().navigate(SnowbirdGroupListFragmentDirections.navigateToSnowbirdShareScreen(groupKey)) - MaxLineLength:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment$val - MaxLineLength:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment.<no name provided>$SnowbirdGroupListFragmentDirections.actionFragmentSnowbirdGroupListToFragmentSnowbirdCreateGroup() - MaxLineLength:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment$// findNavController().navigate(SnowbirdRepoListFragmentDirections.navigateToSnowbirdListFilesScreen(groupKey, repoKey)) - MaxLineLength:SpaceAdapter.kt$SpaceAdapter$class - MaxLineLength:SpaceDrawerAdapter.kt$SpaceDrawerAdapter$class - MaxLineLength:SpaceDrawerAdapter.kt$SpaceDrawerAdapter$val previousIndex = currentList.indexOfFirst { it is SpaceItem.SpaceItemData && it.space.id == selectedSpace?.id } - MaxLineLength:SpaceDrawerAdapter.kt$SpaceDrawerAdapter.Companion.<no name provided>$oldItem is SpaceItem.SpaceItemData && newItem is SpaceItem.SpaceItemData -> oldItem.space.friendlyName == newItem.space.friendlyName - MaxLineLength:SpaceDrawerAdapter.kt$SpaceDrawerAdapter.Companion.<no name provided>$oldItem is SpaceItem.SpaceItemData && newItem is SpaceItem.SpaceItemData -> oldItem.space.id == newItem.space.id - MaxLineLength:SpaceDrawerAdapter.kt$SpaceDrawerAdapter.SpaceViewHolder$val previousIndex = currentList.indexOfFirst { it is SpaceItem.SpaceItemData && it.space.id == selectedSpace?.id } - MaxLineLength:Utility.kt$Utility$fun - MaxLineLength:WebDavSetupLicenseFragment.kt$WebDavSetupLicenseFragment$val - MaximumLineLength:BiometricAuthenticator.kt$BiometricAuthenticator$ - MaximumLineLength:BottomSheetExtensions.kt$ - MaximumLineLength:BrowseFoldersAdapter.kt$BrowseFoldersAdapter$ - MaximumLineLength:ConsentActivity.kt$ConsentActivity$ - MaximumLineLength:CreativeCommonsLicenseManager.kt$CreativeCommonsLicenseManager$ - MaximumLineLength:DriveServiceHelper.kt$DriveServiceHelper$ - MaximumLineLength:GDriveConduit.kt$GDriveConduit.Companion$ - MaximumLineLength:Hbks.kt$Hbks$ - MaximumLineLength:InternetArchiveDetailsScreen.kt$ - MaximumLineLength:InternetArchiveFragment.kt$InternetArchiveFragment$ - MaximumLineLength:PasscodeSetupScreen.kt$ - MaximumLineLength:Prefs.kt$Prefs$ - MaximumLineLength:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Fragment$ - MaximumLineLength:ReviewActivity.kt$ReviewActivity.Companion$ - MaximumLineLength:SnowbirdCreateGroupFragment.kt$SnowbirdCreateGroupFragment$ - MaximumLineLength:SnowbirdFileRepository.kt$SnowbirdFileRepository$ - MaximumLineLength:SnowbirdFragment.kt$SnowbirdFragment$ - MaximumLineLength:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment$ - MaximumLineLength:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment.<no name provided>$ - MaximumLineLength:SpaceAdapter.kt$SpaceAdapter$class - MaximumLineLength:SpaceDrawerAdapter.kt$SpaceDrawerAdapter$ - MaximumLineLength:SpaceDrawerAdapter.kt$SpaceDrawerAdapter$class - MaximumLineLength:SpaceDrawerAdapter.kt$SpaceDrawerAdapter.Companion.<no name provided>$ - MaximumLineLength:SpaceDrawerAdapter.kt$SpaceDrawerAdapter.SpaceViewHolder$ - MaximumLineLength:Utility.kt$Utility$ - MaximumLineLength:WebDavSetupLicenseFragment.kt$WebDavSetupLicenseFragment$ - MemberNameEqualsClassName:Prefs.kt$Prefs$private var prefs: SharedPreferences? = null - ModifierClickableOrder:NumericKeypad.kt$clickable( interactionSource = interactionSource, indication = null, enabled = enabled, onClick = { hapticManager.performHapticFeedback(AppHapticFeedbackType.KeyPress) onClick() } ) - ModifierMissing:AddFolderScreen.kt$AddFolderScreenContent - ModifierMissing:AddFolderScreen.kt$FolderOption - ModifierMissing:BrowseFolderScreen.kt$BrowseFolderItem - ModifierMissing:BrowseFolderScreen.kt$BrowseFolderScreenContent - ModifierMissing:ExpandableSpaceList.kt$DrawerSpaceListItem - ModifierMissing:ExpandableSpaceList.kt$ExpandableSpaceList - ModifierMissing:FolderOptionsPopup.kt$FolderOptionsPopup - ModifierMissing:HomeAppBar.kt$HomeAppBar - ModifierMissing:HomeScreen.kt$HomeScreenContent - ModifierMissing:InternetArchiveLoginScreen.kt$ComposeAppBar - ModifierMissing:MainBottomBar.kt$BottomNavMenuItem - ModifierMissing:MainBottomBar.kt$MainBottomBar - ModifierMissing:MainDrawerContent.kt$MainDrawerContent - ModifierMissing:MainDrawerContent.kt$MainDrawerFolderListItem - ModifierMissing:MainMediaScreen.kt$CollectionHeaderView - ModifierMissing:MainMediaScreen.kt$CollectionSectionView - ModifierMissing:MainMediaScreen.kt$ErrorIndicator - ModifierMissing:MainMediaScreen.kt$MainMediaScreen - ModifierMissing:MainMediaScreen.kt$UploadProgress - ModifierMissing:MainMediaScreen.kt$WelcomeMessage - ModifierMissing:MediaCacheScreen.kt$CacheFileItem - ModifierMissing:MediaCacheScreen.kt$MediaCacheScreen - ModifierMissing:NumericKeypad.kt$NumericKeypad - ModifierMissing:PasscodeDots.kt$PasscodeDots - ModifierMissing:PasscodeEntryScreen.kt$PasscodeEntryScreenContent - ModifierMissing:Preview.kt$DefaultBoxPreview - ModifierMissing:Preview.kt$DefaultEmptyScaffoldPreview - ModifierMissing:Preview.kt$DefaultScaffoldPreview - ModifierMissing:ProofModeScreen.kt$ProofModeScreenContent - ModifierMissing:ServerOptionItem.kt$ServerOptionItem - ModifierMissing:SettingsScreen.kt$SettingsScreen - ModifierMissing:SpaceListScreen.kt$SpaceListItem - ModifierMissing:SpaceListScreen.kt$SpaceListScreen - ModifierMissing:SpaceListScreen.kt$SpaceListScreenContent - ModifierMissing:SpaceSetupScreen.kt$SpaceSetupScreen - MultiLineIfElse:EditFolderActivity.kt$EditFolderActivity$R.string.action_archive_project - MultiLineIfElse:EditFolderActivity.kt$EditFolderActivity$R.string.action_unarchive_project - MultiLineIfElse:FileUtils.kt$FileUtils$Log.d( "$TAG File -", "Authority: " + uri.authority + ", Fragment: " + uri.fragment + ", Port: " + uri.port + ", Query: " + uri.query + ", Scheme: " + uri.scheme + ", Host: " + uri.host + ", Segments: " + uri.pathSegments.toString() ) - MultiLineIfElse:MainActivity.kt$MainActivity$false - MultiLineIfElse:MediaViewHolder.kt$MediaViewHolder$R.drawable.ic_edit_selected - MultiLineIfElse:MediaViewHolder.kt$MediaViewHolder$R.drawable.ic_edit_unselected - MultiLineIfElse:MediaViewHolder.kt$MediaViewHolder$R.drawable.ic_flag_selected - MultiLineIfElse:MediaViewHolder.kt$MediaViewHolder$R.drawable.ic_flag_unselected - MultiLineIfElse:MediaViewHolder.kt$MediaViewHolder$R.drawable.ic_location_selected - MultiLineIfElse:MediaViewHolder.kt$MediaViewHolder$R.drawable.ic_location_unselected - MultiLineIfElse:MediaViewHolder.kt$MediaViewHolder$R.drawable.ic_tag_selected - MultiLineIfElse:MediaViewHolder.kt$MediaViewHolder$R.drawable.ic_tag_unselected - MultiLineIfElse:PasscodeDots.kt$MaterialTheme.colorScheme.onBackground - MultiLineIfElse:PasscodeDots.kt$MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - MultiLineIfElse:PasscodeEntryViewModel.kt$PasscodeEntryViewModel$null - MultiLineIfElse:PasscodeEntryViewModel.kt$PasscodeEntryViewModel$state - MultiLineIfElse:PasscodeEntryViewModel.kt$PasscodeEntryViewModel$state.copy(passcode = state.passcode + number) - MultiLineIfElse:PasscodeEntryViewModel.kt$PasscodeEntryViewModel$state.copy(passcode = state.passcode.dropLast(1)) - MultiLineIfElse:PasscodeSetupViewModel.kt$PasscodeSetupViewModel$state - MultiLineIfElse:PasscodeSetupViewModel.kt$PasscodeSetupViewModel$state.copy(passcode = state.passcode + number) - MultiLineIfElse:PasscodeSetupViewModel.kt$PasscodeSetupViewModel$state.copy(passcode = state.passcode.dropLast(1)) - MultiLineIfElse:RequestBodyUtil.kt$RequestBodyUtil.<no name provided>$FileInputStream( uri.path?.let { File(it) } ) - MultiLineIfElse:RequestBodyUtil.kt$RequestBodyUtil.<no name provided>$cr.openInputStream(uri) - MultiLineIfElse:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$0 - MultiLineIfElse:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$ColorDrawable(ContextCompat.getColor(context, R.color.colorDanger)) - MultiLineIfElse:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$ContextCompat.getColor(context, R.color.colorOnBackground) - MultiLineIfElse:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$ContextCompat.getDrawable(context, R.drawable.ic_delete) - MultiLineIfElse:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$null - NestedBlockDepth:MediaViewHolder.kt$MediaViewHolder$@SuppressLint("SetTextI18n") fun bind(media: Media? = null, batchMode: Boolean = false, doImageFade: Boolean = true) - NestedBlockDepth:UriExtensions.kt$fun Uri.getFilename(applicationContext: Context): String? - NestedBlockDepth:WebDavConduit.kt$WebDavConduit$@Throws(IOException::class) private suspend fun uploadChunked(base: HttpUrl, path: List<String>, fileName: String): Boolean - NoBlankLineBeforeRbrace:AddFolderScreen.kt$ - NoBlankLineBeforeRbrace:BaseDialog.kt$ - NoBlankLineBeforeRbrace:BrowseFolderScreen.kt$ - NoBlankLineBeforeRbrace:CreateNewFolderFragment.kt$CreateNewFolderFragment$ - NoBlankLineBeforeRbrace:ExpandableSpaceList.kt$ - NoBlankLineBeforeRbrace:FileUtils.kt$FileUtils$ - NoBlankLineBeforeRbrace:FolderAdapter.kt$FolderAdapter.ViewHolder$ - NoBlankLineBeforeRbrace:FolderOptionsPopup.kt$ - NoBlankLineBeforeRbrace:HomeActivity.kt$HomeActivity$ - NoBlankLineBeforeRbrace:HomeAppBar.kt$ - NoBlankLineBeforeRbrace:HomeScreen.kt$ - NoBlankLineBeforeRbrace:HomeScreen.kt$HomeViewModel$ - NoBlankLineBeforeRbrace:IaConduit.kt$IaConduit.<no name provided>$ - NoBlankLineBeforeRbrace:InternetArchiveActivity.kt$InternetArchiveActivity$ - NoBlankLineBeforeRbrace:InternetArchiveDetailsScreen.kt$ - NoBlankLineBeforeRbrace:InternetArchiveLoginUseCase.kt$InternetArchiveLoginUseCase$ - NoBlankLineBeforeRbrace:InternetArchiveLoginViewModel.kt$InternetArchiveLoginViewModel$ - NoBlankLineBeforeRbrace:MainActivity.kt$MainActivity$ - NoBlankLineBeforeRbrace:MainBottomBar.kt$ - NoBlankLineBeforeRbrace:MainDrawerContent.kt$ - NoBlankLineBeforeRbrace:MainMediaViewHolder.kt$MainMediaViewHolder$ - NoBlankLineBeforeRbrace:MainMediaViewModel.kt$MainMediaViewModel$ - NoBlankLineBeforeRbrace:MediaCacheScreen.kt$ - NoBlankLineBeforeRbrace:NumericKeypad.kt$ - NoBlankLineBeforeRbrace:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity.<no name provided>$ - NoBlankLineBeforeRbrace:PackageManager.kt$ - NoBlankLineBeforeRbrace:PasscodeEntryScreen.kt$ - NoBlankLineBeforeRbrace:PasscodeEntryViewModel.kt$PasscodeEntryViewModel$ - NoBlankLineBeforeRbrace:Preview.kt$ - NoBlankLineBeforeRbrace:PreviewActivity.kt$PreviewActivity$ - NoBlankLineBeforeRbrace:PreviewViewHolder.kt$PreviewViewHolder$ - NoBlankLineBeforeRbrace:Project.kt$Project$ - NoBlankLineBeforeRbrace:ProofModeSettingsActivity.kt$ProofModeSettingsActivity$ - NoBlankLineBeforeRbrace:RetrofitAPI.kt$RetrofitAPI$ - NoBlankLineBeforeRbrace:SectionViewHolder.kt$SectionViewHolder.Companion$ - NoBlankLineBeforeRbrace:ServerOptionItem.kt$ - NoBlankLineBeforeRbrace:SmartFragmentStatePagerAdapter.kt$SmartFragmentStatePagerAdapter$ - NoBlankLineBeforeRbrace:SnowbirdCreateGroupFragment.kt$SnowbirdCreateGroupFragment$ - NoBlankLineBeforeRbrace:SnowbirdFileRepository.kt$SnowbirdFileRepository$ - NoBlankLineBeforeRbrace:Space.kt$Space$ - NoBlankLineBeforeRbrace:SpaceAdapter.kt$SpaceAdapter.Companion$ - NoBlankLineBeforeRbrace:SpaceListFragment.kt$SpaceListFragment$ - NoBlankLineBeforeRbrace:SpaceListScreen.kt$ - NoBlankLineBeforeRbrace:SpaceSetupActivity.kt$SpaceSetupActivity$ - NoBlankLineBeforeRbrace:SpaceSetupFragment.kt$SpaceSetupFragment$ - NoBlankLineBeforeRbrace:TorStatusContentProvider.kt$TorStatusContentProvider$ - NoBlankLineBeforeRbrace:UiImage.kt$UiImage$ - NoBlankLineBeforeRbrace:WebDavFragment.kt$WebDavFragment$ - NoConsecutiveBlankLines:AddFolderActivity.kt$AddFolderActivity$ - NoConsecutiveBlankLines:AddFolderScreen.kt$ - NoConsecutiveBlankLines:AppLogger.kt$ - NoConsecutiveBlankLines:BadgeDrawable.kt$BadgeDrawable$ - NoConsecutiveBlankLines:BaseButton.kt$ - NoConsecutiveBlankLines:BaseComposeActivity.kt$BaseComposeActivity$ - NoConsecutiveBlankLines:BaseDialog.kt$ - NoConsecutiveBlankLines:BrowseFolderScreen.kt$ - NoConsecutiveBlankLines:BrowseFoldersAdapter.kt$BrowseFoldersAdapter.FolderViewHolder$ - NoConsecutiveBlankLines:BrowseFoldersFragment.kt$ - NoConsecutiveBlankLines:BrowseFoldersFragment.kt$BrowseFoldersFragment$ - NoConsecutiveBlankLines:BrowseFoldersViewModel.kt$ - NoConsecutiveBlankLines:Collection.kt$Collection$ - NoConsecutiveBlankLines:ContentPickerFragment.kt$ContentPickerFragment$ - NoConsecutiveBlankLines:CoreModule.kt$ - NoConsecutiveBlankLines:CustomBottomNavBar.kt$CustomBottomNavBar$ - NoConsecutiveBlankLines:DialogConfigBuilder.kt$ - NoConsecutiveBlankLines:EditFolderActivity.kt$EditFolderActivity$ - NoConsecutiveBlankLines:Effects.kt$ - NoConsecutiveBlankLines:FeaturesModule.kt$ - NoConsecutiveBlankLines:FileUtils.kt$FileUtils$ - NoConsecutiveBlankLines:FolderAdapter.kt$FolderAdapter.ViewHolder$ - NoConsecutiveBlankLines:FolderDrawerAdapter.kt$ - NoConsecutiveBlankLines:FolderDrawerAdapter.kt$FolderDrawerAdapter$ - NoConsecutiveBlankLines:FoldersActivity.kt$FoldersActivity$ - NoConsecutiveBlankLines:GeneralSettingsActivity.kt$ - NoConsecutiveBlankLines:GeneralSettingsActivity.kt$GeneralSettingsActivity$ - NoConsecutiveBlankLines:GeneralSettingsActivity.kt$GeneralSettingsActivity.Fragment$ - NoConsecutiveBlankLines:Hbks.kt$Hbks$ - NoConsecutiveBlankLines:HomeScreen.kt$ - NoConsecutiveBlankLines:IaConduit.kt$IaConduit$ - NoConsecutiveBlankLines:InternetArchive.kt$InternetArchive$ - NoConsecutiveBlankLines:InternetArchiveActivity.kt$InternetArchiveActivity$ - NoConsecutiveBlankLines:InternetArchiveDetailsScreen.kt$ - NoConsecutiveBlankLines:InternetArchiveDetailsState.kt$ - NoConsecutiveBlankLines:MainActivity.kt$ - NoConsecutiveBlankLines:MainActivity.kt$MainActivity$ - NoConsecutiveBlankLines:MainDrawerContent.kt$ - NoConsecutiveBlankLines:MainMediaAdapter.kt$MainMediaAdapter$ - NoConsecutiveBlankLines:MainMediaAdapterTest.kt$ - NoConsecutiveBlankLines:MainMediaAdapterTest.kt$MainMediaAdapterTest$ - NoConsecutiveBlankLines:MainMediaFragment.kt$MainMediaFragment$ - NoConsecutiveBlankLines:MainMediaScreen.kt$ - NoConsecutiveBlankLines:MainMediaViewHolder.kt$MainMediaViewHolder$ - NoConsecutiveBlankLines:MainMediaViewModel.kt$ - NoConsecutiveBlankLines:MainViewModel.kt$MainViewModel$ - NoConsecutiveBlankLines:Media.kt$Media$ - NoConsecutiveBlankLines:Media.kt$Media.Companion$ - NoConsecutiveBlankLines:MediaAdapter.kt$MediaAdapter$ - NoConsecutiveBlankLines:MediaCacheScreen.kt$ - NoConsecutiveBlankLines:MediaViewHolder.kt$MediaViewHolder$ - NoConsecutiveBlankLines:Notifier.kt$ - NoConsecutiveBlankLines:NumericKeypad.kt$ - NoConsecutiveBlankLines:Onboarding23SlideFragment.kt$Onboarding23SlideFragment$ - NoConsecutiveBlankLines:PasscodeEntryActivity.kt$PasscodeEntryActivity$ - NoConsecutiveBlankLines:PasscodeEntryScreen.kt$ - NoConsecutiveBlankLines:PasscodeSetupActivity.kt$PasscodeSetupActivity$ - NoConsecutiveBlankLines:PasscodeSetupScreen.kt$ - NoConsecutiveBlankLines:PreviewActivity.kt$PreviewActivity$ - NoConsecutiveBlankLines:PreviewViewHolder.kt$PreviewViewHolder$ - NoConsecutiveBlankLines:ProofModeScreen.kt$ - NoConsecutiveBlankLines:ProofModeSettingsActivity.kt$ - NoConsecutiveBlankLines:ProofModeSettingsActivity.kt$ProofModeSettingsActivity$ - NoConsecutiveBlankLines:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Fragment$ - NoConsecutiveBlankLines:ReviewActivity.kt$ReviewActivity$ - NoConsecutiveBlankLines:SettingsFragment.kt$SettingsFragment$ - NoConsecutiveBlankLines:SettingsScreen.kt$ - NoConsecutiveBlankLines:SnowbirdCreateGroupFragment.kt$SnowbirdCreateGroupFragment$ - NoConsecutiveBlankLines:SnowbirdFileItem.kt$ - NoConsecutiveBlankLines:SnowbirdJoinGroupFragment.kt$SnowbirdJoinGroupFragment.Companion$ - NoConsecutiveBlankLines:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment$ - NoConsecutiveBlankLines:SpaceAdapter.kt$SpaceAdapter.ViewHolder$ - NoConsecutiveBlankLines:SpaceDrawerAdapter.kt$SpaceDrawerAdapter$ - NoConsecutiveBlankLines:SpaceListFragment.kt$SpaceListFragment$ - NoConsecutiveBlankLines:SpaceListScreen.kt$ - NoConsecutiveBlankLines:SpaceSetupActivity.kt$SpaceSetupActivity$ - NoConsecutiveBlankLines:SpaceSetupScreen.kt$ - NoConsecutiveBlankLines:Theme.kt$ - NoConsecutiveBlankLines:UiImage.kt$ - NoConsecutiveBlankLines:UiImage.kt$UiImage$ - NoConsecutiveBlankLines:View.kt$ - NoConsecutiveBlankLines:ViewExtension.kt$ - NoConsecutiveBlankLines:WebDavConduit.kt$ - NoConsecutiveBlankLines:WebDavFragment.kt$WebDavFragment$ - NoConsecutiveBlankLines:WebDavSetupLicenseFragment.kt$WebDavSetupLicenseFragment$ - NoEmptyClassBody:MainMediaViewModel.kt$MainMediaViewModel${ } - NoEmptyFirstLineInMethodBlock:AddFolderActivity.kt$AddFolderActivity$ - NoEmptyFirstLineInMethodBlock:AddFolderScreen.kt$ - NoEmptyFirstLineInMethodBlock:AppLogger.kt$AppLogger$ - NoEmptyFirstLineInMethodBlock:BaseButton.kt$ - NoEmptyFirstLineInMethodBlock:BaseDialog.kt$ - NoEmptyFirstLineInMethodBlock:BrowseFolderScreen.kt$ - NoEmptyFirstLineInMethodBlock:BrowseFoldersAdapter.kt$BrowseFoldersAdapter.FolderViewHolder$ - NoEmptyFirstLineInMethodBlock:CreateNewFolderFragment.kt$CreateNewFolderFragment$ - NoEmptyFirstLineInMethodBlock:DefaultScaffold.kt$ - NoEmptyFirstLineInMethodBlock:DialogConfigBuilder.kt$DialogBuilder$ - NoEmptyFirstLineInMethodBlock:ExpandableSpaceList.kt$ - NoEmptyFirstLineInMethodBlock:FileUtils.kt$FileUtils$ - NoEmptyFirstLineInMethodBlock:FolderAdapter.kt$FolderAdapter.ViewHolder$ - NoEmptyFirstLineInMethodBlock:FolderDrawerAdapter.kt$FolderDrawerAdapter.FolderViewHolder$ - NoEmptyFirstLineInMethodBlock:FolderOptionsPopup.kt$ - NoEmptyFirstLineInMethodBlock:FoldersActivity.kt$FoldersActivity$ - NoEmptyFirstLineInMethodBlock:HomeAppBar.kt$ - NoEmptyFirstLineInMethodBlock:HomeScreen.kt$ - NoEmptyFirstLineInMethodBlock:InternetArchiveActivity.kt$InternetArchiveActivity$ - NoEmptyFirstLineInMethodBlock:InternetArchiveDetailsScreen.kt$ - NoEmptyFirstLineInMethodBlock:InternetArchiveFragment.kt$InternetArchiveFragment$ - NoEmptyFirstLineInMethodBlock:InternetArchiveLoginScreen.kt$ - NoEmptyFirstLineInMethodBlock:MainActivity.kt$MainActivity$ - NoEmptyFirstLineInMethodBlock:MainBottomBar.kt$ - NoEmptyFirstLineInMethodBlock:MainDrawerContent.kt$ - NoEmptyFirstLineInMethodBlock:MainMediaViewHolder.kt$MainMediaViewHolder$ - NoEmptyFirstLineInMethodBlock:NumericKeypad.kt$ - NoEmptyFirstLineInMethodBlock:PasscodeDots.kt$ - NoEmptyFirstLineInMethodBlock:PasscodeEntryScreen.kt$ - NoEmptyFirstLineInMethodBlock:PasscodeEntryViewModel.kt$PasscodeEntryViewModel$ - NoEmptyFirstLineInMethodBlock:PasscodeSetupScreen.kt$ - NoEmptyFirstLineInMethodBlock:PasscodeSetupViewModel.kt$PasscodeSetupViewModel$ - NoEmptyFirstLineInMethodBlock:Picker.kt$Picker$ - NoEmptyFirstLineInMethodBlock:Preview.kt$ - NoEmptyFirstLineInMethodBlock:PreviewActivity.kt$PreviewActivity$ - NoEmptyFirstLineInMethodBlock:PreviewViewHolder.kt$PreviewViewHolder$ - NoEmptyFirstLineInMethodBlock:PrimaryButton.kt$ - NoEmptyFirstLineInMethodBlock:ProofModeScreen.kt$ - NoEmptyFirstLineInMethodBlock:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Fragment$ - NoEmptyFirstLineInMethodBlock:SaveClient.kt$SaveClient.Companion$ - NoEmptyFirstLineInMethodBlock:ServerOptionItem.kt$ - NoEmptyFirstLineInMethodBlock:SettingsScreen.kt$ - NoEmptyFirstLineInMethodBlock:SnowbirdFragment.kt$SnowbirdFragment$ - NoEmptyFirstLineInMethodBlock:SnowbirdGroupListAdapter.kt$SnowbirdGroupsAdapter.ViewHolder$ - NoEmptyFirstLineInMethodBlock:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment$ - NoEmptyFirstLineInMethodBlock:Space.kt$Space$ - NoEmptyFirstLineInMethodBlock:SpaceAdapter.kt$SpaceAdapter.ViewHolder$ - NoEmptyFirstLineInMethodBlock:SpaceDrawerAdapter.kt$SpaceDrawerAdapter.SpaceViewHolder$ - NoEmptyFirstLineInMethodBlock:SpaceListFragment.kt$SpaceListFragment$ - NoEmptyFirstLineInMethodBlock:SpaceListScreen.kt$ - NoEmptyFirstLineInMethodBlock:SpaceSetupFragment.kt$SpaceSetupFragment$ - NoEmptyFirstLineInMethodBlock:UploadService.kt$UploadService$ - NoEmptyFirstLineInMethodBlock:WebDavFragment.kt$WebDavFragment$ - NoEmptyFirstLineInMethodBlock:WebDavSetupLicenseFragment.kt$WebDavSetupLicenseFragment$ - NoMultipleSpaces:CleanInsightsManager.kt$CleanInsightsManager$ - NoMultipleSpaces:DialogConfigBuilder.kt$ - NoMultipleSpaces:GDriveConduit.kt$GDriveConduit$ - NoMultipleSpaces:Media.kt$Media$ - NoMultipleSpaces:NumericKeypad.kt$ - NoMultipleSpaces:PreviewAdapter.kt$PreviewAdapter$ - NoMultipleSpaces:RequestBodyUtil.kt$<no name provided>$ - NoMultipleSpaces:ScryptHashingStrategy.kt$ScryptHashingStrategy.Companion$ - NoMultipleSpaces:SnowbirdFragment.kt$SnowbirdFragment$ - NoMultipleSpaces:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$ - NoUnusedImports:AddFolderScreen.kt$net.opendasharchive.openarchive.features.folders.AddFolderScreen.kt - NoUnusedImports:AppLogger.kt$net.opendasharchive.openarchive.core.logger.AppLogger.kt - NoUnusedImports:BaseComposeActivity.kt$net.opendasharchive.openarchive.features.core.BaseComposeActivity.kt - NoUnusedImports:BaseDialog.kt$net.opendasharchive.openarchive.features.core.dialog.BaseDialog.kt - NoUnusedImports:BottomSheetExtensions.kt$net.opendasharchive.openarchive.extensions.BottomSheetExtensions.kt - NoUnusedImports:BrowseFolderScreen.kt$net.opendasharchive.openarchive.features.folders.BrowseFolderScreen.kt - NoUnusedImports:BrowseFoldersFragment.kt$net.opendasharchive.openarchive.features.folders.BrowseFoldersFragment.kt - NoUnusedImports:CoreModule.kt$net.opendasharchive.openarchive.core.di.CoreModule.kt - NoUnusedImports:CreateNewFolderFragment.kt$net.opendasharchive.openarchive.features.folders.CreateNewFolderFragment.kt - NoUnusedImports:CustomButton.kt$net.opendasharchive.openarchive.features.main.ui.CustomButton.kt - NoUnusedImports:DialogConfigBuilder.kt$net.opendasharchive.openarchive.features.core.dialog.DialogConfigBuilder.kt - NoUnusedImports:ExpandableSpaceList.kt$net.opendasharchive.openarchive.features.main.ui.components.ExpandableSpaceList.kt - NoUnusedImports:FeaturesModule.kt$net.opendasharchive.openarchive.core.di.FeaturesModule.kt - NoUnusedImports:FullscreenDimmingOverlay.kt$net.opendasharchive.openarchive.util.FullscreenDimmingOverlay.kt - NoUnusedImports:HomeActivity.kt$net.opendasharchive.openarchive.features.main.HomeActivity.kt - NoUnusedImports:InternetArchiveDetailsScreen.kt$net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsScreen.kt - NoUnusedImports:InternetArchiveLocalSource.kt$net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveLocalSource.kt - NoUnusedImports:InternetArchiveLoginScreen.kt$net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginScreen.kt - NoUnusedImports:MainActivity.kt$net.opendasharchive.openarchive.features.main.MainActivity.kt - NoUnusedImports:MainMediaScreen.kt$net.opendasharchive.openarchive.features.main.ui.MainMediaScreen.kt - NoUnusedImports:MainMediaViewHolder.kt$net.opendasharchive.openarchive.features.main.adapters.MainMediaViewHolder.kt - NoUnusedImports:MediaCacheScreen.kt$net.opendasharchive.openarchive.features.main.ui.MediaCacheScreen.kt - NoUnusedImports:MediaViewHolder.kt$net.opendasharchive.openarchive.db.MediaViewHolder.kt - NoUnusedImports:NumericKeypad.kt$net.opendasharchive.openarchive.features.settings.passcode.components.NumericKeypad.kt - NoUnusedImports:PasscodeEntryScreen.kt$net.opendasharchive.openarchive.features.settings.passcode.passcode_entry.PasscodeEntryScreen.kt - NoUnusedImports:PasscodeSetupActivity.kt$net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupActivity.kt - NoUnusedImports:PasscodeSetupScreen.kt$net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupScreen.kt - NoUnusedImports:ProofModeSettingsActivity.kt$net.opendasharchive.openarchive.features.settings.ProofModeSettingsActivity.kt - NoUnusedImports:RequestBodyUtil.kt$net.opendasharchive.openarchive.services.internetarchive.RequestBodyUtil.kt - NoUnusedImports:RestEndpointTask.kt$net.opendasharchive.openarchive.features.main.RestEndpointTask.kt - NoUnusedImports:ReviewActivity.kt$net.opendasharchive.openarchive.features.media.ReviewActivity.kt - NoUnusedImports:SaveApp.kt$net.opendasharchive.openarchive.SaveApp.kt - NoUnusedImports:ServerOptionItem.kt$net.opendasharchive.openarchive.features.spaces.ServerOptionItem.kt - NoUnusedImports:SettingsFragment.kt$net.opendasharchive.openarchive.features.settings.SettingsFragment.kt - NoUnusedImports:SettingsScreen.kt$net.opendasharchive.openarchive.features.settings.SettingsScreen.kt - NoUnusedImports:SpaceListFragment.kt$net.opendasharchive.openarchive.features.spaces.SpaceListFragment.kt - NoUnusedImports:SpaceListScreen.kt$net.opendasharchive.openarchive.features.spaces.SpaceListScreen.kt - NoUnusedImports:SpaceSetupActivity.kt$net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity.kt - NoUnusedImports:SpaceSetupFragment.kt$net.opendasharchive.openarchive.features.settings.SpaceSetupFragment.kt - NoUnusedImports:UnixSocketClient.kt$net.opendasharchive.openarchive.features.main.UnixSocketClient.kt - NoUnusedImports:UploadManagerActivity.kt$net.opendasharchive.openarchive.upload.UploadManagerActivity.kt - NoUnusedImports:UploadManagerFragment.kt$net.opendasharchive.openarchive.upload.UploadManagerFragment.kt - NoWildcardImports:BadgeDrawable.kt$import android.graphics.* - NoWildcardImports:CleanInsightsManager.kt$import org.cleaninsights.sdk.* - NoWildcardImports:Hbks.kt$import java.security.* - NoWildcardImports:Hbks.kt$import javax.crypto.* - NoWildcardImports:IaConduit.kt$import okhttp3.* - NoWildcardImports:MediaCacheScreen.kt$import androidx.compose.foundation.layout.* - NoWildcardImports:RequestBodyUtil.kt$import java.io.* - NoWildcardImports:UploadService.kt$import android.app.* - PackageName:PasscodeEntryActivity.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_entry - PackageName:PasscodeEntryScreen.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_entry - PackageName:PasscodeEntryViewModel.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_entry - PackageName:PasscodeSetupActivity.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_setup - PackageName:PasscodeSetupScreen.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_setup - PackageName:PasscodeSetupViewModel.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_setup - PackageNaming:PasscodeEntryActivity.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_entry - PackageNaming:PasscodeEntryScreen.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_entry - PackageNaming:PasscodeEntryViewModel.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_entry - PackageNaming:PasscodeSetupActivity.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_setup - PackageNaming:PasscodeSetupScreen.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_setup - PackageNaming:PasscodeSetupViewModel.kt$package net.opendasharchive.openarchive.features.settings.passcode.passcode_setup - ParameterListWrapping:AddMediaDialogFragment.kt$AddMediaDialogFragment$( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ) - ParameterListWrapping:AlertHelper.kt$AlertHelper.Companion$( context: Context, message: Int?, title: Int? = R.string.error, icon: Int? = null, buttons: List<Button>? = listOf(Button()) ) - ParameterListWrapping:AlertHelper.kt$AlertHelper.Companion$( context: Context, message: String? = null, title: Int? = R.string.error, icon: Int? = null, buttons: List<Button>? = listOf(Button()) ) - ParameterListWrapping:BiometricAuthenticator.kt$BiometricAuthenticator$( private val activity: BaseActivity, private val config: AppConfig ) - ParameterListWrapping:DialogConfigBuilder.kt$( resourceProvider: ResourceProvider = this.requireResourceProvider(), block: DialogBuilder.() -> Unit) - ParameterListWrapping:DialogConfigBuilder.kt$(resourceProvider: ResourceProvider = this.requireResourceProvider(), block: DialogBuilder.() -> Unit) - ParameterListWrapping:FileUtils.kt$FileUtils$( context: Context, uri: Uri, selection: String?, selectionArgs: Array<String>?) - ParameterListWrapping:FileUtils.kt$FileUtils$(context: Context, uri: Uri, selection: String?, selectionArgs: Array<String>?) - ParameterListWrapping:GDriveFragment.kt$GDriveFragment$( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ) - ParameterListWrapping:Hbks.kt$Hbks$( ciphertext: ByteArray?, key: SecretKey?, activity: FragmentActivity? = null, completed: (plaintext: String?, exception: Exception?) -> Unit) - ParameterListWrapping:Hbks.kt$Hbks$( plaintext: String?, key: SecretKey?, activity: FragmentActivity? = null, completed: (ciphertext: ByteArray?, exception: Exception?) -> Unit) - ParameterListWrapping:Hbks.kt$Hbks$(ciphertext: ByteArray?, key: SecretKey?, activity: FragmentActivity? = null, completed: (plaintext: String?, exception: Exception?) -> Unit) - ParameterListWrapping:Hbks.kt$Hbks$(plaintext: String?, key: SecretKey?, activity: FragmentActivity? = null, completed: (ciphertext: ByteArray?, exception: Exception?) -> Unit) - ParameterListWrapping:InternetArchiveLoginScreen.kt$( state: InternetArchiveLoginState, dispatch: Dispatch<Action> ) - ParameterListWrapping:Onboarding23SlideFragment.kt$Onboarding23SlideFragment$( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ) - ParameterListWrapping:RequestBodyUtil.kt$( cancellable: () -> Boolean, onProgress: (Long) -> Unit = { }, onComplete: () -> Unit = {}) - ParameterListWrapping:RequestBodyUtil.kt$(cancellable: () -> Boolean, onProgress: (Long) -> Unit = { }, onComplete: () -> Unit = {}) - ParameterListWrapping:RequestBodyUtil.kt$RequestBodyUtil$( mediaType: MediaType?, inputStream: InputStream, contentLength: Long? = null, listener: RequestListener? ) - ParameterListWrapping:SnowbirdFileRepository.kt$ISnowbirdFileRepository$( groupKey: String, repoKey: String, forceRefresh: Boolean = false) - ParameterListWrapping:SnowbirdFileRepository.kt$ISnowbirdFileRepository$(groupKey: String, repoKey: String, forceRefresh: Boolean = false) - ParameterListWrapping:SnowbirdFileRepository.kt$SnowbirdFileRepository$( groupKey: String, repoKey: String, forceRefresh: Boolean) - ParameterListWrapping:SnowbirdFileRepository.kt$SnowbirdFileRepository$(groupKey: String, repoKey: String, forceRefresh: Boolean) - ParameterListWrapping:SpaceSetupSuccessFragment.kt$SpaceSetupSuccessFragment$( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ) - ParameterListWrapping:WebDavFragment.kt$WebDavFragment$( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ) - ParameterNaming:BaseDialog.kt$onCheckBoxStateChanged - ParameterNaming:HomeScreen.kt$onFolderSelected - ParameterNaming:MainDrawerContent.kt$onSelected - ParameterNaming:SpaceListScreen.kt$onSpaceClicked - ParameterNaming:SpaceSetupScreen.kt$onDwebClicked - PrintStackTrace:SnowbirdFileRepository.kt$SnowbirdFileRepository$e - PrintStackTrace:SnowbirdGroupRepository.kt$SnowbirdGroupRepository$e - PrintStackTrace:SnowbirdService.kt$SnowbirdService$e - PrintStackTrace:UnixSocketClient.kt$UnixSocketClient$e - PrintStackTrace:VideoRequestHandler.kt$VideoRequestHandler$throwable - RethrowCaughtException:UnixSocketClientUtilityExtensions.kt$throw e - ReturnCount:BrowseFoldersFragment.kt$BrowseFoldersFragment$private fun addFolder(folder: Folder?) - ReturnCount:Conduit.kt$Conduit$fun getProof(): Array<out File> - ReturnCount:CreateNewFolderFragment.kt$CreateNewFolderFragment$private fun store() - ReturnCount:EmptyableRecyclerView.kt$EmptyableRecyclerView$private fun findSuitableParent(): ViewGroup? - ReturnCount:FileUtils.kt$FileUtils$@SuppressLint("NewAPI", "LogNotTimber") fun getPath(context: Context, uri: Uri): String? - ReturnCount:FolderAdapter.kt$FolderAdapter.Companion$fun getColorOld(context: Context, highlight: Boolean): Int - ReturnCount:GDriveConduit.kt$GDriveConduit$override suspend fun upload(): Boolean - ReturnCount:Hbks.kt$Hbks$@RequiresApi(Build.VERSION_CODES.M) fun decrypt( ciphertext: ByteArray?, key: SecretKey?, activity: FragmentActivity? = null, completed: (plaintext: String?, exception: Exception?) -> Unit ) - ReturnCount:Hbks.kt$Hbks$@RequiresApi(Build.VERSION_CODES.M) fun encrypt( plaintext: String?, key: SecretKey?, activity: FragmentActivity? = null, completed: (ciphertext: ByteArray?, exception: Exception?) -> Unit ) - ReturnCount:Hbks.kt$Hbks$fun biometryType(context: Context): BiometryType - ReturnCount:Hbks.kt$Hbks$fun deviceAvailablity(context: Context): Availability - ReturnCount:MainActivity.kt$MainActivity$private fun importSharedMedia(imageIntent: Intent?) - ReturnCount:Onboarding23FragmentStateAdapter.kt$Onboarding23FragmentStateAdapter$override fun createFragment(position: Int): Fragment - ReturnCount:PasscodeRepository.kt$PasscodeRepository$fun isLockedOut(): Boolean - ReturnCount:Picker.kt$Picker$fun import(context: Context, project: Project?, uri: Uri): Media? - ReturnCount:UploadManagerActivity.kt$UploadManagerActivity$override fun onOptionsItemSelected(item: MenuItem): Boolean - ReturnCount:UploadService.kt$UploadService$private fun isNetworkAvailable(requireUnmetered: Boolean): Boolean - ReturnCount:Utility.kt$Utility$fun writeStreamToFile(input: InputStream?, file: File?): Boolean - ReturnCount:WebDavConduit.kt$WebDavConduit$@Throws(IOException::class) private suspend fun uploadChunked(base: HttpUrl, path: List<String>, fileName: String): Boolean - ReturnCount:WebDavConduit.kt$WebDavConduit$override suspend fun upload(): Boolean - SpacingAroundColon:ApiError.kt$ApiError$: - SpacingAroundColon:ConsentActivity.kt$ConsentActivity$: - SpacingAroundColon:ContentPickerFragment.kt$ContentPickerFragment$: - SpacingAroundColon:GeneralSettingsActivity.kt$GeneralSettingsActivity$: - SpacingAroundColon:GeneralSettingsActivity.kt$GeneralSettingsActivity.Fragment$: - SpacingAroundColon:Hbks.kt$Hbks.Availability.Enroll$: - SpacingAroundColon:HomeActivity.kt$HomeActivity$: - SpacingAroundColon:HomeScreen.kt$HomeScreenAction.AddMediaClicked$: - SpacingAroundColon:JoinGroupResponse.kt$JoinGroupResponse$: - SpacingAroundColon:PasscodeEntryViewModel.kt$PasscodeEntryScreenAction.OnSubmit$: - SpacingAroundColon:PasscodeManager.kt$PasscodeManager$: - SpacingAroundColon:PasscodeSetupViewModel.kt$PasscodeSetupUiAction.OnSubmit$: - SpacingAroundColon:PreviewAdapter.kt$PreviewAdapter$: - SpacingAroundColon:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Fragment$: - SpacingAroundColon:RequestNameDTO.kt$MembershipRequest$: - SpacingAroundColon:RequestNameDTO.kt$RequestName$: - SpacingAroundColon:SaveClient.kt$SaveClient.OrbotException$: - SpacingAroundColon:SettingsFragment.kt$SettingsFragment$: - SpacingAroundColon:SnowbirdCreateGroupFragment.kt$SnowbirdCreateGroupFragment$: - SpacingAroundColon:SnowbirdError.kt$SnowbirdError$: - SpacingAroundColon:SnowbirdFileItem.kt$SnowbirdFileItem$: - SpacingAroundColon:SnowbirdGroupOverviewFragment.kt$SnowbirdGroupOverviewFragment$: - SpacingAroundColon:SnowbirdJoinGroupFragment.kt$SnowbirdJoinGroupFragment$: - SpacingAroundColon:SnowbirdRepoListAdapter.kt$SnowbirdRepoListAdapter$: - SpacingAroundColon:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment$: - SpacingAroundColon:SnowbirdShareFragment.kt$SnowbirdShareFragment$: - SpacingAroundColon:SwipeToDeleteCallback.kt$SwipeToDeleteCallback$: - SpacingAroundColon:UnixSocketAPI.kt$UnixSocketAPI$: - SpacingAroundColon:WebDavSetupLicenseFragment.kt$WebDavSetupLicenseFragment$: - SpacingAroundKeyword:BrowseFoldersViewModel.kt$BrowseFoldersViewModel$else - SpacingAroundKeyword:Context.kt$catch - SpacingAroundKeyword:Drawable.kt$else - SpacingAroundKeyword:DrawableExtensions.kt$else - SpacingAroundKeyword:GDriveFragment.kt$GDriveFragment$if - SpacingAroundKeyword:Hbks.kt$Hbks$catch - SpacingAroundKeyword:Hbks.kt$Hbks$else - SpacingAroundKeyword:InternetArchiveDetailsViewModel.kt$InternetArchiveDetailsViewModel$when - SpacingAroundKeyword:PackageManager.kt$else - SpacingAroundKeyword:Picker.kt$Picker$else - SpacingAroundKeyword:ProofModeHelper.kt$ProofModeHelper$catch - SpacingAroundKeyword:ProofModeHelper.kt$ProofModeHelper$else - SpacingAroundKeyword:ReviewActivity.kt$ReviewActivity$else - SpacingAroundKeyword:ReviewActivity.kt$ReviewActivity.<no name provided>$else - SpacingAroundKeyword:SaveClient.kt$SaveClient.Companion$else - SpacingAroundKeyword:SaveClient.kt$SaveClient.Companion.<no name provided>$else - SpacingAroundKeyword:SpaceAdapter.kt$SpaceAdapter$else - SpacingAroundKeyword:SpaceDrawerAdapter.kt$SpaceDrawerAdapter.SpaceViewHolder$if - SpacingAroundKeyword:UploadManagerActivity.kt$UploadManagerActivity$else - SpacingAroundKeyword:UploadManagerActivity.kt$UploadManagerActivity.<no name provided>$else - SpacingAroundKeyword:Util.kt$Util$else - SpacingAroundKeyword:Utility.kt$Utility$catch - SpacingAroundKeyword:Utility.kt$Utility$finally - SpacingAroundKeyword:View.kt$ViewHelper$else - SpacingAroundKeyword:View.kt$else - SpacingAroundKeyword:ViewExtension.kt$ViewHelper$else - SpacingAroundKeyword:ViewExtension.kt$else - SpacingAroundKeyword:WebDavConduit.kt$WebDavConduit$catch - SpacingAroundKeyword:WebDavFragment.kt$WebDavFragment.<no name provided>$if - SpacingAroundKeyword:WebDavSetupLicenseFragment.kt$WebDavSetupLicenseFragment$if - SpacingAroundOperators:MediaCacheScreen.kt$= - SpacingAroundOperators:PasscodeSetupViewModel.kt$PasscodeSetupViewModel$-> - SpacingAroundParens:FileUploadResult.kt$FileUploadResult$( - SpacingAroundParens:Picker.kt$Picker$( - SpacingAroundParens:ReviewActivity.kt$ReviewActivity$( - SpacingAroundParens:SnowbirdConduit.kt$SnowbirdConduit$( - SpacingAroundParens:SnowbirdFileListAdapter.kt$SnowbirdFileListAdapter$( - SpacingAroundParens:WebDAVModel.kt$BackendCapabilities$( - SpacingAroundParens:WebDAVModel.kt$Data$( - SpacingAroundParens:WebDAVModel.kt$Meta$( - SpacingAroundParens:WebDAVModel.kt$Ocs$( - SpacingAroundParens:WebDAVModel.kt$Quota$( - SpacingAroundParens:WebDAVModel.kt$WebDAVModel$( - SpacingBetweenDeclarationsWithAnnotations:BasicAuthInterceptor.kt$BasicAuthInterceptor$@Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response - SpacingBetweenDeclarationsWithAnnotations:Hbks.kt$Hbks.Availability$Enroll : Availability - SpacingBetweenDeclarationsWithAnnotations:Media.kt$Media.Status$DeleteRemote : Status - SpacingBetweenDeclarationsWithAnnotations:Media.kt$Media.Status$Published : Status - SpacingBetweenDeclarationsWithAnnotations:VideoRequestHandler.kt$VideoRequestHandler.Companion$@Throws(Throwable::class) fun retrieveVideoFrameFromVideo(context: Context?, videoPath: Uri?): Bitmap? - SpacingBetweenDeclarationsWithComments:Prefs.kt$Prefs$// private const val USE_NEXTCLOUD_CHUNKING = "upload_nextcloud_chunks" - SpacingBetweenDeclarationsWithComments:UnixSocketClient.kt$UnixSocketClient$// val socketPath: String = File(context.filesDir, "rust_server.sock").absolutePath - SpreadOperator:GDriveConduit.kt$GDriveConduit.Companion$( GoogleSignIn.getLastSignedInAccount(context), *SCOPES ) - SpreadOperator:GDriveFragment.kt$GDriveFragment$( requireActivity(), REQUEST_CODE_GOOGLE_AUTH, GoogleSignIn.getLastSignedInAccount(requireActivity()), *GDriveConduit.SCOPES ) - StringTemplate:Hbks.kt$Hbks$${algorithm} - StringTemplate:Hbks.kt$Hbks$${blockMode} - StringTemplate:Hbks.kt$Hbks$${padding} - StringTemplate:MainMediaViewHolder.kt$MainMediaViewHolder$${progressValue} - StringTemplate:MediaViewHolder.kt$MediaViewHolder$${progressValue} - StringTemplate:PreviewViewHolder.kt$PreviewViewHolder$${progressValue} - StringTemplate:Utility.kt$Utility$${appId} - SwallowedException:Context.kt$e: ActivityNotFoundException - SwallowedException:PackageManager.kt$e: PackageManager.NameNotFoundException - SwallowedException:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Companion$ioe: IOException - SwallowedException:RestEndpointTask.kt$RestEndpointTask$e: Exception - SwallowedException:SnowbirdFileItem.kt$SnowbirdFileItem.Companion$e: SQLiteException - SwallowedException:SnowbirdFileViewModel.kt$SnowbirdFileViewModel$e: TimeoutCancellationException - SwallowedException:SnowbirdGroup.kt$SnowbirdGroup.Companion$e: SQLiteException - SwallowedException:SnowbirdGroupViewModel.kt$SnowbirdGroupViewModel$e: TimeoutCancellationException - SwallowedException:SnowbirdRepoViewModel.kt$SnowbirdRepoViewModel$e: TimeoutCancellationException - SwallowedException:UnixSocketClient.kt$UnixSocketClient$e: Exception - SwallowedException:UnixSocketClientFileExtensions.kt$e: Exception - SwallowedException:VideoRequestHandler.kt$VideoRequestHandler.Companion$e: Exception - ThrowingExceptionsWithoutMessageOrCause:Hbks.kt$Hbks$NullPointerException() - ThrowingExceptionsWithoutMessageOrCause:Onboarding23FragmentStateAdapter.kt$Onboarding23FragmentStateAdapter$IndexOutOfBoundsException() - ThrowsCount:UnixSocketClient.kt$UnixSocketClient$fun <REQUEST : SerializableMarker, RESPONSE : Any> sendRequestInternal( endpoint: String, method: HttpMethod, body: REQUEST?, serialize: (REQUEST) -> String, deserialize: (String) -> RESPONSE ): RESPONSE - TooGenericExceptionCaught:BrowseFoldersViewModel.kt$BrowseFoldersViewModel$e: Throwable - TooGenericExceptionCaught:GDriveConduit.kt$GDriveConduit$e: Exception - TooGenericExceptionCaught:Hbks.kt$Hbks$e: Exception - TooGenericExceptionCaught:IaConduit.kt$IaConduit$e: Throwable - TooGenericExceptionCaught:MainMediaViewHolder.kt$MainMediaViewHolder$e: Throwable - TooGenericExceptionCaught:MediaViewHolder.kt$MediaViewHolder$e: Throwable - TooGenericExceptionCaught:Picker.kt$Picker$e: Exception - TooGenericExceptionCaught:PreviewViewHolder.kt$PreviewViewHolder$e: Throwable - TooGenericExceptionCaught:ProofModeHelper.kt$ProofModeHelper$e: Exception - TooGenericExceptionCaught:RestEndpointTask.kt$RestEndpointTask$e: Exception - TooGenericExceptionCaught:SnowbirdFileRepository.kt$SnowbirdFileRepository$e: Exception - TooGenericExceptionCaught:SnowbirdGroupRepository.kt$SnowbirdGroupRepository$e: Exception - TooGenericExceptionCaught:SnowbirdRepoRepository.kt$SnowbirdRepoRepository$e: Exception - TooGenericExceptionCaught:SnowbirdService.kt$SnowbirdService$e: Exception - TooGenericExceptionCaught:StringExtensions.kt$e: Exception - TooGenericExceptionCaught:SuspendableExtensions.kt$e: Throwable - TooGenericExceptionCaught:UnixSocketClient.kt$UnixSocketClient$e: Exception - TooGenericExceptionCaught:UnixSocketClientFileExtensions.kt$e: Exception - TooGenericExceptionCaught:VideoRequestHandler.kt$VideoRequestHandler$throwable: Throwable - TooGenericExceptionCaught:VideoRequestHandler.kt$VideoRequestHandler.Companion$e: Exception - TooGenericExceptionCaught:WebDavConduit.kt$WebDavConduit$e: Throwable - TooGenericExceptionThrown:Conduit.kt$Conduit$throw Exception("Cancelled") - TooGenericExceptionThrown:GDriveConduit.kt$GDriveConduit$throw Exception("Cancelled") - TooGenericExceptionThrown:GDriveConduit.kt$GDriveConduit.Companion$throw Exception("could not create folders $destinationPath") - TooGenericExceptionThrown:IaConduit.kt$IaConduit$throw RuntimeException("${result.code}: ${result.message}") - TooGenericExceptionThrown:VideoRequestHandler.kt$VideoRequestHandler.Companion$throw Throwable("Exception in retrieveVideoFrameFromVideo(String videoPath)" + e.message) - TooGenericExceptionThrown:WebDavConduit.kt$WebDavConduit$throw Exception("Cancelled") - TooManyFunctions:AppLogger.kt$AppLogger - TooManyFunctions:Conduit.kt$Conduit - TooManyFunctions:FoldersActivity.kt$FoldersActivity : BaseActivityFolderAdapterListener - TooManyFunctions:HomeActivity.kt$HomeActivity : FragmentActivity - TooManyFunctions:MainActivity.kt$MainActivity : BaseActivitySpaceDrawerAdapterListenerFolderDrawerAdapterListener - TooManyFunctions:MainMediaAdapter.kt$MainMediaAdapter : Adapter - TooManyFunctions:MainMediaFragment.kt$MainMediaFragment : Fragment - TooManyFunctions:MainMediaScreen.kt$net.opendasharchive.openarchive.features.main.ui.MainMediaScreen.kt - TooManyFunctions:MediaAdapter.kt$MediaAdapter : Adapter - TooManyFunctions:PasscodeRepository.kt$PasscodeRepository - TooManyFunctions:PreviewActivity.kt$PreviewActivity : BaseActivityOnClickListenerListener - TooManyFunctions:SnowbirdCreateGroupFragment.kt$SnowbirdCreateGroupFragment : BaseFragment - TooManyFunctions:SnowbirdFileListFragment.kt$SnowbirdFileListFragment : BaseFragment - TooManyFunctions:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment : BaseFragment - TooManyFunctions:SnowbirdJoinGroupFragment.kt$SnowbirdJoinGroupFragment : BaseFragment - TooManyFunctions:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment : BaseFragment - TooManyFunctions:SnowbirdService.kt$SnowbirdService : Service - TooManyFunctions:WebDavFragment.kt$WebDavFragment : BaseFragment - UnusedParameter:AppLogger.kt$AppLogger$context: Context - UnusedParameter:AppLogger.kt$AppLogger$initDebugger: Boolean - UnusedParameter:BrowseFolderScreen.kt$onClick: () -> Unit - UnusedParameter:BrowseFoldersViewModel.kt$BrowseFoldersViewModel$space: Space - UnusedParameter:HomeActivity.kt$HomeActivity$folderId: Long - UnusedParameter:HomeScreen.kt$onAddMedia: (AddMediaType) -> Unit - UnusedParameter:HomeScreen.kt$onFolderSelected: (Long) -> Unit - UnusedParameter:HomeScreen.kt$onNewFolder: () -> Unit - UnusedParameter:InternetArchiveHeader.kt$titleSize: TextUnit = 18.sp - UnusedParameter:InternetArchiveLoginScreen.kt$enabled: Boolean = true - UnusedParameter:MainActivity.kt$MainActivity$count: Int - UnusedParameter:MainDrawerContent.kt$isSelected: Boolean = false - UnusedParameter:MainDrawerContent.kt$onSelected: () -> Unit - UnusedParameter:MainDrawerContent.kt$project: Project - UnusedParameter:MainMediaAdapterTest.kt$progress: Int? = 0 - UnusedParameter:PasscodeEntryScreen.kt$onExit: () -> Unit - UnusedParameter:SnowbirdBridge.kt$SnowbirdBridge.Companion$message: String - UnusedParameter:Space.kt$Space$style: IconStyle = IconStyle.SOLID - UnusedParameter:Utility.kt$Utility$appId: String - UnusedPrivateMember:AddFolderScreen.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun AddFolderScreenPreview() - UnusedPrivateMember:BaseButton.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CustomButtonPreview() - UnusedPrivateMember:BaseButton.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CustomDestructiveButtonPreview() - UnusedPrivateMember:BaseButton.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CustomNeutralButtonPreview() - UnusedPrivateMember:BaseDialog.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun BaseDialogPreview() - UnusedPrivateMember:BaseDialog.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun ErrorDialogPreview() - UnusedPrivateMember:BaseDialog.kt$@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun WarningDialogPreview() - UnusedPrivateMember:BrowseFolderScreen.kt$@Preview @Composable private fun BrowseFolderScreenPreview() - UnusedPrivateMember:ExpandableSpaceList.kt$@Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun ExpandableSpaceListPreview() - UnusedPrivateMember:FolderOptionsPopup.kt$@Preview @Composable private fun FolderOptionsPopupPreview() - UnusedPrivateMember:HomeScreen.kt$@Preview @Composable private fun MainContentPreview() - UnusedPrivateMember:IaConduit.kt$IaConduit$@Throws(IOException::class) private fun OkHttpClient.uploadProofFiles(uploadFile: File) - UnusedPrivateMember:InternetArchiveDetailsScreen.kt$@Composable @Preview(showBackground = true) @Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) private fun InternetArchiveScreenPreview() - UnusedPrivateMember:InternetArchiveHeader.kt$@Composable @Preview(showBackground = true) @Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) private fun InternetArchiveHeaderPreview() - UnusedPrivateMember:InternetArchiveLoginScreen.kt$@Composable @Preview @Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) private fun InternetArchiveLoginPreview() - UnusedPrivateMember:MainDrawerContent.kt$@Preview @Composable private fun MainDrawerContentPreview() - UnusedPrivateMember:MainMediaAdapter.kt$MainMediaAdapter$private fun selectView(view: View) - UnusedPrivateMember:MainMediaScreen.kt$private fun deleteMediaItem(sections: MutableList<CollectionSection>, media: Media) - UnusedPrivateMember:MainMediaScreen.kt$private fun deleteSelected(sections: MutableList<CollectionSection>, context: Context) - UnusedPrivateMember:NumericKeypad.kt$@Preview @Composable private fun NumericKeypadPreview() - UnusedPrivateMember:PasscodeDots.kt$@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview @Composable private fun PasswordDotsPreview() - UnusedPrivateMember:PasscodeEntryScreen.kt$@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview @Composable private fun PasscodeEntryScreenPreview() - UnusedPrivateMember:PasscodeSetupScreen.kt$@Preview(uiMode = UI_MODE_NIGHT_YES) @Preview @Composable private fun PasscodeSetupScreenPreview() - UnusedPrivateMember:PrimaryButton.kt$@Preview @Composable private fun PrimaryButtonPreview() - UnusedPrivateMember:ProofModeScreen.kt$@Preview @Composable private fun ProofModeScreenPreview() - UnusedPrivateMember:ProofModeSettingsActivity.kt$ProofModeSettingsActivity.Companion$private fun shareKey(activity: Activity) - UnusedPrivateMember:ServerOptionItem.kt$@Preview @Composable private fun ServerOptionItemPreview() - UnusedPrivateMember:SettingsScreen.kt$@Preview @Composable private fun SettingsScreenPreview() - UnusedPrivateMember:SpaceListScreen.kt$@Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun SpaceListScreenPreview() - UnusedPrivateMember:SpaceSetupScreen.kt$@Preview @Composable private fun SpaceSetupScreenPreview() - UnusedPrivateProperty:BrowseFolderScreen.kt$val navController = LocalView.current.findNavController() - UnusedPrivateProperty:Colors.kt$private val c23_grey_50 = Color(0xff777979) - UnusedPrivateProperty:Colors.kt$private val c23_nav_drawer_night = Color(0xff101010) - UnusedPrivateProperty:Colors.kt$private val c23_teal_10 = Color(0xff001b19) // v=10.6 --> - UnusedPrivateProperty:Colors.kt$private val c23_teal_100 = Color(0xff00ffeb) // h=175,3 s=100 v=100 --> - UnusedPrivateProperty:Colors.kt$private val c23_teal_30 = Color(0xff004e48) // v=30.6 --> - UnusedPrivateProperty:Colors.kt$private val c23_teal_50 = Color(0xff008177) // v=50.6 --> - UnusedPrivateProperty:Colors.kt$private val c23_teal_60 = Color(0xff009b8f) // v=60.6 --> - UnusedPrivateProperty:Colors.kt$private val c23_teal_80 = Color(0xff00cebe) // v=80.6 --> - UnusedPrivateProperty:Colors.kt$private val c23_teal_90 = Color(0xff00e7d5) // v=90.6 --> - UnusedPrivateProperty:Colors.kt$private val darkPrimary = Color(0xff000A0A) - UnusedPrivateProperty:GDriveConduit.kt$GDriveConduit$val response = request.execute() - UnusedPrivateProperty:HomeActivity.kt$HomeActivity$private val folderResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { val selectedFolderId: Long? = result.data?.getLongExtra("SELECTED_FOLDER_ID", -1) if (selectedFolderId != null && selectedFolderId > -1) { navigateToFolder(selectedFolderId) } } } - UnusedPrivateProperty:HomeActivity.kt$HomeActivity$private val mNewFolderResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { // TODO: Refresh projects in MainViewModel } } - UnusedPrivateProperty:IaConduit.kt$IaConduit.Companion$private const val ARCHIVE_DETAILS_ENDPOINT = "https://archive.org/details/" - UnusedPrivateProperty:MainActivity.kt$MainActivity$private var currentSelectionCount = 0 - UnusedPrivateProperty:MainMediaAdapter.kt$MainMediaAdapter.Companion$private const val PAYLOAD_PROGRESS = "progress" - UnusedPrivateProperty:MainMediaAdapter.kt$MainMediaAdapter.Companion$private const val PAYLOAD_SELECTION = "selection" - UnusedPrivateProperty:MainMediaScreen.kt$var isSelecting by remember { mutableStateOf(false) } - UnusedPrivateProperty:MainMediaScreen.kt$var showDeleteDialog by remember { mutableStateOf(false) } - UnusedPrivateProperty:NumericKeypad.kt$val borderColor by animateColorAsState( targetValue = when { isPressed -> when (label) { "delete" -> colorResource(R.color.red_bg).copy(alpha = 0.7f) "submit" -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f) else -> MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) } else -> when (label) { "delete" -> colorResource(R.color.red_bg).copy(alpha = 0.5f) "submit" -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.5f) else -> Color.Transparent } }, animationSpec = spring(), label = "" ) - UnusedPrivateProperty:ProofModeScreen.kt$val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { isGranted -> if (!isGranted) { Toast.makeText(context, "Please allow all permissions", Toast.LENGTH_LONG).show() val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) val uri = Uri.fromParts("package", context.packageName, null) intent.data = uri context.startActivity(intent) } } - UnusedPrivateProperty:ProofModeScreen.kt$val uriHandler = LocalUriHandler.current - UnusedPrivateProperty:SectionViewHolder.kt$SectionViewHolder.Companion$private val mDf = DateFormat.getDateTimeInstance() - UnusedPrivateProperty:SnowbirdFragment.kt$SnowbirdFragment$private val CANNED_URI = "save+dweb::?dht=82fd345d484393a96b6e0c5d5e17a85a61c9184cc5a3311ab069d6efa0bf1410&enc=6fa27396fe298f92c91013ac54d8f316c2d45dc3bed0edec73078040aa10feed&pk=f4b404d294817cf11ea7f8ef7231626e03b74f6fafe3271b53918608afa82d12&sk=5482a8f490081be684fbadb8bde7f0a99bab8acdcf1ec094826f0f18e327e399" - UnusedPrivateProperty:SnowbirdFragment.kt$SnowbirdFragment$private var canNavigate = false - UnusedPrivateProperty:SnowbirdGroupRepository.kt$SnowbirdGroupRepository$val shouldFetchFromNetwork = forceRefresh || currentTime - lastFetchTime > cacheValidityPeriod - UnusedPrivateProperty:UnixSocketClient.kt$UnixSocketClient$context: Context - VariableNaming:SnowbirdFragment.kt$SnowbirdFragment$private val CANNED_URI = "save+dweb::?dht=82fd345d484393a96b6e0c5d5e17a85a61c9184cc5a3311ab069d6efa0bf1410&enc=6fa27396fe298f92c91013ac54d8f316c2d45dc3bed0edec73078040aa10feed&pk=f4b404d294817cf11ea7f8ef7231626e03b74f6fafe3271b53918608afa82d12&sk=5482a8f490081be684fbadb8bde7f0a99bab8acdcf1ec094826f0f18e327e399" - ViewModelForwarding:HomeScreen.kt$HomeScreen( viewModel = viewModel, onExit = onExit, onNewFolder = onNewFolder, onFolderSelected = onFolderSelected, onAddMedia = onAddMedia, onNavigateToCache = { navController.navigate(MediaCacheRoute) } ) - WildcardImport:BadgeDrawable.kt$import android.graphics.* - WildcardImport:CleanInsightsManager.kt$import org.cleaninsights.sdk.* - WildcardImport:Hbks.kt$import java.security.* - WildcardImport:Hbks.kt$import javax.crypto.* - WildcardImport:IaConduit.kt$import okhttp3.* - WildcardImport:MediaCacheScreen.kt$import androidx.compose.foundation.layout.* - WildcardImport:RequestBodyUtil.kt$import java.io.* - WildcardImport:UploadService.kt$import android.app.* - Wrapping:BaseDialog.kt$( - Wrapping:BrowseFoldersFragment.kt$BrowseFoldersFragment$( - Wrapping:BrowseFoldersFragment.kt$BrowseFoldersFragment$(RESULT_OK, Intent().apply { putExtra(AddFolderActivity.EXTRA_FOLDER_ID, project.id) }) - Wrapping:CleanInsightsManager.kt$CleanInsightsManager$( - Wrapping:CleanInsightsManager.kt$CleanInsightsManager$(CI_CAMPAIGN, object : ConsentRequestUi { override fun show( campaignId: String, campaign: Campaign, complete: ConsentRequestUiComplete ) { mCompleted = completed context.startActivity(Intent(context, ConsentActivity::class.java)) } override fun show(feature: Feature, complete: ConsentRequestUiComplete) { complete(true) } }, completed) - Wrapping:ConsentActivity.kt$ConsentActivity$( - Wrapping:Drawable.kt$( - Wrapping:EditFolderActivity.kt$EditFolderActivity$( - Wrapping:EditFolderActivity.kt$EditFolderActivity$(this, R.string.action_remove_project, R.string.remove_from_app, buttons = listOf( AlertHelper.positiveButton(R.string.remove) { _, _ -> mProject.delete() finish() }, AlertHelper.negativeButton())) - Wrapping:FileUtils.kt$FileUtils$( - Wrapping:GDriveActivity.kt$GDriveActivity$( - Wrapping:GDriveActivity.kt$GDriveActivity$(this, R.string.are_you_sure_you_want_to_remove_this_server_from_the_app, R.string.remove_from_app, buttons = listOf( AlertHelper.positiveButton(R.string.remove) { _, _ -> // delete sign-in from database space.delete() // google logout val googleSignInClient = GoogleSignIn.getClient(applicationContext, GoogleSignInOptions.DEFAULT_SIGN_IN) googleSignInClient.revokeAccess().addOnCompleteListener { googleSignInClient.signOut() } // leave activity Space.navigate(this) }, AlertHelper.negativeButton() )) - Wrapping:GDriveFragment.kt$GDriveFragment$( getString( R.string.gdrive_disclaimer_1, getString(R.string.app_name), getString(R.string.google_name), getString(R.string.gdrive_sudp_name), ), HtmlCompat.FROM_HTML_MODE_COMPACT ) - Wrapping:Hbks.kt$Hbks$( - Wrapping:Hbks.kt$Hbks$(activity, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError( errorCode: Int, errString: CharSequence ) { super.onAuthenticationError(errorCode, errString) completed(false) } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) completed(true) } override fun onAuthenticationFailed() { super.onAuthenticationFailed() completed(false) } }) - Wrapping:InternetArchiveActivity.kt$InternetArchiveActivity$( - Wrapping:MainMediaScreen.kt${ /* no op */ } - Wrapping:Media.kt$Media.Companion$( - Wrapping:MediaAdapter.kt$MediaAdapter$( it, it.getString(R.string.upload_unsuccessful_description), R.string.upload_unsuccessful, R.drawable.ic_error, listOf( AlertHelper.positiveButton(R.string.retry) { _, _ -> media[pos].apply { sStatus = Media.Status.Queued statusMessage = "" save() BroadcastManager.postChange(it, collectionId, id) } UploadService.startUploadService(it) }, AlertHelper.negativeButton(R.string.remove) { _, _ -> deleteItem(pos) }, AlertHelper.neutralButton() ) ) - Wrapping:MediaCacheScreen.kt$( - Wrapping:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity$( - Wrapping:Onboarding23InstructionsActivity.kt$Onboarding23InstructionsActivity$(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { if (isFirstPage()) { finish() } else { mBinding.viewPager.currentItem-- } } }) - Wrapping:PasscodeSetupActivity.kt$PasscodeSetupActivity$( - Wrapping:PasscodeSetupActivity.kt$PasscodeSetupActivity$(RESULT_OK, Intent().apply { putExtra(EXTRA_PASSCODE_ENABLED, true) }) - Wrapping:Picker.kt$Picker$( - Wrapping:Picker.kt$Picker$(activity, arrayOf( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO )) - Wrapping:ProofModeHelper.kt$ProofModeHelper$( - Wrapping:RequestBodyUtil.kt$ - Wrapping:RequestBodyUtil.kt$RequestBodyUtil$( - Wrapping:SnowbirdFileItem.kt$SnowbirdFileItem.Companion$( - Wrapping:SnowbirdFileListFragment.kt$SnowbirdFileListFragment$( - Wrapping:SnowbirdFileListFragment.kt$SnowbirdFileListFragment$(object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.menu_snowbird, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_add -> { Timber.d("Adde!") openFilePicker() true } else -> false } } }, viewLifecycleOwner, Lifecycle.State.RESUMED) - Wrapping:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment$( - Wrapping:SnowbirdGroupListFragment.kt$SnowbirdGroupListFragment$(object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.menu_snowbird, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_add -> { if (isJetpackNavigation) { val action = SnowbirdGroupListFragmentDirections.actionFragmentSnowbirdGroupListToFragmentSnowbirdCreateGroup() findNavController().navigate(action) } else { setFragmentResult( RESULT_REQUEST_KEY, bundleOf(RESULT_BUNDLE_NAVIGATION_KEY to RESULT_VAL_RAVEN_CREATE_GROUP_SCREEN) ) } true } else -> false } } }, viewLifecycleOwner, Lifecycle.State.RESUMED) - Wrapping:SnowbirdGroupRepository.kt$SnowbirdGroupRepository$( - Wrapping:SnowbirdRepo.kt$SnowbirdRepo.Companion$( - Wrapping:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment$( - Wrapping:SnowbirdRepoListFragment.kt$SnowbirdRepoListFragment$(object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.menu_snowbird, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_add -> { Utility.showMaterialWarning( context = requireContext(), message = "Feature not implemented yet.", positiveButtonText = "OK" ) true } else -> false } } }, viewLifecycleOwner, Lifecycle.State.RESUMED) - Wrapping:SpaceAdapter.kt$SpaceAdapter$( - Wrapping:TextView.kt$( - Wrapping:TextView.kt$(SpannableString(text).apply { setSpan(URLSpan(""), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }, TextView.BufferType.SPANNABLE) - Wrapping:TwoLetterDrawable.kt$TwoLetterDrawable$( - Wrapping:UnixSocketAPI.kt$UnixSocketAPI$( - Wrapping:Utility.kt$Utility$( - Wrapping:WebDavFragment.kt$WebDavFragment$( - - diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a6e7fc298..5c86c45ec 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -23,14 +23,6 @@ # A resource is loaded with a relative path so the package of this class must be preserved. -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase --assumenosideeffects class androidx.compose.material.icons.extended.{ - !Visibility, - !VisibilityOff, - ** -} { - ; -} - -if class androidx.credentials.CredentialManager -keep class androidx.credentials.playservices.** { *; diff --git a/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/1.json b/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/1.json new file mode 100644 index 000000000..ba3817a24 --- /dev/null +++ b/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/1.json @@ -0,0 +1,603 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "f0649f2c75baba064b3ad9a7575903d0", + "entities": [ + { + "tableName": "vaults", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `password` TEXT NOT NULL, `host` TEXT NOT NULL, `metaData` TEXT NOT NULL, `licenseUrl` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaData", + "columnName": "metaData", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "archives", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT, `createdAt` INTEGER, `vaultId` INTEGER NOT NULL, `archived` INTEGER NOT NULL, `openSubmissionId` INTEGER NOT NULL, `licenseUrl` TEXT, `isRemote` INTEGER NOT NULL, FOREIGN KEY(`vaultId`) REFERENCES `vaults`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "vaultId", + "columnName": "vaultId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archived", + "columnName": "archived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "openSubmissionId", + "columnName": "openSubmissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "isRemote", + "columnName": "isRemote", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_archives_vaultId", + "unique": false, + "columnNames": [ + "vaultId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_archives_vaultId` ON `${TABLE_NAME}` (`vaultId`)" + } + ], + "foreignKeys": [ + { + "table": "vaults", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "vaultId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "submissions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `archiveId` INTEGER NOT NULL, `uploadedAt` INTEGER, `serverUrl` TEXT, FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadedAt", + "columnName": "uploadedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverUrl", + "columnName": "serverUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_submissions_archiveId", + "unique": false, + "columnNames": [ + "archiveId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_submissions_archiveId` ON `${TABLE_NAME}` (`archiveId`)" + } + ], + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "evidence", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `originalFilePath` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `createdAt` INTEGER, `updatedAt` INTEGER, `uploadedAt` INTEGER, `serverUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT NOT NULL, `location` TEXT NOT NULL, `tags` TEXT NOT NULL, `licenseUrl` TEXT, `mediaHashString` TEXT NOT NULL, `status` INTEGER NOT NULL, `statusMessage` TEXT NOT NULL, `archiveId` INTEGER NOT NULL, `submissionId` INTEGER NOT NULL, `contentLength` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `flag` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `thumbnail` BLOB, FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`submissionId`) REFERENCES `submissions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalFilePath", + "columnName": "originalFilePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadedAt", + "columnName": "uploadedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverUrl", + "columnName": "serverUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaHashString", + "columnName": "mediaHashString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusMessage", + "columnName": "statusMessage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flag", + "columnName": "flag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_evidence_archiveId", + "unique": false, + "columnNames": [ + "archiveId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_archiveId` ON `${TABLE_NAME}` (`archiveId`)" + }, + { + "name": "index_evidence_submissionId", + "unique": false, + "columnNames": [ + "submissionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_submissionId` ON `${TABLE_NAME}` (`submissionId`)" + }, + { + "name": "index_evidence_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_evidence_priority", + "unique": false, + "columnNames": [ + "priority" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_priority` ON `${TABLE_NAME}` (`priority`)" + }, + { + "name": "index_evidence_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "submissions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "migration_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `stage` TEXT NOT NULL, `processedCount` INTEGER NOT NULL, `totalCount` INTEGER NOT NULL, `completedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "processedCount", + "columnName": "processedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "vault_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`vaultId` INTEGER NOT NULL, `vaultKey` TEXT NOT NULL, PRIMARY KEY(`vaultId`), FOREIGN KEY(`vaultId`) REFERENCES `vaults`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "vaultId", + "columnName": "vaultId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "vaultKey", + "columnName": "vaultKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "vaultId" + ] + }, + "foreignKeys": [ + { + "table": "vaults", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "vaultId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "archive_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`archiveId` INTEGER NOT NULL, `archiveKey` TEXT NOT NULL, `archiveHash` TEXT NOT NULL, `permissions` TEXT NOT NULL, PRIMARY KEY(`archiveId`), FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archiveKey", + "columnName": "archiveKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveHash", + "columnName": "archiveHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "archiveId" + ] + }, + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "evidence_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`evidenceId` INTEGER NOT NULL, `isDownloaded` INTEGER NOT NULL, PRIMARY KEY(`evidenceId`), FOREIGN KEY(`evidenceId`) REFERENCES `evidence`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "evidenceId", + "columnName": "evidenceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "evidenceId" + ] + }, + "foreignKeys": [ + { + "table": "evidence", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "evidenceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f0649f2c75baba064b3ad9a7575903d0')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/2.json b/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/2.json new file mode 100644 index 000000000..1aae12f3b --- /dev/null +++ b/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/2.json @@ -0,0 +1,597 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "7c435845a0996d16ffad32c0f0af9dc7", + "entities": [ + { + "tableName": "vaults", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `host` TEXT NOT NULL, `metaData` TEXT NOT NULL, `licenseUrl` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaData", + "columnName": "metaData", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "archives", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT, `createdAt` INTEGER, `vaultId` INTEGER NOT NULL, `archived` INTEGER NOT NULL, `openSubmissionId` INTEGER NOT NULL, `licenseUrl` TEXT, `isRemote` INTEGER NOT NULL, FOREIGN KEY(`vaultId`) REFERENCES `vaults`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "vaultId", + "columnName": "vaultId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archived", + "columnName": "archived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "openSubmissionId", + "columnName": "openSubmissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "isRemote", + "columnName": "isRemote", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_archives_vaultId", + "unique": false, + "columnNames": [ + "vaultId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_archives_vaultId` ON `${TABLE_NAME}` (`vaultId`)" + } + ], + "foreignKeys": [ + { + "table": "vaults", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "vaultId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "submissions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `archiveId` INTEGER NOT NULL, `uploadedAt` INTEGER, `serverUrl` TEXT, FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadedAt", + "columnName": "uploadedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverUrl", + "columnName": "serverUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_submissions_archiveId", + "unique": false, + "columnNames": [ + "archiveId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_submissions_archiveId` ON `${TABLE_NAME}` (`archiveId`)" + } + ], + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "evidence", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `originalFilePath` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `createdAt` INTEGER, `updatedAt` INTEGER, `uploadedAt` INTEGER, `serverUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT NOT NULL, `location` TEXT NOT NULL, `tags` TEXT NOT NULL, `licenseUrl` TEXT, `mediaHashString` TEXT NOT NULL, `status` INTEGER NOT NULL, `statusMessage` TEXT NOT NULL, `archiveId` INTEGER NOT NULL, `submissionId` INTEGER NOT NULL, `contentLength` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `flag` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `thumbnail` BLOB, FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`submissionId`) REFERENCES `submissions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalFilePath", + "columnName": "originalFilePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadedAt", + "columnName": "uploadedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverUrl", + "columnName": "serverUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaHashString", + "columnName": "mediaHashString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusMessage", + "columnName": "statusMessage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flag", + "columnName": "flag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_evidence_archiveId", + "unique": false, + "columnNames": [ + "archiveId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_archiveId` ON `${TABLE_NAME}` (`archiveId`)" + }, + { + "name": "index_evidence_submissionId", + "unique": false, + "columnNames": [ + "submissionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_submissionId` ON `${TABLE_NAME}` (`submissionId`)" + }, + { + "name": "index_evidence_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_evidence_priority", + "unique": false, + "columnNames": [ + "priority" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_priority` ON `${TABLE_NAME}` (`priority`)" + }, + { + "name": "index_evidence_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "submissions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "migration_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `stage` TEXT NOT NULL, `processedCount` INTEGER NOT NULL, `totalCount` INTEGER NOT NULL, `completedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "processedCount", + "columnName": "processedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "vault_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`vaultId` INTEGER NOT NULL, `vaultKey` TEXT NOT NULL, PRIMARY KEY(`vaultId`), FOREIGN KEY(`vaultId`) REFERENCES `vaults`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "vaultId", + "columnName": "vaultId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "vaultKey", + "columnName": "vaultKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "vaultId" + ] + }, + "foreignKeys": [ + { + "table": "vaults", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "vaultId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "archive_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`archiveId` INTEGER NOT NULL, `archiveKey` TEXT NOT NULL, `archiveHash` TEXT NOT NULL, `permissions` TEXT NOT NULL, PRIMARY KEY(`archiveId`), FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archiveKey", + "columnName": "archiveKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveHash", + "columnName": "archiveHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "archiveId" + ] + }, + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "evidence_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`evidenceId` INTEGER NOT NULL, `isDownloaded` INTEGER NOT NULL, PRIMARY KEY(`evidenceId`), FOREIGN KEY(`evidenceId`) REFERENCES `evidence`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "evidenceId", + "columnName": "evidenceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "evidenceId" + ] + }, + "foreignKeys": [ + { + "table": "evidence", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "evidenceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7c435845a0996d16ffad32c0f0af9dc7')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/3.json b/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/3.json new file mode 100644 index 000000000..2e0a9b3d0 --- /dev/null +++ b/app/schemas/net.opendasharchive.openarchive.db.AppDatabase/3.json @@ -0,0 +1,597 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "7c435845a0996d16ffad32c0f0af9dc7", + "entities": [ + { + "tableName": "vaults", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `host` TEXT NOT NULL, `metaData` TEXT NOT NULL, `licenseUrl` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaData", + "columnName": "metaData", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "archives", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT, `createdAt` INTEGER, `vaultId` INTEGER NOT NULL, `archived` INTEGER NOT NULL, `openSubmissionId` INTEGER NOT NULL, `licenseUrl` TEXT, `isRemote` INTEGER NOT NULL, FOREIGN KEY(`vaultId`) REFERENCES `vaults`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "vaultId", + "columnName": "vaultId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archived", + "columnName": "archived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "openSubmissionId", + "columnName": "openSubmissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "isRemote", + "columnName": "isRemote", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_archives_vaultId", + "unique": false, + "columnNames": [ + "vaultId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_archives_vaultId` ON `${TABLE_NAME}` (`vaultId`)" + } + ], + "foreignKeys": [ + { + "table": "vaults", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "vaultId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "submissions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `archiveId` INTEGER NOT NULL, `uploadedAt` INTEGER, `serverUrl` TEXT, FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadedAt", + "columnName": "uploadedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverUrl", + "columnName": "serverUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_submissions_archiveId", + "unique": false, + "columnNames": [ + "archiveId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_submissions_archiveId` ON `${TABLE_NAME}` (`archiveId`)" + } + ], + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "evidence", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `originalFilePath` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `createdAt` INTEGER, `updatedAt` INTEGER, `uploadedAt` INTEGER, `serverUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT NOT NULL, `location` TEXT NOT NULL, `tags` TEXT NOT NULL, `licenseUrl` TEXT, `mediaHashString` TEXT NOT NULL, `status` INTEGER NOT NULL, `statusMessage` TEXT NOT NULL, `archiveId` INTEGER NOT NULL, `submissionId` INTEGER NOT NULL, `contentLength` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `flag` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `thumbnail` BLOB, FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`submissionId`) REFERENCES `submissions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalFilePath", + "columnName": "originalFilePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadedAt", + "columnName": "uploadedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverUrl", + "columnName": "serverUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseUrl", + "columnName": "licenseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaHashString", + "columnName": "mediaHashString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusMessage", + "columnName": "statusMessage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flag", + "columnName": "flag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_evidence_archiveId", + "unique": false, + "columnNames": [ + "archiveId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_archiveId` ON `${TABLE_NAME}` (`archiveId`)" + }, + { + "name": "index_evidence_submissionId", + "unique": false, + "columnNames": [ + "submissionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_submissionId` ON `${TABLE_NAME}` (`submissionId`)" + }, + { + "name": "index_evidence_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_evidence_priority", + "unique": false, + "columnNames": [ + "priority" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_priority` ON `${TABLE_NAME}` (`priority`)" + }, + { + "name": "index_evidence_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_evidence_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "submissions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "migration_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `stage` TEXT NOT NULL, `processedCount` INTEGER NOT NULL, `totalCount` INTEGER NOT NULL, `completedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "processedCount", + "columnName": "processedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "vault_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`vaultId` INTEGER NOT NULL, `vaultKey` TEXT NOT NULL, PRIMARY KEY(`vaultId`), FOREIGN KEY(`vaultId`) REFERENCES `vaults`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "vaultId", + "columnName": "vaultId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "vaultKey", + "columnName": "vaultKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "vaultId" + ] + }, + "foreignKeys": [ + { + "table": "vaults", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "vaultId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "archive_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`archiveId` INTEGER NOT NULL, `archiveKey` TEXT NOT NULL, `archiveHash` TEXT NOT NULL, `permissions` TEXT NOT NULL, PRIMARY KEY(`archiveId`), FOREIGN KEY(`archiveId`) REFERENCES `archives`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "archiveId", + "columnName": "archiveId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archiveKey", + "columnName": "archiveKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveHash", + "columnName": "archiveHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "archiveId" + ] + }, + "foreignKeys": [ + { + "table": "archives", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "archiveId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "evidence_dweb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`evidenceId` INTEGER NOT NULL, `isDownloaded` INTEGER NOT NULL, PRIMARY KEY(`evidenceId`), FOREIGN KEY(`evidenceId`) REFERENCES `evidence`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "evidenceId", + "columnName": "evidenceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "evidenceId" + ] + }, + "foreignKeys": [ + { + "table": "evidence", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "evidenceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7c435845a0996d16ffad32c0f0af9dc7')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 09589d666..000000000 --- a/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/foss/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt b/app/src/foss/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt new file mode 100644 index 000000000..f19e665c7 --- /dev/null +++ b/app/src/foss/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt @@ -0,0 +1,268 @@ +package net.opendasharchive.openarchive.core.logger + +import android.app.Application +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.analytics.crash.AcraCrashReporter +import net.opendasharchive.openarchive.analytics.crash.CrashReporter +import org.acra.ACRA +import org.acra.config.CoreConfigurationBuilder +import org.acra.config.DialogConfigurationBuilder +import org.acra.config.MailSenderConfigurationBuilder +import org.acra.data.StringFormat +import timber.log.Timber + + +/** + * A utility object for centralized logging in Android applications. + * Integrates with Timber, Crash Reporting, and Analytics for comprehensive error tracking. + * + * FOSS Version - Uses ACRA via CrashReporter abstraction + * + * Features: + * - Logs to Logcat via Timber + * - Sends errors to ACRA (privacy-focused crash reporting) + * - Tracks critical errors in Analytics (GDPR-compliant) + * - User journey breadcrumbs for crash analysis + */ +object AppLogger { + + private var crashReporter: CrashReporter? = null + private var currentScreen: String = "Unknown" + private var analyticsManager: AnalyticsManager? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var acraInitialized = false + + /** + * Initialize ACRA crash reporting. + * Must be called from Application.attachBaseContext() after super.attachBaseContext(). + */ + fun initAcra(app: Application) { + if (acraInitialized) return + + try { + val acraEmail = net.opendasharchive.openarchive.BuildConfig.ACRA_EMAIL + if (acraEmail.isBlank()) { + Timber.w("ACRA_EMAIL not configured in local.properties - crash reporting disabled") + return + } + + val config = CoreConfigurationBuilder() + .withReportFormat(StringFormat.KEY_VALUE_LIST) + .withBuildConfigClass(net.opendasharchive.openarchive.BuildConfig::class.java) + .withPluginConfigurations( + MailSenderConfigurationBuilder() + .withMailTo(acraEmail) + .withReportAsFile(false) // Embed in body to avoid Android intent bug + .withSubject("Save App Crash Report") + .build(), + DialogConfigurationBuilder() + .withTitle(app.getString(R.string.crash_dialog_title)) + .withText(app.getString(R.string.crash_dialog_text)) + .withCommentPrompt(app.getString(R.string.crash_dialog_comment_prompt)) + .withPositiveButtonText(app.getString(R.string.crash_dialog_ok_toast)) + .withNegativeButtonText(app.getString(R.string.crash_dialog_cancel)) + .build() + ) + .build() + + ACRA.init(app, config) + acraInitialized = true + Timber.d("ACRA initialized successfully") + } catch (e: Exception) { + Timber.e(e, "Failed to initialize ACRA") + } + } + + /** + * Initializes the logger + * @param context The context used to initialize services + * @param initDebugger Legacy parameter (unused) + */ + fun init(context: Context, initDebugger: Boolean) { + Timber.plant(DebugTreeWithTag()) + + try { + crashReporter = AcraCrashReporter(context).apply { + initialize() + } + } catch (e: Exception) { + Timber.e(e, "Failed to initialize Crash Reporter") + } + } + + /** + * Set analytics manager for error tracking + */ + fun setAnalyticsManager(manager: AnalyticsManager) { + analyticsManager = manager + } + + /** + * Set current screen for breadcrumb context + */ + fun setCurrentScreen(screenName: String) { + currentScreen = screenName + crashReporter?.log("Screen: $screenName") + } + + /** + * Add breadcrumb for user journey tracking + * This helps understand what user was doing before a crash + */ + fun breadcrumb(action: String, details: String? = null) { + val breadcrumb = if (details != null) "$action: $details" else action + crashReporter?.log("[$currentScreen] $breadcrumb") + } + + // Info Level Logging + // REMOVED Analytics.log() - info logs are for debugging, not analytics + fun i(message: String, vararg args: Any?) { + Timber.i(message + args.joinToString(" ")) + } + + fun i(message: String, throwable: Throwable) { + Timber.i(throwable, message) + } + + // Debug Level Logging + fun d(message: String, vararg args: Any?) { + Timber.d(message + args.joinToString(" ")) + } + + fun d(message: String, throwable: Throwable) { + Timber.d(throwable, message) + } + + // Error Level Logging + /** + * Log error message only (no exception) + * This is for minor errors that don't require stack traces + */ + fun e(message: String, vararg args: Any?) { + val fullMessage = message + args.joinToString(" ") + Timber.e(fullMessage) + + // Add breadcrumb for context + crashReporter?.log("ERROR: $fullMessage") + } + + /** + * Log error with exception + * Sends to Crash Reporter + Analytics + */ + fun e(message: String, throwable: Throwable) { + Timber.e(throwable, message) + + // Send to Crash Reporter (non-fatal exception) + crashReporter?.let { + it.log("[$currentScreen] ERROR: $message") + it.recordException(throwable) + } + + // Track in Analytics (GDPR-safe - only error category, no PII) + val errorCategory = categorizeError(throwable) + analyticsManager?.let { manager -> + scope.launch { + manager.trackError( + errorCategory = errorCategory, + screenName = currentScreen + ) + } + } + } + + /** + * Log exception only + * Sends to Crash Reporter + Analytics + */ + fun e(throwable: Throwable) { + Timber.e(throwable) + + // Send to Crash Reporter (non-fatal exception) + crashReporter?.let { + it.log("[$currentScreen] EXCEPTION: ${throwable.message}") + it.recordException(throwable) + } + + // Track in Analytics (GDPR-safe) + val errorCategory = categorizeError(throwable) + analyticsManager?.let { manager -> + scope.launch { + manager.trackError( + errorCategory = errorCategory, + screenName = currentScreen + ) + } + } + } + + /** + * Categorize error for analytics (GDPR-safe) + */ + private fun categorizeError(throwable: Throwable): String { + return when (throwable) { + is java.io.IOException -> "network" + is java.io.FileNotFoundException -> "file_not_found" + is SecurityException -> "permission" + is IllegalStateException -> "illegal_state" + is IllegalArgumentException -> "illegal_argument" + is NullPointerException -> "null_pointer" + is OutOfMemoryError -> "out_of_memory" + else -> throwable::class.simpleName ?: "unknown" + } + } + + // Warning Level Logging + fun w(message: String, vararg args: Any?) { + Timber.w("%s%s", message, args.joinToString(" ")) + } + + fun w(message: String, throwable: Throwable) { + Timber.w(throwable, message) + } + + // Verbose Level Logging + fun v(message: String, vararg args: Any?) { + Timber.v("%s%s", message, args.joinToString(" ")) + } + + // Tagged Logging Methods + fun tagD(tag: String, message: String, vararg args: Any?) { + Timber.tag(tag).d("%s%s", message, args.joinToString(" ")) + } + + fun tagI(tag: String, message: String, vararg args: Any?) { + Timber.tag(tag).i("%s%s", message, args.joinToString(" ")) + } + + fun tagE(tag: String, message: String, vararg args: Any?) { + Timber.tag(tag).e("%s%s", message, args.joinToString(" ")) + } + + private class DebugTreeWithTag : Timber.DebugTree() { + override fun createStackElementTag(element: StackTraceElement): String? { + // Customize the tag to include the class name and line number + return "${element.fileName}:${element.lineNumber}" + } + } + + + val imageLogger = object : coil3.util.Logger { + override var minLevel: coil3.util.Logger.Level = coil3.util.Logger.Level.Verbose + + override fun log( + tag: String, + level: coil3.util.Logger.Level, + message: String?, + throwable: Throwable? + ) { + Timber.tag("Coil3:$tag").log(level.ordinal, throwable, message) + } + } +} diff --git a/app/src/foss/java/net/opendasharchive/openarchive/features/main/InAppReviewCoordinator.kt b/app/src/foss/java/net/opendasharchive/openarchive/features/main/InAppReviewCoordinator.kt new file mode 100644 index 000000000..5865aff2b --- /dev/null +++ b/app/src/foss/java/net/opendasharchive/openarchive/features/main/InAppReviewCoordinator.kt @@ -0,0 +1,16 @@ +package net.opendasharchive.openarchive.features.main + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable + +/** No-op stubs for FOSS builds — Play Core is not available on F-Droid. */ + +@Composable +fun CheckForInAppUpdates(snackbarHostState: SnackbarHostState) { + // No-op: F-Droid handles updates through its own update mechanism. +} + +@Composable +fun CheckForInAppReview() { + // No-op: Google Play In-App Review API is unavailable in FOSS builds. +} diff --git a/app/src/foss/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt b/app/src/foss/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt new file mode 100644 index 000000000..241c5ceca --- /dev/null +++ b/app/src/foss/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt @@ -0,0 +1,30 @@ +package net.opendasharchive.openarchive.features.main + +import android.app.Activity +import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import net.opendasharchive.openarchive.core.logger.AppLogger + +/** + * FOSS InAppUpdateCoordinator stub for F-Droid builds + * + * F-Droid handles updates through its own mechanism, so in-app update checking is not needed. + * This is a no-op implementation that maintains API compatibility with the GMS version. + */ +internal class InAppUpdateCoordinator( + private val activity: Activity, + private val rootView: View, + private val updateLauncher: ActivityResultLauncher +) { + + fun onResume() { + // No-op for FOSS builds + // F-Droid handles updates automatically through its own update mechanism + AppLogger.d("InAppUpdateCoordinator", "FOSS build - updates handled by F-Droid") + } + + fun onDestroy() { + // No cleanup needed + } +} diff --git a/app/src/foss/java/net/opendasharchive/openarchive/util/FossLocationProvider.kt b/app/src/foss/java/net/opendasharchive/openarchive/util/FossLocationProvider.kt new file mode 100644 index 000000000..87636dd34 --- /dev/null +++ b/app/src/foss/java/net/opendasharchive/openarchive/util/FossLocationProvider.kt @@ -0,0 +1,66 @@ +package net.opendasharchive.openarchive.util + +import android.content.Context +import android.location.Location +import android.location.LocationManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.logger.AppLogger + +/** + * FOSS implementation of LocationProvider using LocationManager. + * Used in F-Droid builds. + */ +class FossLocationProvider(private val context: Context) : LocationProvider { + + private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + override suspend fun getCurrentLocation(timeoutMs: Long): Location? { + return withContext(Dispatchers.IO) { + try { + // Try GPS first, then Network provider + val providers = listOf( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER + ).filter { locationManager.isProviderEnabled(it) } + + if (providers.isEmpty()) { + AppLogger.w("[FossLocation] No location providers available") + return@withContext null + } + + // Try to get last known location first (fast) + val lastKnown = providers.firstNotNullOfOrNull { provider -> + try { + locationManager.getLastKnownLocation(provider) + } catch (e: SecurityException) { + AppLogger.e("[FossLocation] No permission for provider: $provider", e) + null + } + } + + // If last known location is recent (< 2 minutes), use it + if (lastKnown != null && + System.currentTimeMillis() - lastKnown.time < 120_000) { + AppLogger.d("[FossLocation] Using last known location from ${lastKnown.provider}: ${lastKnown.latitude}, ${lastKnown.longitude} (age: ${(System.currentTimeMillis() - lastKnown.time) / 1000}s)") + return@withContext lastKnown + } + + // Otherwise return last known (stale) or null + if (lastKnown != null) { + AppLogger.d("[FossLocation] Using stale last known location from ${lastKnown.provider}: ${lastKnown.latitude}, ${lastKnown.longitude} (age: ${(System.currentTimeMillis() - lastKnown.time) / 1000}s)") + return@withContext lastKnown + } + + AppLogger.w("[FossLocation] No location available from any provider") + null + } catch (e: SecurityException) { + AppLogger.e("[FossLocation] No location permission", e) + null + } catch (e: Exception) { + AppLogger.e("[FossLocation] Unexpected error getting location", e) + null + } + } + } +} diff --git a/app/src/foss/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt b/app/src/foss/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt new file mode 100644 index 000000000..914cd0ffd --- /dev/null +++ b/app/src/foss/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt @@ -0,0 +1,14 @@ +package net.opendasharchive.openarchive.util + +import android.app.Activity +import android.content.Context +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager + +@Suppress("UNUSED_PARAMETER") +object InAppReviewHelper { + fun init(context: Context) = Unit + fun requestReviewInfo(context: Context, analyticsManager: AnalyticsManager) = Unit + fun onAppLaunched(): Boolean = false + fun showReviewIfPossible(activity: Activity, reviewManager: Any?, analyticsManager: AnalyticsManager) = Unit + fun markReviewDone() = Unit +} diff --git a/app/src/foss/res/values/strings.xml b/app/src/foss/res/values/strings.xml new file mode 100644 index 000000000..408753e7f --- /dev/null +++ b/app/src/foss/res/values/strings.xml @@ -0,0 +1,9 @@ + + + + Oops! Save has crashed + We\'re sorry for the inconvenience. Would you like to send a crash report to help us fix this issue? + What were you doing when the crash occurred? (optional) + Send Report + Don\'t Send + diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt b/app/src/gms/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt similarity index 82% rename from app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt rename to app/src/gms/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt index 9a9e13eb3..5e4047501 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt +++ b/app/src/gms/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt @@ -1,20 +1,22 @@ package net.opendasharchive.openarchive.core.logger +import android.app.Application import android.content.Context -import com.google.firebase.crashlytics.FirebaseCrashlytics import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent import net.opendasharchive.openarchive.analytics.api.AnalyticsManager -import net.opendasharchive.openarchive.core.logger.AppLogger.init +import net.opendasharchive.openarchive.analytics.crash.CrashReporter +import net.opendasharchive.openarchive.analytics.crash.FirebaseCrashReporter import timber.log.Timber /** * A utility object for centralized logging in Android applications. - * Integrates with Timber, Firebase Crashlytics, and Analytics for comprehensive error tracking. + * Integrates with Timber, Crash Reporting, and Analytics for comprehensive error tracking. + * + * GMS Version - Uses Firebase Crashlytics via CrashReporter abstraction * * Features: * - Logs to Logcat via Timber @@ -24,11 +26,20 @@ import timber.log.Timber */ object AppLogger { - private var crashlytics: FirebaseCrashlytics? = null + private var crashReporter: CrashReporter? = null private var currentScreen: String = "Unknown" private var analyticsManager: AnalyticsManager? = null private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + /** + * Initialize crash reporting (ACRA for FOSS builds). + * No-op for GMS builds - Firebase Crashlytics is initialized automatically. + */ + @Suppress("UNUSED_PARAMETER") + fun initAcra(app: Application) { + // No-op for GMS builds - Firebase Crashlytics handles crash reporting + } + /** * Initializes the logger * @param context The context used to initialize services @@ -38,9 +49,11 @@ object AppLogger { Timber.plant(DebugTreeWithTag()) try { - crashlytics = FirebaseCrashlytics.getInstance() + crashReporter = FirebaseCrashReporter().apply { + initialize() + } } catch (e: Exception) { - Timber.e(e, "Failed to initialize Firebase Crashlytics") + Timber.e(e, "Failed to initialize Crash Reporter") } } @@ -56,7 +69,7 @@ object AppLogger { */ fun setCurrentScreen(screenName: String) { currentScreen = screenName - crashlytics?.log("Screen: $screenName") + crashReporter?.log("Screen: $screenName") } /** @@ -65,7 +78,7 @@ object AppLogger { */ fun breadcrumb(action: String, details: String? = null) { val breadcrumb = if (details != null) "$action: $details" else action - crashlytics?.log("[$currentScreen] $breadcrumb") + crashReporter?.log("[$currentScreen] $breadcrumb") } // Info Level Logging @@ -97,18 +110,18 @@ object AppLogger { Timber.e(fullMessage) // Add breadcrumb for context - crashlytics?.log("ERROR: $fullMessage") + crashReporter?.log("ERROR: $fullMessage") } /** * Log error with exception - * Sends to Firebase Crashlytics + Analytics + * Sends to Crash Reporter + Analytics */ fun e(message: String, throwable: Throwable) { Timber.e(throwable, message) - // Send to Firebase Crashlytics (non-fatal exception) - crashlytics?.let { + // Send to Crash Reporter (non-fatal exception) + crashReporter?.let { it.log("[$currentScreen] ERROR: $message") it.recordException(throwable) } @@ -127,13 +140,13 @@ object AppLogger { /** * Log exception only - * Sends to Firebase Crashlytics + Analytics + * Sends to Crash Reporter + Analytics */ fun e(throwable: Throwable) { Timber.e(throwable) - // Send to Firebase Crashlytics (non-fatal exception) - crashlytics?.let { + // Send to Crash Reporter (non-fatal exception) + crashReporter?.let { it.log("[$currentScreen] EXCEPTION: ${throwable.message}") it.recordException(throwable) } @@ -213,4 +226,4 @@ object AppLogger { Timber.tag("Coil3:$tag").log(level.ordinal, throwable, message) } } -} \ No newline at end of file +} diff --git a/app/src/gms/java/net/opendasharchive/openarchive/features/main/InAppReviewCoordinator.kt b/app/src/gms/java/net/opendasharchive/openarchive/features/main/InAppReviewCoordinator.kt new file mode 100644 index 000000000..435801749 --- /dev/null +++ b/app/src/gms/java/net/opendasharchive/openarchive/features/main/InAppReviewCoordinator.kt @@ -0,0 +1,44 @@ +package net.opendasharchive.openarchive.features.main + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.google.android.play.core.review.ReviewManagerFactory +import kotlinx.coroutines.delay +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.util.InAppReviewHelper +import org.koin.compose.koinInject + +@Composable +fun CheckForInAppReview( + analyticsManager: AnalyticsManager = koinInject() +) { + val context = LocalContext.current + val activity = context as? Activity ?: return + + // Create ReviewManager for launching the flow later + val reviewManager = remember { ReviewManagerFactory.create(context) } + + LaunchedEffect(Unit) { + // 1. Asynchronously fetch ReviewInfo (stored in InAppReviewHelper singleton) + // We do this early to ensure it's ready if we decide to show it. + InAppReviewHelper.requestReviewInfo(context, analyticsManager) + + // 2. Check eligibility (increment launch count, check criteria) + val shouldPrompt = InAppReviewHelper.onAppLaunched() + + if (shouldPrompt) { + // 3. Wait for UI to settle and ReviewInfo to fetch + // Original logic waited 2s after onResume. We'll wait 3s here to be safe and ensure stability. + delay(3000) + + // 4. Show review if info is available + InAppReviewHelper.showReviewIfPossible(activity, reviewManager, analyticsManager) + + // 5. Mark as done to prevent spamming + InAppReviewHelper.markReviewDone() + } + } +} diff --git a/app/src/gms/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt b/app/src/gms/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt new file mode 100644 index 000000000..f2d8780c9 --- /dev/null +++ b/app/src/gms/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt @@ -0,0 +1,169 @@ +package net.opendasharchive.openarchive.features.main + +import android.app.Activity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.appupdate.AppUpdateManager +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.InstallStateUpdatedListener +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.InstallStatus +import com.google.android.play.core.install.model.UpdateAvailability +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.BuildConfig +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.logger.AppLogger + +private const val IMMEDIATE_UPDATE_VERSION_GAP = 3 +private const val FLEXIBLE_UPDATE_MAX_GAP = 2 + +@Composable +fun CheckForInAppUpdates( + snackbarHostState: SnackbarHostState +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + + val appUpdateManager = remember { AppUpdateManagerFactory.create(context) } + + val updateLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartIntentSenderForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) { + AppLogger.w("In-app update flow failed or cancelled: ${result.resultCode}") + } + } + + val result = stringResource(R.string.in_app_update_ready) + val actionLabel = stringResource(R.string.in_app_update_restart) + + // Helper to show snackbar + fun showFlexibleUpdateDownloadedSnackbar() { + scope.launch { + // Check if snackbar is already being shown or we just want to ensure it is visible? + // SnackbarHostState puts new requests in queue. + // We'll just show it. If user dismisses, it's gone until next resume/trigger. + val result = snackbarHostState.showSnackbar( + message = result, + actionLabel = actionLabel, + duration = SnackbarDuration.Indefinite + ) + if (result == SnackbarResult.ActionPerformed) { + appUpdateManager.completeUpdate() + } + } + } + + DisposableEffect(lifecycleOwner) { + val listener = InstallStateUpdatedListener { state -> + if (state.installStatus() == InstallStatus.DOWNLOADED) { + showFlexibleUpdateDownloadedSnackbar() + } + } + + // Register listener for flexible updates progress/completion + appUpdateManager.registerListener(listener) + + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + checkForAppUpdates( + appUpdateManager, + updateLauncher, + ::showFlexibleUpdateDownloadedSnackbar + ) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + appUpdateManager.unregisterListener(listener) + lifecycleOwner.lifecycle.removeObserver(observer) + } + } +} + +private fun checkForAppUpdates( + appUpdateManager: AppUpdateManager, + updateLauncher: androidx.activity.result.ActivityResultLauncher, + onDownloaded: () -> Unit +) { + appUpdateManager.appUpdateInfo.addOnSuccessListener { info -> + if (info.installStatus() == InstallStatus.DOWNLOADED) { + onDownloaded() + return@addOnSuccessListener + } + + if (info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) { + if (info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)) { + startUpdateFlow(appUpdateManager, info, AppUpdateType.IMMEDIATE, updateLauncher) + } + } else if (info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + handleUpdateAvailability(appUpdateManager, info, updateLauncher) + } + }.addOnFailureListener { e -> + AppLogger.w("Failed to load in-app update info", e) + } +} + +private fun handleUpdateAvailability( + appUpdateManager: AppUpdateManager, + info: AppUpdateInfo, + updateLauncher: androidx.activity.result.ActivityResultLauncher +) { + val versionGap = info.availableVersionCode() - BuildConfig.VERSION_CODE + if (versionGap <= 0) { + AppLogger.d("No newer version detected. Gap: $versionGap") + return + } + + val immediateAllowed = versionGap >= IMMEDIATE_UPDATE_VERSION_GAP && + info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) + val flexibleAllowed = versionGap <= FLEXIBLE_UPDATE_MAX_GAP && + info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) + + when { + immediateAllowed -> { + AppLogger.i("Triggering immediate update flow. Version gap: $versionGap") + startUpdateFlow(appUpdateManager, info, AppUpdateType.IMMEDIATE, updateLauncher) + } + flexibleAllowed -> { + AppLogger.i("Triggering flexible update flow. Version gap: $versionGap") + startUpdateFlow(appUpdateManager, info, AppUpdateType.FLEXIBLE, updateLauncher) + } + info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> { + AppLogger.i("Falling back to flexible update flow. Version gap: $versionGap") + startUpdateFlow(appUpdateManager, info, AppUpdateType.FLEXIBLE, updateLauncher) + } + else -> AppLogger.w("Update available but no compatible update types allowed.") + } +} + +private fun startUpdateFlow( + appUpdateManager: AppUpdateManager, + info: AppUpdateInfo, + type: Int, + launcher: androidx.activity.result.ActivityResultLauncher +) { + val options = AppUpdateOptions.newBuilder(type).build() + try { + appUpdateManager.startUpdateFlowForResult(info, launcher, options) + } catch (e: Exception) { + AppLogger.e("Failed to launch in-app update flow", e) + } +} diff --git a/app/src/gms/java/net/opendasharchive/openarchive/util/GmsLocationProvider.kt b/app/src/gms/java/net/opendasharchive/openarchive/util/GmsLocationProvider.kt new file mode 100644 index 000000000..b7772e1a7 --- /dev/null +++ b/app/src/gms/java/net/opendasharchive/openarchive/util/GmsLocationProvider.kt @@ -0,0 +1,85 @@ +package net.opendasharchive.openarchive.util + +import android.content.Context +import android.location.Location +import com.google.android.gms.location.CurrentLocationRequest +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.logger.AppLogger +import kotlin.coroutines.resume + +/** + * GMS implementation of LocationProvider using FusedLocationProviderClient. + * Used in Google Play builds. + */ +class GmsLocationProvider(private val context: Context) : LocationProvider { + + private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) + + override suspend fun getCurrentLocation(timeoutMs: Long): Location? { + return withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + try { + val cancellationTokenSource = CancellationTokenSource() + + // Create location request with high accuracy + val locationRequest = CurrentLocationRequest.Builder() + .setPriority(Priority.PRIORITY_HIGH_ACCURACY) + .setMaxUpdateAgeMillis(0) // Don't accept cached locations + .build() + + fusedLocationClient.getCurrentLocation( + locationRequest, + cancellationTokenSource.token + ).addOnSuccessListener { location -> + if (continuation.isActive) { + if (location != null) { + AppLogger.d("[GmsLocation] Got location: ${location.latitude}, ${location.longitude}") + } else { + AppLogger.w("[GmsLocation] Location is null") + } + continuation.resume(location) + } + }.addOnFailureListener { exception -> + if (continuation.isActive) { + AppLogger.w("[GmsLocation] Failed to get location: ${exception.message}") + continuation.resume(null) + } + } + + // Handle cancellation + continuation.invokeOnCancellation { + cancellationTokenSource.cancel() + } + + // Set timeout + GlobalScope.launch { + delay(timeoutMs) + if (continuation.isActive) { + AppLogger.w("[GmsLocation] Location request timed out after ${timeoutMs}ms") + cancellationTokenSource.cancel() + continuation.resume(null) + } + } + } catch (e: SecurityException) { + AppLogger.e("[GmsLocation] No location permission", e) + if (continuation.isActive) { + continuation.resume(null) + } + } catch (e: Exception) { + AppLogger.e("[GmsLocation] Unexpected error", e) + if (continuation.isActive) { + continuation.resume(null) + } + } + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt b/app/src/gms/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt similarity index 84% rename from app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt rename to app/src/gms/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt index 9d69e07e8..54b964bcd 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt +++ b/app/src/gms/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt @@ -28,6 +28,9 @@ object InAppReviewHelper { // Once requestReviewFlow() succeeds, we cache this: private var reviewInfo: ReviewInfo? = null + // ReviewManager instance + private var reviewManager: ReviewManager? = null + // Coroutine scope for analytics private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -42,9 +45,9 @@ object InAppReviewHelper { * Call early (e.g. in onCreate of MainActivity) to asynchronously fetch ReviewInfo. */ fun requestReviewInfo(context: Context, analyticsManager: AnalyticsManager) { - val manager: ReviewManager = ReviewManagerFactory.create(context) - manager.requestReviewFlow() - .addOnCompleteListener { task -> + reviewManager = ReviewManagerFactory.create(context) + reviewManager?.requestReviewFlow() + ?.addOnCompleteListener { task -> if (task.isSuccessful) { reviewInfo = task.result AppLogger.d("InAppReview", "ReviewInfo obtained successfully.") @@ -89,18 +92,24 @@ object InAppReviewHelper { /** * Once you decide it's time to actually show the prompt (e.g. in onResume, after UI ready), * call this. If reviewInfo is non-null it will launch; otherwise it just logs "no Info." + * + * @param activity The activity to launch the review flow in + * @param reviewManagerParam Ignored - kept for API compatibility with FOSS build + * @param analyticsManager Analytics manager for tracking events */ - fun showReviewIfPossible(activity: Activity, reviewManager: ReviewManager, analyticsManager: AnalyticsManager) { + fun showReviewIfPossible(activity: Activity, reviewManagerParam: Any?, analyticsManager: AnalyticsManager) { reviewInfo?.let { info -> - reviewManager.launchReviewFlow(activity, info) - .addOnCompleteListener { + reviewManager?.launchReviewFlow(activity, info) + ?.addOnCompleteListener { AppLogger.d("InAppReview", "Review flow finished.") // Track review flow completed scope.launch { analyticsManager.trackEvent(AnalyticsEvent.ReviewPromptCompleted) } reviewInfo = null - } + } ?: run { + AppLogger.d("InAppReview", "ReviewManager was null; cannot launch review flow.") + } } ?: run { AppLogger.d("InAppReview", "ReviewInfo was null; cannot launch review flow.") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d350ae6ba..05e01e6f2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,19 +23,21 @@ + - + + - - - + + + + @@ -62,6 +64,7 @@ --> - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - @@ -121,77 +163,8 @@ - - - - - - - - - - - - - - - - - - - - + + android:windowSoftInputMode="stateHidden" + tools:ignore="DiscouragedApi" /> + android:windowSoftInputMode="stateHidden" + tools:ignore="DiscouragedApi" /> + android:value="39" /> @@ -255,6 +230,12 @@ + + diff --git a/app/src/main/assets/dweb/dweb_file_upload_response.json b/app/src/main/assets/dweb/dweb_file_upload_response.json new file mode 100644 index 000000000..62998d999 --- /dev/null +++ b/app/src/main/assets/dweb/dweb_file_upload_response.json @@ -0,0 +1,5 @@ +{ + "file_hash": "o4xhgmqklivhmwodow7e7hc5bu53637r6vnhq47gjaw63awvgwya", + "name": "20260222_020921.IMG_1771706361851.jpg", + "updated_collection_hash": "petlwhi34vvjbqjci47do2jpt5k764netnhk5ritf763ivo65ioq" +} \ No newline at end of file diff --git a/app/src/main/java/info/guardianproject/netcipher/webkit/WebkitProxy.java b/app/src/main/java/info/guardianproject/netcipher/webkit/WebkitProxy.java deleted file mode 100644 index 1431b6ed5..000000000 --- a/app/src/main/java/info/guardianproject/netcipher/webkit/WebkitProxy.java +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright 2015 Anthony Restaino - * Copyright 2012-2016 Nathan Freitas - - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package info.guardianproject.netcipher.webkit; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.net.Proxy; -import android.os.Parcelable; -import android.util.ArrayMap; - -import net.opendasharchive.openarchive.util.Utility; - -import java.io.IOException; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.net.InetSocketAddress; -import java.net.Socket; - -import javax.annotation.Nullable; - -import info.guardianproject.netcipher.proxy.OrbotHelper; -import timber.log.Timber; - -public class WebkitProxy { - - private final static int REQUEST_CODE = 0; - - private WebkitProxy() { - // this is a utility class with only static methods - } - - public static boolean setProxy(Context ctx, String host, int port) { - setSystemProperties(host, port); - - return setWebkitProxyLollipop(ctx, host, port); - } - - private static void setSystemProperties(String host, int port) { - - System.setProperty("proxyHost", host); - System.setProperty("proxyPort", Integer.toString(port)); - - System.setProperty("http.proxyHost", host); - System.setProperty("http.proxyPort", Integer.toString(port)); - - System.setProperty("https.proxyHost", host); - System.setProperty("https.proxyPort", Integer.toString(port)); - - System.setProperty("socks.proxyHost", host); - System.setProperty("socks.proxyPort", Integer.toString(OrbotHelper.DEFAULT_PROXY_SOCKS_PORT)); - - System.setProperty("socksProxyHost", host); - System.setProperty("socksProxyPort", Integer.toString(OrbotHelper.DEFAULT_PROXY_SOCKS_PORT)); - } - - private static void resetSystemProperties() { - - System.setProperty("proxyHost", ""); - System.setProperty("proxyPort", ""); - - System.setProperty("http.proxyHost", ""); - System.setProperty("http.proxyPort", ""); - - System.setProperty("https.proxyHost", ""); - System.setProperty("https.proxyPort", ""); - - System.setProperty("socks.proxyHost", ""); - System.setProperty("socks.proxyPort", Integer.toString(OrbotHelper.DEFAULT_PROXY_SOCKS_PORT)); - - System.setProperty("socksProxyHost", ""); - System.setProperty("socksProxyPort", Integer.toString(OrbotHelper.DEFAULT_PROXY_SOCKS_PORT)); - } - - - public static boolean resetLollipopProxy(Context appContext) { - return setWebkitProxyLollipop(appContext, null, 0); - } - - // http://stackanswers.com/questions/25272393/android-webview-set-proxy-programmatically-on-android-l - // for android.util.ArrayMap methods - @SuppressWarnings({"rawtypes", "JavaReflectionMemberAccess", "unchecked"}) - @SuppressLint({"DiscouragedPrivateApi", "PrivateApi"}) - private static boolean setWebkitProxyLollipop(Context appContext, String host, int port) { - - try { - Class applictionClass = Class.forName("android.app.Application"); - Field mLoadedApkField = applictionClass.getDeclaredField("mLoadedApk"); - mLoadedApkField.setAccessible(true); - - Object mloadedApk = mLoadedApkField.get(appContext); - Class loadedApkClass = Class.forName("android.app.LoadedApk"); - Field mReceiversField = loadedApkClass.getDeclaredField("mReceivers"); - mReceiversField.setAccessible(true); - - ArrayMap receivers = (ArrayMap) mReceiversField.get(mloadedApk); - - if (receivers != null) { - for (Object receiverMap : receivers.values()) { - for (Object receiver : ((ArrayMap) receiverMap).keySet()) { - Class clazz = receiver.getClass(); - - if (clazz.getName().contains("ProxyChangeListener")) { - Method onReceiveMethod = clazz.getDeclaredMethod("onReceive", Context.class, Intent.class); - Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION); - Object proxyInfo = null; - - if (host != null) { - final String CLASS_NAME = "android.net.ProxyInfo"; - Class cls = Class.forName(CLASS_NAME); - Method buildDirectProxyMethod = cls.getMethod("buildDirectProxy", String.class, Integer.TYPE); - proxyInfo = buildDirectProxyMethod.invoke(cls, host, port); - } - - intent.putExtra("proxy", (Parcelable) proxyInfo); - onReceiveMethod.invoke(receiver, appContext, intent); - } - } - } - } - - return true; - } - catch (ClassNotFoundException | NoSuchFieldException | IllegalAccessException | - NoSuchMethodException | InvocationTargetException e) - { - Timber.d(e,"Exception setting WebKit proxy on Lollipop through ProxyChangeListener."); - } - - return false; - } - - @SuppressWarnings("unused") - public static boolean resetProxy(String appClass, Context ctx) { - resetSystemProperties(); - - return resetLollipopProxy(ctx); - } - - @SuppressWarnings({"unused", "rawtypes"}) - @SuppressLint("PrivateApi") - @Nullable - public static Object getRequestQueue(Context ctx) throws Exception { - Object ret = null; - Class networkClass = Class.forName("android.webkit.Network"); - - Object networkObj = invokeMethod(networkClass, "getInstance", - new Object[]{ ctx }, Context.class); - - if (networkObj != null) { - ret = getDeclaredField(networkObj, "mRequestQueue"); - } - - return ret; - } - - @SuppressWarnings("SameParameterValue") - private static Object getDeclaredField(Object obj, String name) - throws NoSuchFieldException, IllegalAccessException - { - Field f = obj.getClass().getDeclaredField(name); - f.setAccessible(true); - - return f.get(obj); - } - - @SuppressWarnings({"SameParameterValue", "rawtypes", "unchecked"}) - private static Object invokeMethod(Object object, String methodName, Object[] params, - Class... types) throws Exception - { - Object out; - Class c = object instanceof Class ? (Class) object : object.getClass(); - - if (types != null) { - Method method = c.getMethod(methodName, types); - out = method.invoke(object, params); - } - else { - Method method = c.getMethod(methodName); - out = method.invoke(object); - } - - return out; - } - - public static Socket getSocket(String proxyHost, int proxyPort) throws IOException - { - Socket sock = new Socket(); - - sock.connect(new InetSocketAddress(proxyHost, proxyPort), 10000); - - return sock; - } - - @SuppressWarnings("unused") - public static Socket getSocket() throws IOException { - return getSocket(OrbotHelper.DEFAULT_PROXY_HOST, OrbotHelper.DEFAULT_PROXY_SOCKS_PORT); - } - - @SuppressWarnings("unused") - @Nullable - public static AlertDialog initOrbot(Activity activity, - CharSequence stringTitle, - CharSequence stringMessage, - CharSequence stringButtonYes, - CharSequence stringButtonNo) - { - Intent intentScan = new Intent("org.torproject.android.START_TOR"); - intentScan.addCategory(Intent.CATEGORY_DEFAULT); - - try { - activity.startActivityForResult(intentScan, REQUEST_CODE); - return null; - } - catch (ActivityNotFoundException e) { - return showDownloadDialog(activity, stringTitle, stringMessage, stringButtonYes, - stringButtonNo); - } - } - - private static AlertDialog showDownloadDialog(final Activity activity, - CharSequence stringTitle, - CharSequence stringMessage, - CharSequence stringButtonYes, - CharSequence stringButtonNo) - { - AlertDialog.Builder downloadDialog = new AlertDialog.Builder(activity); - downloadDialog.setTitle(stringTitle); - downloadDialog.setMessage(stringMessage); - - downloadDialog.setPositiveButton(stringButtonYes, (dialogInterface, i) -> - Utility.INSTANCE.openStore(activity, "org.torproject.android")); - - downloadDialog.setNegativeButton(stringButtonNo, (dialogInterface, i) -> {}); - - return downloadDialog.show(); - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt deleted file mode 100644 index 2d7e2df54..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt +++ /dev/null @@ -1,72 +0,0 @@ -package net.opendasharchive.openarchive - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.databinding.RvFoldersRowBinding -import net.opendasharchive.openarchive.db.Project -import java.lang.ref.WeakReference - -interface FolderAdapterListener { - - fun projectClicked(project: Project) - -} - -class FolderAdapter(private val context: Context, private val listener: FolderAdapterListener, private val isArchived: Boolean = false) : ListAdapter(DIFF_CALLBACK) { - - inner class FolderViewHolder(private val binding: RvFoldersRowBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(project: Project) { - - binding.rvTitle.text = project.description - - val icon = ContextCompat.getDrawable(context, R.drawable.ic_folder_new) - icon?.setTint(ContextCompat.getColor(context, R.color.colorOnBackground)) - binding.rvIcon.setImageDrawable(icon) - - itemView.setOnClickListener { - listener.projectClicked(project) - } - } - } - - companion object { - private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Project, newItem: Project): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: Project, newItem: Project): Boolean { - return oldItem.description == newItem.description - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FolderViewHolder { - return FolderViewHolder( - RvFoldersRowBinding.inflate( - LayoutInflater.from(parent.context), - parent, false - ) - ) - } - - override fun onBindViewHolder(holder: FolderViewHolder, position: Int) { - val project = getItem(position) - - holder.bind( project) - } - - fun update(projects: List) { - - submitList(projects) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt index 79564f946..12cc8f1b8 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt @@ -1,36 +1,47 @@ package net.opendasharchive.openarchive import android.app.NotificationChannel +import android.os.Build import android.app.NotificationManager -import android.app.UiModeManager import android.content.Context -import android.os.Build import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader import coil3.video.VideoFrameDecoder import com.orm.SugarApp -import info.guardianproject.netcipher.proxy.OrbotHelper -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.lifecycleScope +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import net.opendasharchive.openarchive.core.repositories.CacheCleanupWorker +import net.opendasharchive.openarchive.core.di.torModule +import net.opendasharchive.openarchive.services.tor.TorConstants +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.MigrationWorker import net.opendasharchive.openarchive.core.di.coreModule +import net.opendasharchive.openarchive.core.di.databaseModule import net.opendasharchive.openarchive.core.di.featuresModule import net.opendasharchive.openarchive.core.di.passcodeModule import net.opendasharchive.openarchive.core.di.retrofitModule -import net.opendasharchive.openarchive.core.di.unixSocketModule import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.features.settings.passcode.PasscodeManager +import net.opendasharchive.openarchive.util.C2paHelper +import net.opendasharchive.openarchive.util.CleanInsightsManager import net.opendasharchive.openarchive.util.Prefs +import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger -import org.koin.android.ext.android.inject import org.koin.core.context.startKoin import org.koin.core.logger.Level @@ -42,6 +53,8 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) + // Initialize ACRA for FOSS builds (no-op for GMS builds) + AppLogger.initAcra(this) } private fun applyTheme() { @@ -57,20 +70,33 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv // Initialize logging first AppLogger.init(applicationContext, initDebugger = true) - registerActivityLifecycleCallbacks(PasscodeManager()) - Prefs.load(this) + // Initialize C2PA Helper + C2paHelper.init(this) + + // Trigger Room migration if needed (SugarORM → Room) + if (!Prefs.isRoomMigrated) { + val migrationRequest = OneTimeWorkRequestBuilder() + .build() + WorkManager.getInstance(this).enqueueUniqueWork( + "RoomMigration", + ExistingWorkPolicy.KEEP, + migrationRequest + ) + } + // Initialize Koin DI startKoin { androidLogger(Level.DEBUG) androidContext(this@SaveApp) modules( + databaseModule, coreModule, + passcodeModule, featuresModule, retrofitModule, - unixSocketModule, - passcodeModule, + torModule, analyticsModule( mixpanelToken = getString(R.string.mixpanel_key), cleanInsightsConsentChecker = { CleanInsightsManager.hasConsent() } @@ -80,7 +106,52 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv applyTheme() - if (Prefs.useTor) initNetCipher() + // 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, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder( + CacheCleanupWorker.REPEAT_INTERVAL_DAYS, + java.util.concurrent.TimeUnit.DAYS + ) + .setConstraints( + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .build() + ) + .build() + ) + + // Start embedded Tor service if enabled. + // On Android 12+ we cannot call startForegroundService() when the process is launched + // from the background (e.g. Android restarting SnowbirdService via START_STICKY). + // Strategy: try to start immediately (gives Tor a head-start before Snowbird initialises), + // and if the OS rejects it we register a one-shot observer to retry on first foreground entry. + if (Prefs.useTor) { + val torServiceManager: TorServiceManager by inject() + try { + torServiceManager.start() + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + e is android.app.ForegroundServiceStartNotAllowedException + ) { + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + torServiceManager.start() + owner.lifecycle.removeObserver(this) + } + }) + } else { + throw e + } + } + } // Legacy CleanInsightsManager (kept for backwards compatibility) CleanInsightsManager.init(this) @@ -93,7 +164,9 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv AppLogger.setAnalyticsManager(analyticsManager) // Set app version for session tracker - (sessionTracker as? net.opendasharchive.openarchive.analytics.api.session.SessionTrackerImpl)?.setAppVersion(BuildConfig.VERSION_NAME) + (sessionTracker as? net.opendasharchive.openarchive.analytics.api.session.SessionTrackerImpl)?.setAppVersion( + BuildConfig.VERSION_NAME + ) // Set user properties (GDPR-compliant) analyticsManager.setUserProperty("app_version", BuildConfig.VERSION_NAME) @@ -104,6 +177,7 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv } createSnowbirdNotificationChannel() + createTorNotificationChannel() } override fun onStart(owner: LifecycleOwner) { @@ -127,15 +201,27 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv } } - private fun initNetCipher() { - AppLogger.d("Initializing NetCipher client") - val oh = OrbotHelper.get(this) + override fun onTerminate() { + super.onTerminate() + // Clean up Tor service when app is terminated + if (Prefs.useTor) { + val torServiceManager: TorServiceManager by inject() + torServiceManager.cleanup() + } + } - if (BuildConfig.DEBUG) { - oh.skipOrbotValidation() + private fun createTorNotificationChannel() { + val channel = NotificationChannel( + TorConstants.TOR_NOTIFICATION_CHANNEL_ID, + getString(R.string.tor_notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.tor_notification_channel_description) } -// oh.init() + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) } private fun createSnowbirdNotificationChannel() { @@ -175,4 +261,4 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv .logger(AppLogger.imageLogger) .build() } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/SpaceAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/SpaceAdapter.kt deleted file mode 100644 index a548c89cd..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/SpaceAdapter.kt +++ /dev/null @@ -1,155 +0,0 @@ -package net.opendasharchive.openarchive - -import android.content.Context -import android.graphics.Rect -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.databinding.RvSpacesRowBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.util.extensions.scaled -import java.lang.ref.WeakReference - -interface SpaceAdapterListener { - - fun spaceClicked(space: Space) - - fun editSpaceClicked(spaceId: Long?) - - fun getSelectedSpace(): Space? -} - -class SpaceAdapter(private val context: Context, listener: SpaceAdapterListener?) : ListAdapter(DIFF_CALLBACK), SpaceAdapterListener { - - inner class ViewHolder(private val binding: RvSpacesRowBinding) : RecyclerView.ViewHolder(binding.root) { - - fun bind(listener: WeakReference?, space: Space?) { - - val isSelected = listener?.get()?.getSelectedSpace()?.id == space?.id - itemView.isSelected = isSelected - val textColorRes = if (isSelected) R.color.colorTertiary else R.color.colorText - val iconColorRes = if (isSelected) R.color.colorTertiary else R.color.colorOnBackground - val backgroundRes = if (isSelected) R.drawable.item_background_selector else android.R.color.transparent - - binding.root.setBackgroundResource(backgroundRes) - - val icon = space?.getAvatar(context)?.scaled(32, context) - - icon?.setTint(ContextCompat.getColor(binding.rvIcon.context, iconColorRes)) - binding.rvIcon.setImageDrawable(icon) - - binding.rvEdit.setColorFilter(ContextCompat.getColor(binding.rvEdit.context, iconColorRes)) - - - binding.rvTitle.text = space?.friendlyName - binding.rvTitle.setTextColor(ContextCompat.getColor(binding.rvTitle.context, textColorRes)) - - - binding.rvEdit.setOnClickListener { - listener?.get()?.editSpaceClicked(space?.id) - } - - if (space != null) { - binding.root.setOnClickListener { - listener?.get()?.spaceClicked(space) - } - } else { - binding.root.setOnClickListener(null) - } - } - } - - companion object { - private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Space, newItem: Space): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: Space, newItem: Space): Boolean { - return oldItem.friendlyName == newItem.friendlyName - } - } - - } - - private val mListener = WeakReference(listener) - - private var mLastSelected: Space? = null - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(RvSpacesRowBinding.inflate(LayoutInflater.from(parent.context), - parent, false)) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val space = getItem(position) - - holder.bind(WeakReference(this), space) - } - - fun update(spaces: List) { - notifyItemChanged(getIndex(mLastSelected)) - - //@Suppress("NAME_SHADOWING") - //val spaces = spaces.toMutableList() - //spaces.add(Space(ADD_SPACE_ID)) - - submitList(spaces) - } - - override fun spaceClicked(space: Space) { - // Notify previous and new selected items - val previousIndex = getIndex(getSelectedSpace()) - val newIndex = getIndex(space) - - mLastSelected = space - - notifyItemChanged(previousIndex) - notifyItemChanged(newIndex) - - mListener.get()?.spaceClicked(space) - } - - override fun editSpaceClicked(spaceId: Long?) { - mListener.get()?.editSpaceClicked(spaceId) - } - - override fun getSelectedSpace(): Space? { - mLastSelected = mListener.get()?.getSelectedSpace() - - return mLastSelected - } - - private fun getIndex(space: Space?): Int { - return if (space == null) { - -1 - } - else { - currentList.indexOf(space) - } - } -} - -class SpaceItemDecoration(private val space: Int) : RecyclerView.ItemDecoration() { - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State - ) { - // Add space to the bottom of each item except the last one - val position = parent.getChildAdapterPosition(view) - val itemCount = state.itemCount - - outRect.bottom = space - - // Optional: Add top margin only to the first item - if (position == 0) { - outRect.top = space - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/AppConfig.kt b/app/src/main/java/net/opendasharchive/openarchive/core/config/AppConfig.kt similarity index 59% rename from app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/AppConfig.kt rename to app/src/main/java/net/opendasharchive/openarchive/core/config/AppConfig.kt index 7b7c1fa79..a45741c54 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/AppConfig.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/config/AppConfig.kt @@ -1,4 +1,4 @@ -package net.opendasharchive.openarchive.features.settings.passcode +package net.opendasharchive.openarchive.core.config data class AppConfig( val passcodeLength: Int = 6, @@ -9,4 +9,9 @@ data class AppConfig( val isDwebEnabled: Boolean = false, val multipleProjectSelectionMode: Boolean = false, val useCustomCamera: Boolean = false, + val useComposeUploadManager: Boolean = true, + val autoVerifyPasscode: Boolean = false, + val useMocks: Boolean = false, + val simulateErrors: Boolean = false, + val mockDelayMs: Long = 500L, ) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt index 80fe47bc3..976f70f91 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt @@ -1,39 +1,75 @@ package net.opendasharchive.openarchive.core.di -import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +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 import net.opendasharchive.openarchive.features.core.dialog.ResourceProvider import net.opendasharchive.openarchive.features.folders.BrowseFoldersViewModel -import net.opendasharchive.openarchive.features.main.MainViewModel -import net.opendasharchive.openarchive.features.main.ui.HomeViewModel +import net.opendasharchive.openarchive.features.folders.CreateNewFolderViewModel +import net.opendasharchive.openarchive.features.settings.FolderDetailViewModel +import net.opendasharchive.openarchive.features.settings.FoldersViewModel +import net.opendasharchive.openarchive.features.settings.SpaceSetupSuccessViewModel import net.opendasharchive.openarchive.features.settings.license.SetupLicenseViewModel import org.koin.android.ext.koin.androidApplication -import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf +import org.koin.core.qualifier.named import org.koin.dsl.module val coreModule = module { + + single { + AppConfig( + passcodeLength = 6, + enableHapticFeedback = true, + maxRetryLimitEnabled = false, + biometricAuthEnabled = false, + maxFailedAttempts = 5, + isDwebEnabled = true, + useCustomCamera = true, + useMocks = false, // Default to true for now as requested for testing + simulateErrors = false, + mockDelayMs = 500L + ) + } + // Provide a ResourceProvider using the application context. single { DefaultResourceProvider(androidApplication()) } - // Provide DialogStateManager and let Koin inject the ResourceProvider. - viewModel { DialogStateManager(get()) } + // Provide the DialogStateManager as a Singleton + single { DialogStateManager(resourceProvider = get()) } - viewModel { HomeViewModel() } + // Dispatchers + single(named("io")) { Dispatchers.IO } + single(named("main")) { Dispatchers.Main } - viewModel { - MainViewModel() - } + viewModelOf(::BrowseFoldersViewModel) - viewModel { - BrowseFoldersViewModel( - context = get() - ) + viewModelOf(::CreateNewFolderViewModel) + + viewModelOf(::SetupLicenseViewModel) + + viewModelOf(::SpaceSetupSuccessViewModel) + + viewModelOf(::FoldersViewModel) + + viewModelOf(::FolderDetailViewModel) + + // Default SharedPreferences for general settings + single(named("default_prefs")) { + PreferenceManager.getDefaultSharedPreferences(androidApplication()) } + // Centrally managed security flags + single { SecurityManager(get(named("default_prefs"))) } - viewModelOf(::SetupLicenseViewModel) + // Secure storage for C2PA signing keys (Android Keystore backed) + single { C2paKeyStore(androidApplication()) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/DatabaseModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/DatabaseModule.kt new file mode 100644 index 000000000..fa58740bd --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/DatabaseModule.kt @@ -0,0 +1,51 @@ +package net.opendasharchive.openarchive.core.di + +import androidx.room3.Room +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.preferencesDataStoreFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.db.AppDatabase +import net.opendasharchive.openarchive.core.repositories.* +import net.opendasharchive.openarchive.core.security.TinkVaultCredentialStore +import net.opendasharchive.openarchive.core.security.VaultCredentialStore +import org.koin.android.ext.koin.androidApplication +import org.koin.android.ext.koin.androidContext +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val databaseModule = module { + single { + Room.databaseBuilder( + androidApplication(), + AppDatabase::class.java, + "openarchive.db_room" + ) +// .setQueryCallback(queryCallback = { sqlQuery, bindArgs -> +// AppLogger.d("SQL Query: $sqlQuery, Bind Args: $bindArgs") +// }, executor = Dispatchers.IO.asExecutor() ) + .build() + } + + single { get().vaultDao() } + single { get().archiveDao() } + single { get().submissionDao() } + single { get().evidenceDao() } + single { get().migrationDao() } + single { get().dwebDao() } + + single { + PreferenceDataStoreFactory.create( + produceFile = { androidContext().preferencesDataStoreFile("settings") } + ) + } + + single { SettingsRepositoryImpl(get()) } + single { TinkVaultCredentialStore(androidContext(), get(named("io"))) } + + single { VaultRepositoryImpl(androidContext(), get(), get(), get(), get(), get(named("io"))) } + single { ArchiveRepositoryImpl(get(), get(), get(), get(), get(), get(named("io"))) } + single { SubmissionRepositoryImpl(get(), get(named("io"))) } + single { EvidenceRepositoryImpl(get(), get(), get(), get(), get(named("io"))) } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/FeaturesModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/FeaturesModule.kt index 91abe16e1..e64387663 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/FeaturesModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/FeaturesModule.kt @@ -1,64 +1,47 @@ package net.opendasharchive.openarchive.core.di import android.app.Application -import net.opendasharchive.openarchive.features.internetarchive.internetArchiveModule +import android.content.ContentResolver +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.features.main.ui.HomeViewModel +import net.opendasharchive.openarchive.features.main.ui.SharedImportState +import net.opendasharchive.openarchive.features.main.ui.MainMediaViewModel +import net.opendasharchive.openarchive.features.media.PreviewMediaViewModel +import net.opendasharchive.openarchive.features.media.ReviewMediaViewModel import net.opendasharchive.openarchive.features.spaces.SpaceListViewModel -import net.opendasharchive.openarchive.services.SaveClientFactory -import net.opendasharchive.openarchive.services.SaveClientFactoryImpl -import net.opendasharchive.openarchive.services.webdav.WebDavRepository -import net.opendasharchive.openarchive.services.webdav.WebDavViewModel -import net.opendasharchive.openarchive.services.snowbird.ISnowbirdFileRepository -import net.opendasharchive.openarchive.services.snowbird.ISnowbirdGroupRepository -import net.opendasharchive.openarchive.services.snowbird.ISnowbirdRepoRepository -import net.opendasharchive.openarchive.services.snowbird.SnowbirdFileRepository -import net.opendasharchive.openarchive.services.snowbird.SnowbirdFileViewModel -import net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupRepository -import net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupViewModel -import net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoRepository -import net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoViewModel -import org.koin.core.module.dsl.viewModel +import net.opendasharchive.openarchive.features.spaces.SpaceSetupViewModel +import net.opendasharchive.openarchive.upload.JobSchedulerUploadJobScheduler +import net.opendasharchive.openarchive.upload.UploadJobScheduler +import net.opendasharchive.openarchive.upload.UploadManagerViewModel +import org.koin.android.ext.koin.androidApplication +import org.koin.androidx.scope.dsl.activityRetainedScope import org.koin.core.module.dsl.viewModelOf -import org.koin.core.qualifier.named import org.koin.dsl.module val featuresModule = module { - includes(internetArchiveModule) - // TODO: have some registry of feature modules - - - single { SnowbirdFileRepository(get(named("retrofit"))) } - single { SnowbirdGroupRepository(get(named("retrofit"))) } - single { SnowbirdRepoRepository(get(named("retrofit"))) } - -// single { SnowbirdFileRepository(get(named("unixSocket"))) } -// single { SnowbirdGroupRepository(get(named("unixSocket"))) } -// single { SnowbirdRepoRepository(get(named("unixSocket"))) } - - viewModel { (application: Application) -> - SnowbirdGroupViewModel( - application = application, - repository = get() - ) + includes( + internetArchiveModule, + webDavModule, + snowbirdModule, + repositoriesModule + ) + + activityRetainedScope { + scoped { Navigator() } } - viewModel { (application: Application) -> - SnowbirdFileViewModel( - application = application, - repository = get() - ) - } + viewModelOf(::HomeViewModel) + viewModelOf(::SpaceListViewModel) + viewModelOf(::SpaceSetupViewModel) + viewModelOf(::MainMediaViewModel) - viewModel { (application: Application) -> - SnowbirdRepoViewModel( - application = application, - repository = get() - ) + single { + get().contentResolver } + viewModelOf(::ReviewMediaViewModel) + viewModelOf(::PreviewMediaViewModel) - viewModelOf(::SpaceListViewModel) - - // WebDAV - single { SaveClientFactoryImpl(get()) } - single { WebDavRepository(get()) } - viewModel { WebDavViewModel(get(), get(), get()) } -} \ No newline at end of file + viewModelOf(::UploadManagerViewModel) + single { JobSchedulerUploadJobScheduler(androidApplication()) } + single { SharedImportState() } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/InternetArchiveModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/InternetArchiveModule.kt new file mode 100644 index 000000000..8104c204f --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/InternetArchiveModule.kt @@ -0,0 +1,16 @@ +package net.opendasharchive.openarchive.core.di + +import net.opendasharchive.openarchive.services.internetarchive.data.InternetArchiveAuthenticator +import net.opendasharchive.openarchive.services.internetarchive.data.InternetArchiveRepository +import net.opendasharchive.openarchive.services.internetarchive.presentation.details.InternetArchiveDetailsViewModel +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.InternetArchiveLoginViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val internetArchiveModule = module { + single { InternetArchiveAuthenticator(get(), get()) } + single { InternetArchiveRepository() } + + viewModelOf(::InternetArchiveDetailsViewModel) + viewModelOf(::InternetArchiveLoginViewModel) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt index 5b5581791..6f5d809d8 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt @@ -1,30 +1,22 @@ package net.opendasharchive.openarchive.core.di import android.content.Context -import net.opendasharchive.openarchive.features.settings.passcode.AppConfig +import android.content.SharedPreferences +import net.opendasharchive.openarchive.core.config.AppConfig import net.opendasharchive.openarchive.features.settings.passcode.HapticManager import net.opendasharchive.openarchive.features.settings.passcode.HashingStrategy import net.opendasharchive.openarchive.features.settings.passcode.PBKDF2HashingStrategy +import net.opendasharchive.openarchive.features.settings.passcode.PasscodeFlowState +import net.opendasharchive.openarchive.features.settings.passcode.PasscodeGate import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository +import net.opendasharchive.openarchive.features.settings.passcode.PrefAead import net.opendasharchive.openarchive.features.settings.passcode.passcode_entry.PasscodeEntryViewModel import net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupViewModel -import org.koin.core.module.dsl.viewModel +import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val passcodeModule = module { - single { - AppConfig( - passcodeLength = 6, - enableHapticFeedback = true, - maxRetryLimitEnabled = false, - biometricAuthEnabled = false, - maxFailedAttempts = 5, - isDwebEnabled = false, - useCustomCamera = true, - ) - } - single { HapticManager( appConfig = get(), @@ -35,16 +27,28 @@ val passcodeModule = module { PBKDF2HashingStrategy() } + // SharedPreferences injected once + single { + get().applicationContext + .getSharedPreferences("secret_shared_prefs", Context.MODE_PRIVATE) + } + + // Crypto primitive — singleton + single { PrefAead(get()) } + + single { PasscodeFlowState() } + single { PasscodeGate(get()) } + single { - val hashingStrategy: HashingStrategy = PBKDF2HashingStrategy() PasscodeRepository( - context = get(), - config = get(), - hashingStrategy = hashingStrategy + prefs = get(), + config = get(), + hashingStrategy = get(), + aead = get(), ) } - viewModel { PasscodeEntryViewModel(get(), get()) } - viewModel { PasscodeSetupViewModel(get(), get()) } -} \ No newline at end of file + viewModelOf(::PasscodeEntryViewModel) + viewModelOf(::PasscodeSetupViewModel) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/RepositoriesModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/RepositoriesModule.kt new file mode 100644 index 000000000..3820a9266 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/RepositoriesModule.kt @@ -0,0 +1,38 @@ +package net.opendasharchive.openarchive.core.di + +import net.opendasharchive.openarchive.core.repositories.CollectionRepository +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.core.repositories.SugarCollectionRepository +import net.opendasharchive.openarchive.core.repositories.SugarMediaRepository +import net.opendasharchive.openarchive.core.repositories.SugarProjectRepository +import net.opendasharchive.openarchive.core.repositories.SugarSpaceRepository +import net.opendasharchive.openarchive.core.security.VaultCredentialStore +import net.opendasharchive.openarchive.util.Prefs +import net.opendasharchive.openarchive.core.repositories.* +import org.koin.android.ext.koin.androidContext +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val repositoriesModule = module { + single { FileCleanupHelper(androidContext()) } + + // Home/Main repositories + single { + if (Prefs.isRoomMigrated) get() + else SugarSpaceRepository(androidContext(), get(), get(named("io"))) + } + single { + if (Prefs.isRoomMigrated) get() + else SugarProjectRepository(get(), get(named("io"))) + } + single { + if (Prefs.isRoomMigrated) get() + else SugarCollectionRepository(get(named("io"))) + } + single { + if (Prefs.isRoomMigrated) get() + else SugarMediaRepository(get(), get(named("io"))) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/RetrofitModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/RetrofitModule.kt index 9035dd2a0..ef8aa2726 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/RetrofitModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/RetrofitModule.kt @@ -15,36 +15,45 @@ import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit val retrofitModule = module { + single { + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + } - single { + single(named("snowbird_http_logger")) { HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } } - single { + single(named("snowbird_okhttp")) { OkHttpClient.Builder() .connectTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) - .addInterceptor(get()) + .addInterceptor(get(named("snowbird_http_logger"))) .build() } - single { + single(named("snowbird_retrofit")) { Retrofit.Builder() .baseUrl("http://localhost:8080/api/") - .client(get()) - .addConverterFactory(Json.asConverterFactory("application/json; charset=UTF8".toMediaType())) + .client(get(named("snowbird_okhttp"))) + .addConverterFactory(get().asConverterFactory("application/json; charset=UTF8".toMediaType())) .build() } - single { get().create(RetrofitClient::class.java) } + single(named("snowbird_retrofit_client")) { + get(named("snowbird_retrofit")).create(RetrofitClient::class.java) + } - single(named("retrofit")) { + single(named("snowbird_api")) { RetrofitAPI( context = get(), - client = get() + client = get(named("snowbird_retrofit_client")) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/SnowbirdModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/SnowbirdModule.kt new file mode 100644 index 000000000..57979d846 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/SnowbirdModule.kt @@ -0,0 +1,105 @@ +package net.opendasharchive.openarchive.core.di + +import net.opendasharchive.openarchive.core.config.AppConfig +import net.opendasharchive.openarchive.services.snowbird.presentation.dashboard.SnowbirdDashboardViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.file.SnowbirdFileViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdCreateGroupViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdGroupListViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdJoinGroupViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdShareViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.repo.SnowbirdRepoViewModel +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdFileRepository +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdGroupRepository +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdRepoRepository +import net.opendasharchive.openarchive.services.snowbird.service.repository.MockSnowbirdFileRepository +import net.opendasharchive.openarchive.services.snowbird.service.repository.MockSnowbirdGroupRepository +import net.opendasharchive.openarchive.services.snowbird.service.repository.MockSnowbirdRepoRepository +import net.opendasharchive.openarchive.services.snowbird.service.repository.SnowbirdFileRepository +import net.opendasharchive.openarchive.services.snowbird.service.repository.SnowbirdGroupRepository +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdServiceController +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdServiceControllerImpl +import net.opendasharchive.openarchive.services.snowbird.service.repository.SnowbirdRepoRepository +import net.opendasharchive.openarchive.services.snowbird.util.SnowbirdFileStorage +import net.opendasharchive.openarchive.util.ProcessingTracker +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.viewModelOf +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val snowbirdModule = module { + // Each ViewModel gets its own instance of ProcessingTracker + factory { ProcessingTracker() } + + singleOf(::SnowbirdServiceControllerImpl) { bind() } + + + single { + val appConfig = get() + if (appConfig.useMocks) { + MockSnowbirdFileRepository( + assetManager = androidContext().assets, + config = appConfig, + evidenceDao = get(), + archiveDao = get(), + dwebDao = get() + ) + } else { + SnowbirdFileRepository( + api = get(named("snowbird_api")), + evidenceDao = get(), + archiveDao = get(), + dwebDao = get() + ) + } + } + + single { + val appConfig = get() + if (appConfig.useMocks) { + MockSnowbirdGroupRepository( + assetManager = androidContext().assets, + config = appConfig, + vaultDao = get(), + dwebDao = get() + ) + } else { + SnowbirdGroupRepository( + api = get(named("snowbird_api")), + vaultDao = get(), + dwebDao = get() + ) + } + } + + single { + val appConfig = get() + if (appConfig.useMocks) { + MockSnowbirdRepoRepository( + assetManager = androidContext().assets, + config = appConfig, + archiveDao = get(), + submissionDao = get(), + dwebDao = get() + ) + } else { + SnowbirdRepoRepository( + api = get(named("snowbird_api")), + archiveDao = get(), + submissionDao = get(), + dwebDao = get() + ) + } + } + + single { SnowbirdFileStorage(get()) } + + viewModelOf(::SnowbirdFileViewModel) + viewModelOf(::SnowbirdRepoViewModel) + viewModelOf(::SnowbirdDashboardViewModel) + viewModelOf(::SnowbirdCreateGroupViewModel) + viewModelOf(::SnowbirdGroupListViewModel) + viewModelOf(::SnowbirdJoinGroupViewModel) + viewModelOf(::SnowbirdShareViewModel) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/TorModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/TorModule.kt new file mode 100644 index 000000000..0604b069a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/TorModule.kt @@ -0,0 +1,14 @@ +package net.opendasharchive.openarchive.core.di + +import net.opendasharchive.openarchive.services.tor.TorServiceManager +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +/** + * Koin module for Tor-related dependencies. + * + * Provides TorServiceManager as a singleton to manage the embedded Tor service. + */ +val torModule = module { + single { TorServiceManager(androidContext(), get()) } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/UnixSocketModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/UnixSocketModule.kt deleted file mode 100644 index 2bd3629ab..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/UnixSocketModule.kt +++ /dev/null @@ -1,15 +0,0 @@ -package net.opendasharchive.openarchive.core.di - -import net.opendasharchive.openarchive.features.main.UnixSocketClient -import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI -import net.opendasharchive.openarchive.services.snowbird.service.UnixSocketAPI -import org.koin.core.qualifier.named -import org.koin.dsl.module - -val unixSocketModule = module { - single { - UnixSocketClient(context = get()) - } - - single(named("unixSocket")) { UnixSocketAPI(get(), get()) } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/WebDavModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/WebDavModule.kt new file mode 100644 index 000000000..41f21fc94 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/WebDavModule.kt @@ -0,0 +1,19 @@ +package net.opendasharchive.openarchive.core.di + +import net.opendasharchive.openarchive.services.webdav.data.WebDavAuthenticator +import net.opendasharchive.openarchive.services.webdav.data.WebDavRepository +import net.opendasharchive.openarchive.services.webdav.presentation.login.WebDavLoginViewModel +import net.opendasharchive.openarchive.services.webdav.presentation.detail.WebDavDetailViewModel +import net.opendasharchive.openarchive.services.SaveClientFactory +import net.opendasharchive.openarchive.services.SaveClientFactoryImpl +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val webDavModule = module { + single { SaveClientFactoryImpl(get()) } + single { WebDavAuthenticator(get()) } + single { WebDavRepository(get(), get()) } + + viewModelOf(::WebDavLoginViewModel) + viewModelOf(::WebDavDetailViewModel) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/Archive.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Archive.kt new file mode 100644 index 000000000..2d3101dfc --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Archive.kt @@ -0,0 +1,23 @@ +package net.opendasharchive.openarchive.core.domain + +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable + +/** + * Archive - Domain representation of a folder or project. + * (Formerly known as Project) + */ +@Serializable +data class Archive( + val id: Long = 0L, + val description: String? = null, + val created: LocalDateTime? = null, + val vaultId: Long? = null, + val isArchived: Boolean = false, + val openSubmissionId: Long = -1L, + val licenseUrl: String? = null, + val isRemote: Boolean = false, + val archiveKey: String? = null, + val archiveHash: String? = null, + val permissions: ArchivePermission? = null +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/ArchivePermission.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/ArchivePermission.kt new file mode 100644 index 000000000..663dde231 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/ArchivePermission.kt @@ -0,0 +1,6 @@ +package net.opendasharchive.openarchive.core.domain + +enum class ArchivePermission { + READ_ONLY, + READ_WRITE +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/Credentials.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Credentials.kt new file mode 100644 index 000000000..704a63c36 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Credentials.kt @@ -0,0 +1,12 @@ +package net.opendasharchive.openarchive.core.domain + +import kotlinx.serialization.Serializable + +@Serializable +sealed class Credentials { + @Serializable + data class WebDav(val url: String, val user: String, val pass: String) : Credentials() + + @Serializable + data class InternetArchive(val email: String, val pass: String) : Credentials() +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/DomainError.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/DomainError.kt new file mode 100644 index 000000000..6580c43b4 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/DomainError.kt @@ -0,0 +1,13 @@ +package net.opendasharchive.openarchive.core.domain + +sealed class DomainError { + abstract val message: String + + data class Network(override val message: String, val code: Int? = null) : DomainError() + data class Server(override val message: String, val code: Int? = null) : DomainError() + data class Timeout(override val message: String = "The operation timed out.") : DomainError() + data class Unknown(override val message: String) : DomainError() + + val friendlyMessage: String + get() = message +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/DomainResult.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/DomainResult.kt new file mode 100644 index 000000000..f75b6c616 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/DomainResult.kt @@ -0,0 +1,13 @@ +package net.opendasharchive.openarchive.core.domain + +sealed class DomainResult { + data class Success(val data: T) : DomainResult() + data class Error(val error: DomainError) : DomainResult() +} + +inline fun DomainResult.valueOr(alternative: (DomainResult.Error) -> T): T { + return when (this) { + is DomainResult.Error -> alternative(this) + is DomainResult.Success -> this.data + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/Evidence.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Evidence.kt new file mode 100644 index 000000000..831206968 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Evidence.kt @@ -0,0 +1,67 @@ +package net.opendasharchive.openarchive.core.domain + +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable +import android.net.Uri +import androidx.core.net.toUri +import androidx.core.net.toFile +import java.io.File + +/** + * Evidence - Domain representation of a media asset and its metadata. + * (Formerly known as Media) + */ +@Serializable +data class Evidence( + val id: Long = 0L, + val originalFilePath: String = "", + val thumbnail: ByteArray? = null, + val mimeType: String = "", + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null, + val uploadedAt: LocalDateTime? = null, + val serverUrl: String = "", + val title: String = "", + val description: String = "", + val author: String = "", + val location: String = "", + val tags: List = emptyList(), + val licenseUrl: String? = null, + val mediaHashString: String = "", + val status: EvidenceStatus = EvidenceStatus.NEW, + val statusMessage: String = "", + val vaultId: Long = 0L, + val archiveId: Long = 0L, + val submissionId: Long = 0L, + val contentLength: Long = 0, + val progress: Long = 0, + val isFlagged: Boolean = false, + val priority: Int = 0, + val isSelected: Boolean = false, + val uploadPercentage: Int? = null, + val isDownloaded: Boolean = false +) { + val fileUri: Uri + get() = originalFilePath.toUri() + + val file: File + get() = fileUri.toFile() + + val isUploading + get() = status == EvidenceStatus.QUEUED + || status == EvidenceStatus.UPLOADING + || status == EvidenceStatus.ERROR +} + +/** + * EvidenceStatus - Lifecycle states of an Evidence item. + */ +@Serializable +enum class EvidenceStatus(val id: Int) { + NEW(0), + LOCAL(1), + QUEUED(2), + UPLOADING(4), + UPLOADED(5), + ERROR(9) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/Metadata.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Metadata.kt new file mode 100644 index 000000000..a5ff4ecdd --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Metadata.kt @@ -0,0 +1,44 @@ +package net.opendasharchive.openarchive.core.domain + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.Locale +import net.opendasharchive.openarchive.util.format + +/** + * Metadata - DTO for serializing Evidence to the format expected by the backend. + * This ensures compatibility with legacy "meta.json" structure. + */ +@Serializable +data class Metadata( + @SerialName("author") val author: String = "", + @SerialName("contentLength") val contentLength: Long = 0, + @SerialName("dateCreated") val dateCreated: String = "", + @SerialName("description") val description: String = "", + @SerialName("usage") val usage: String = "", + @SerialName("location") val location: String = "", + @SerialName("hash") val hash: String = "", + @SerialName("contentType") val contentType: String = "", + @SerialName("tags") val tags: String = "", + @SerialName("originalFileName") val originalFileName: String = "" +) + +/** + * Extension to convert Evidence domain model to Metadata DTO. + */ +fun Evidence.toMetadata(licenseUrl: String? = this.licenseUrl): Metadata { + val formattedDate = this.createdAt?.format("MMM d, yyyy h:mm:ss a", Locale.US) ?: "" + + return Metadata( + author = this.author, + contentLength = this.contentLength, + dateCreated = formattedDate, + description = this.description, + usage = licenseUrl ?: "", + location = this.location, + hash = this.mediaHashString, + contentType = this.mimeType, + tags = this.tags.joinToString(";"), + originalFileName = this.title + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/Submission.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Submission.kt new file mode 100644 index 000000000..2aa3cab6a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Submission.kt @@ -0,0 +1,15 @@ +package net.opendasharchive.openarchive.core.domain + +import kotlinx.datetime.LocalDateTime + +/** + * Submission - Domain representation of an upload batch or collection. + * (Formerly known as Collection) + */ +data class Submission( + val id: Long = 0L, + val archiveId: Long = 0L, + val vaultId: Long = 0L, + val uploadDate: LocalDateTime? = null, + val serverUrl: String? = null +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/Vault.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Vault.kt new file mode 100644 index 000000000..10a4965bd --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/Vault.kt @@ -0,0 +1,50 @@ +package net.opendasharchive.openarchive.core.domain + +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.util.Locale + +/** + * Vault - Domain representation of a server connection or account. + * (Formerly known as Space) + */ +@Serializable +data class Vault( + val id: Long = 0L, + val type: VaultType, + val name: String = "", + val username: String = "", + val displayName: String = "", + val password: String = "", + val host: String = "", + val metaData: String = "", + val licenseUrl: String? = null, + val vaultKey: String? = null, + val createdAt: LocalDateTime? = null +) { + val friendlyName: String + get() { + if (name.isNotBlank()) { + return name + } + return hostUrl?.host ?: name + } + + val initial: String + get() = (friendlyName.firstOrNull() ?: 'X').uppercase(Locale.getDefault()) + + val hostUrl: HttpUrl? + get() = host.toHttpUrlOrNull() +} + +/** + * VaultType - Types of supported backends. + */ +@Serializable +enum class VaultType(val id: Int, val friendlyName: String) { + PRIVATE_SERVER(0, "Private Server"), + INTERNET_ARCHIVE(1, "Internet Archive"), + DWEB_STORAGE(5, "DWeb Storage") +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/VaultAuth.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/VaultAuth.kt new file mode 100644 index 000000000..47c756e72 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/VaultAuth.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.core.domain + +data class VaultAuth( + val vaultId: Long, + val type: VaultType, + val username: String, + val secret: String +) + diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/VaultAuthenticator.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/VaultAuthenticator.kt new file mode 100644 index 000000000..f126bcb54 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/VaultAuthenticator.kt @@ -0,0 +1,17 @@ +package net.opendasharchive.openarchive.core.domain + +/** + * Standard interface for authenticating and validating server connections. + */ +interface VaultAuthenticator { + /** + * Authenticates with the service and returns a Vault object populated with + * necessary keys, display names, and metadata. + */ + suspend fun authenticate(credentials: Credentials): Result + + /** + * Validates an existing Vault connection (e.g., checking if keys are still valid). + */ + suspend fun testConnection(vault: Vault): Result +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/Mappers.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/Mappers.kt new file mode 100644 index 000000000..0ea873053 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/Mappers.kt @@ -0,0 +1,168 @@ +package net.opendasharchive.openarchive.core.domain.mappers + +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.domain.Submission +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.db.* +import net.opendasharchive.openarchive.util.* + +/** + * Extension mappers between Clean domain models and Room Entities. + */ + +// --- Room Entity Mappers --- + +fun VaultEntity.toDomain(): Vault = Vault( + id = this.id, + type = this.type, + name = this.name, + username = this.username, + displayName = this.displayName, + host = this.host, + metaData = this.metaData, + licenseUrl = this.licenseUrl, + createdAt = this.createdAt +) + +fun VaultWithDweb.toDomain(): Vault = vault.toDomain().copy( + vaultKey = dwebMetadata?.vaultKey +) + +fun Vault.toVaultEntity(): VaultEntity = VaultEntity( + id = this.id, + type = this.type, + name = this.name, + username = this.username, + displayName = this.displayName, + host = this.host, + metaData = this.metaData, + licenseUrl = this.licenseUrl, + createdAt = this.createdAt ?: DateUtils.now.toLocalDateTime() +) + +fun Vault.toDwebEntity(): VaultDwebEntity? = vaultKey?.let { + VaultDwebEntity( + vaultId = id, + vaultKey = it + ) +} + +fun ArchiveEntity.toDomain(): Archive = Archive( + id = this.id, + description = this.description, + created = this.createdAt, + vaultId = this.vaultId, + isArchived = this.archived, + openSubmissionId = this.openSubmissionId, + licenseUrl = this.licenseUrl, + isRemote = this.isRemote +) + +fun ArchiveWithDweb.toDomain(): Archive = archive.toDomain().copy( + archiveKey = dwebMetadata?.archiveKey, + archiveHash = dwebMetadata?.archiveHash, + permissions = dwebMetadata?.permissions +) + +fun Archive.toArchiveEntity(): ArchiveEntity = ArchiveEntity( + id = this.id, + description = this.description, + createdAt = this.created, + vaultId = this.vaultId ?: 0L, + archived = this.isArchived, + openSubmissionId = this.openSubmissionId, + licenseUrl = this.licenseUrl, + isRemote = this.isRemote +) + +fun Archive.toDwebEntity(): ArchiveDwebEntity? = + if (archiveKey != null && archiveHash != null && permissions != null) { + ArchiveDwebEntity( + archiveId = id, + archiveKey = archiveKey, + archiveHash = archiveHash, + permissions = permissions + ) + } else null + +fun SubmissionEntity.toDomain(): Submission = Submission( + id = this.id, + archiveId = this.archiveId, + vaultId = 0L, // Will be resolved by repository + uploadDate = this.uploadedAt, + serverUrl = this.serverUrl +) + +fun Submission.toSubmissionEntity(): SubmissionEntity = SubmissionEntity( + id = this.id, + archiveId = this.archiveId, + uploadedAt = this.uploadDate, + serverUrl = this.serverUrl +) + +fun EvidenceEntity.toDomain(vaultId: Long = 0L): Evidence = Evidence( + id = this.id, + originalFilePath = this.originalFilePath, + thumbnail = this.thumbnail, + mimeType = this.mimeType, + createdAt = this.createdAt, + updatedAt = this.updatedAt, + uploadedAt = this.uploadedAt, + serverUrl = this.serverUrl, + title = this.title, + description = this.description, + author = this.author, + location = this.location, + tags = if (this.tags.isBlank()) emptyList() else this.tags.split(";"), + licenseUrl = this.licenseUrl, + mediaHashString = this.mediaHashString, + status = this.status, + statusMessage = this.statusMessage, + vaultId = vaultId, + archiveId = this.archiveId, + submissionId = this.submissionId, + contentLength = this.contentLength, + progress = this.progress, + uploadPercentage = if (this.contentLength > 0) (this.progress.toFloat() / this.contentLength * 100).toInt() else null, + isFlagged = this.flag, + priority = this.priority, + isSelected = false // UI only +) + +fun EvidenceWithDweb.toDomain(vaultId: Long = 0L): Evidence = evidence.toDomain(vaultId).copy( + isDownloaded = dwebMetadata?.isDownloaded ?: false +) + +fun Evidence.toEvidenceEntity(): EvidenceEntity = EvidenceEntity( + id = this.id, + originalFilePath = this.originalFilePath, + mimeType = this.mimeType, + createdAt = this.createdAt, + updatedAt = this.updatedAt, + uploadedAt = this.uploadedAt, + serverUrl = this.serverUrl, + title = this.title, + description = this.description, + author = this.author, + location = this.location, + tags = this.tags.joinToString(";"), + licenseUrl = this.licenseUrl, + mediaHashString = this.mediaHashString, + status = this.status, + statusMessage = this.statusMessage, + archiveId = this.archiveId, + submissionId = this.submissionId, + contentLength = this.contentLength, + progress = this.progress, + flag = this.isFlagged, + priority = this.priority, + thumbnail = this.thumbnail +) + +fun Evidence.toDwebEntity(): EvidenceDwebEntity = EvidenceDwebEntity( + evidenceId = id, + isDownloaded = isDownloaded +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/SugarMappers.kt b/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/SugarMappers.kt new file mode 100644 index 000000000..65636d966 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/domain/mappers/SugarMappers.kt @@ -0,0 +1,173 @@ +package net.opendasharchive.openarchive.core.domain.mappers + +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.domain.Submission +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.db.sugar.Media +import net.opendasharchive.openarchive.db.sugar.Project +import net.opendasharchive.openarchive.db.sugar.Collection as SugarCollection +import net.opendasharchive.openarchive.db.sugar.Space +import net.opendasharchive.openarchive.util.* + +/** + * Extension mappers between SugarORM entities and clean domain models. + */ + +// --- Vault / Space --- + +fun Space.toDomain(): Vault = Vault( + id = this.id, + type = when (this.tType) { + Space.Type.WEBDAV -> VaultType.PRIVATE_SERVER + Space.Type.INTERNET_ARCHIVE -> VaultType.INTERNET_ARCHIVE + Space.Type.RAVEN -> VaultType.DWEB_STORAGE + }, + name = this.name, + username = this.username, + displayName = this.displayname, + password = "", // never surface plaintext password through domain model; use VaultCredentialStore + host = this.host, + metaData = this.metaData, + licenseUrl = this.license, + createdAt = DateUtils.now.toLocalDateTime() +) + +fun Vault.toEntity(): Space { + val space = Space() + if (this.id != 0L) space.id = this.id + space.tType = when (this.type) { + VaultType.PRIVATE_SERVER -> Space.Type.WEBDAV + VaultType.INTERNET_ARCHIVE -> Space.Type.INTERNET_ARCHIVE + VaultType.DWEB_STORAGE -> Space.Type.RAVEN + } + space.name = this.name + space.username = this.username + space.displayname = this.displayName + space.password = this.password + space.host = this.host + space.metaData = this.metaData + space.license = this.licenseUrl + return space +} + +// --- Archive / Project --- + +fun Project.toDomain(): Archive = Archive( + id = this.id ?: 0L, + description = this.description, + created = this.created?.toKotlinLocalDateTime(), + vaultId = this.spaceId, + isArchived = this.isArchived, + openSubmissionId = this.openCollectionId, + licenseUrl = this.licenseUrl, + isRemote = false +) + +fun Archive.toEntity(): Project { + val project = Project() + if (this.id != 0L) project.id = this.id + project.description = this.description + project.created = this.created?.toJavaDate() + project.spaceId = this.vaultId + project.isArchived = this.isArchived + project.openCollectionId = this.openSubmissionId + project.licenseUrl = this.licenseUrl + return project +} + +// --- Submission / Collection --- + +fun SugarCollection.toDomain(): Submission = Submission( + id = this.id ?: 0L, + archiveId = this.projectId ?: 0L, + vaultId = Project.getById(this.projectId)?.spaceId ?: 0L, + uploadDate = this.uploadDate?.toKotlinLocalDateTime(), + serverUrl = this.serverUrl +) + +fun Submission.toEntity(): SugarCollection { + val collection = SugarCollection() + if (this.id != 0L) collection.id = this.id + collection.projectId = this.archiveId + collection.uploadDate = this.uploadDate?.toJavaDate() + collection.serverUrl = this.serverUrl + return collection +} + +// --- Evidence / Media --- + +fun Media.toDomain(): Evidence = Evidence( + id = this.id ?: 0L, + originalFilePath = this.originalFilePath, + thumbnail = null, + mimeType = this.mimeType, + createdAt = this.createDate?.toKotlinLocalDateTime(), + updatedAt = this.updateDate?.toKotlinLocalDateTime(), + uploadedAt = this.uploadDate?.toKotlinLocalDateTime(), + serverUrl = this.serverUrl, + title = this.title, + description = this.description, + author = this.author, + location = this.location, + tags = if (this.tags.isBlank()) emptyList() else this.tags.split(";"), + licenseUrl = this.licenseUrl, + mediaHashString = this.mediaHashString, + status = when (this.sStatus) { + Media.Status.New -> EvidenceStatus.NEW + Media.Status.Local -> EvidenceStatus.LOCAL + Media.Status.Queued -> EvidenceStatus.QUEUED + Media.Status.Uploading -> EvidenceStatus.UPLOADING + Media.Status.Uploaded -> EvidenceStatus.UPLOADED + Media.Status.Published -> EvidenceStatus.UPLOADED + Media.Status.Error -> EvidenceStatus.ERROR + else -> EvidenceStatus.NEW + }, + statusMessage = this.statusMessage, + vaultId = this.space?.id ?: 0L, + archiveId = this.projectId, + submissionId = this.collectionId, + contentLength = this.contentLength, + progress = this.progress, + uploadPercentage = if (this.contentLength > 0) (this.progress.toFloat() / this.contentLength * 100).toInt() else null, + isFlagged = this.flag, + priority = this.priority, + isSelected = this.selected +) + +fun Evidence.toEntity(): Media { + val media = Media() + if (this.id != 0L) media.id = this.id + media.originalFilePath = this.originalFilePath + media.mimeType = this.mimeType + media.createDate = this.createdAt?.toJavaDate() + media.updateDate = this.updatedAt?.toJavaDate() + media.uploadDate = this.uploadedAt?.toJavaDate() + media.serverUrl = this.serverUrl + media.title = this.title + media.description = this.description + media.author = this.author + media.location = this.location + media.tags = this.tags.joinToString(";") + media.licenseUrl = this.licenseUrl + media.mediaHashString = this.mediaHashString + media.sStatus = when (this.status) { + EvidenceStatus.NEW -> Media.Status.New + EvidenceStatus.LOCAL -> Media.Status.Local + EvidenceStatus.QUEUED -> Media.Status.Queued + EvidenceStatus.UPLOADING -> Media.Status.Uploading + EvidenceStatus.UPLOADED -> Media.Status.Uploaded + EvidenceStatus.ERROR -> Media.Status.Error + } + media.statusMessage = this.statusMessage + media.projectId = this.archiveId + media.collectionId = this.submissionId + media.contentLength = this.contentLength + media.progress = this.progress + media.flag = this.isFlagged + media.priority = this.priority + media.selected = this.isSelected + return media +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/navigation/NavigationResultKeys.kt b/app/src/main/java/net/opendasharchive/openarchive/core/navigation/NavigationResultKeys.kt new file mode 100644 index 000000000..51201cc76 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/navigation/NavigationResultKeys.kt @@ -0,0 +1,12 @@ +package net.opendasharchive.openarchive.core.navigation + +/** + * Constants for result keys used in [ResultEventBus] and [ResultEffect]. + */ +object NavigationResultKeys { + const val QR_SCAN_RESULT = "qr_scan_result" + const val CAMERA_CAPTURE_RESULT = "camera_capture_result" + const val SNOWBIRD_CAMERA_RESULT = "snowbird_camera_result" + const val SHARED_MEDIA_IMPORT = "shared_media_import" + const val REFRESH_SPACES = "refresh_spaces" +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/navigation/ResultEffect.kt b/app/src/main/java/net/opendasharchive/openarchive/core/navigation/ResultEffect.kt new file mode 100644 index 000000000..25da4b1e1 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/navigation/ResultEffect.kt @@ -0,0 +1,31 @@ +package net.opendasharchive.openarchive.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.flow.filterNotNull + +/** + * Composable effect for receiving results from other screens via [ResultEventBus]. + * + * Usage: + * ``` + * ResultEffect>(resultBus) { uris -> + * // Handle received URIs + * viewModel.importMedia(uris) + * } + * ``` + */ +@Composable +inline fun ResultEffect( + resultBus: ResultEventBus = ResultEventBus, + resultKey: String = T::class.toString(), + crossinline onResult: (T) -> Unit +) { + LaunchedEffect(resultKey) { + resultBus.getResultFlow(resultKey) + .filterNotNull() + .collect { result -> + onResult(result as T) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/navigation/ResultEventBus.kt b/app/src/main/java/net/opendasharchive/openarchive/core/navigation/ResultEventBus.kt new file mode 100644 index 000000000..7df4e46e7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/navigation/ResultEventBus.kt @@ -0,0 +1,63 @@ +package net.opendasharchive.openarchive.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * Local for receiving results in a [ResultEventBus] + */ +object LocalResultEventBus { + private val LocalResultEventBus: ProvidableCompositionLocal = + compositionLocalOf { ResultEventBus } + + /** + * The current [ResultEventBus] + */ + val current: ResultEventBus + @Composable + get() = LocalResultEventBus.current + + /** + * Provides a [ResultEventBus] to the composition + */ + infix fun provides( + bus: ResultEventBus + ): ProvidedValue { + return LocalResultEventBus.provides(bus) + } +} + +/** + * An EventBus for passing results between multiple sets of screens. + * + * It provides a solution for event based results. + */ +object ResultEventBus { + val channelMap: MutableMap> = mutableMapOf() + + @PublishedApi internal fun getOrCreate(resultKey: String): Channel = + channelMap.getOrPut(resultKey) { + Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + } + + /** + * Always returns a non-null flow. The channel is created eagerly so that + * subscribers established before [sendResult] is called still receive events. + */ + inline fun getResultFlow(resultKey: String = T::class.toString()) = + getOrCreate(resultKey).receiveAsFlow() + + inline fun sendResult(resultKey: String = T::class.toString(), result: T) { + getOrCreate(resultKey).trySend(result) + } + + inline fun removeResult(resultKey: String = T::class.toString()) { + channelMap.remove(resultKey) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/navigation/ResultStore.kt b/app/src/main/java/net/opendasharchive/openarchive/core/navigation/ResultStore.kt new file mode 100644 index 000000000..2312f78b3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/navigation/ResultStore.kt @@ -0,0 +1,37 @@ +package net.opendasharchive.openarchive.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable + +@Stable +class ResultStore { + + private val results = mutableMapOf() + + @Suppress("UNCHECKED_CAST") + fun getResult(key: Any): T? = results[key] as T? + + fun setResult(key: Any, value: T?) { + results[key] = value + } + + fun removeResult(key: Any) { + results.remove(key) + } + + companion object { + val Saver = Saver>( + save = { it.results.toMap() }, + restore = { ResultStore().apply { results.putAll(it) } } + ) + } +} + +@Composable +fun rememberResultStore() = rememberSaveable( + saver = ResultStore.Saver +) { + ResultStore() +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/StatefulViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/StatefulViewModel.kt deleted file mode 100644 index 3aceb31d4..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/StatefulViewModel.kt +++ /dev/null @@ -1,29 +0,0 @@ -package net.opendasharchive.openarchive.core.presentation - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import net.opendasharchive.openarchive.core.state.StateDispatcher -import net.opendasharchive.openarchive.core.state.StoreObserver -import net.opendasharchive.openarchive.core.state.Stateful -import net.opendasharchive.openarchive.core.state.Store - -abstract class StatefulViewModel( - initialState: State, -) : ViewModel(), Store, Stateful { - - private val dispatcher = - StateDispatcher(viewModelScope, initialState, ::reduce, ::effects) - - private val observer = StoreObserver() - - override val state = dispatcher.state - override val actions = observer.actions - - abstract fun reduce(state: State, action: Action): State - - abstract suspend fun effects(state: State, action: Action) - - override fun dispatch(action: Action) = dispatcher.dispatch(action) - - override suspend fun notify(action: Action) = observer.notify(action) -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/CustomBottomNavBar.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/CustomBottomNavBar.kt deleted file mode 100644 index d44ab5e9d..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/CustomBottomNavBar.kt +++ /dev/null @@ -1,70 +0,0 @@ -package net.opendasharchive.openarchive.core.presentation.components - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.LinearLayout -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.CustomBottomNavBinding - -class CustomBottomNavBar @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : LinearLayout(context, attrs, defStyleAttr) { - - var onMyMediaClick: (() -> Unit)? = null - var onSettingsClick: (() -> Unit)? = null - - var onAddClick: (() -> Unit)? = null - var onAddLongClick: (() -> Unit)? = null - - // Inflate the layout - private var binding: CustomBottomNavBinding = - CustomBottomNavBinding.inflate(LayoutInflater.from(context), this, true) - - init { - - - // Set up click listeners - binding.myMediaButton.setOnClickListener { onMyMediaClick?.invoke() } - binding.settingsButton.setOnClickListener { onSettingsClick?.invoke() } - - binding.addButton.setOnClickListener { onAddClick?.invoke() } - - - binding.myMediaLabel.setOnClickListener { - // perform click + play ripple animation - binding.myMediaButton.isPressed = true - binding.myMediaButton.isPressed = false - binding.myMediaButton.performClick() - } - - binding.settingsLabel.setOnClickListener { - // perform click + play ripple animation - binding.settingsButton.isPressed = true - binding.settingsButton.isPressed = false - binding.settingsButton.performClick() - } - } - - /** - * Updates the highlighted state of the navigation bar buttons. - */ - fun updateSelectedItem(isSettings: Boolean) { - if (isSettings) { - binding.myMediaButton.setIconResource(R.drawable.outline_perm_media_24) - binding.settingsButton.setIconResource(R.drawable.ic_settings_filled) - } else { - binding.myMediaButton.setIconResource(R.drawable.perm_media_24px) - binding.settingsButton.setIconResource(R.drawable.ic_settings) - } - } - - fun setAddButtonLongClickEnabled() { - binding.addButton.setOnLongClickListener { - onAddLongClick?.invoke() - true - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/LoadingOverlay.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/LoadingOverlay.kt new file mode 100644 index 000000000..3c7cbeb2d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/LoadingOverlay.kt @@ -0,0 +1,46 @@ +package net.opendasharchive.openarchive.core.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * A reusable full-screen loading overlay for Compose screens. + * Displays a semi-transparent dimming background and a central progress indicator. + * Blocks interaction with the underlying content. + */ +@Composable +fun LoadingOverlay( + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), + indicatorColor: Color = MaterialTheme.colorScheme.tertiary +) { + Box( + modifier = modifier + .fillMaxSize() + .background(backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {} // Consume clicks to block underlying content + ), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = indicatorColor, + strokeWidth = 4.dp + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/PrimaryButton.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/PrimaryButton.kt index 396124102..132c1a9bd 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/PrimaryButton.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/PrimaryButton.kt @@ -4,27 +4,45 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +/** + * Primary button component matching the XML MaterialButton style. + * Uses 8dp corner radius and tertiary color scheme by default. + */ @Composable fun PrimaryButton( + text: String, + onClick: () -> Unit, modifier: Modifier = Modifier, icon: ImageVector? = null, - text: String, - onClick: () -> Unit + enabled: Boolean = true, + colors: ButtonColors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = colorResource(R.color.black) + ) ) { Button( + onClick = onClick, modifier = modifier, - shape = RoundedCornerShape(8f), - onClick = onClick + enabled = enabled, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = colors ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -34,7 +52,10 @@ fun PrimaryButton( Icon(imageVector = it, contentDescription = null) } - Text(text) + Text( + text = text, + style = MaterialTheme.typography.titleMedium + ) } } } @@ -43,9 +64,9 @@ fun PrimaryButton( @Composable private fun PrimaryButtonPreview() { DefaultBoxPreview { - PrimaryButton( - text = "New Folder" - ) { } + text = "New Folder", + onClick = { } + ) } } \ No newline at end of file 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 new file mode 100644 index 000000000..0d8463b7f --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/QRImageAnalyzer.kt @@ -0,0 +1,43 @@ +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 + +/** + * Image analysis helper for ML Kit barcode scanning. + */ +class QRImageAnalyzer( + private val onQrCodeScanned: (String) -> Unit +) : ImageAnalysis.Analyzer { + + private val scanner = BarcodeScanning.getClient() + + @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 { + imageProxy.close() + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/QRScanner.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/QRScanner.kt new file mode 100644 index 000000000..e7c56a2a0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/QRScanner.kt @@ -0,0 +1,202 @@ +package net.opendasharchive.openarchive.core.presentation.components + +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import java.util.concurrent.Executors + +/** + * A reusable QR code scanner component using CameraX and ML Kit. + * Features a stylish "WhatsApp-like" UI with an animated scanning line. + */ +@Composable +fun QRScanner( + modifier: Modifier = Modifier, + onQrCodeScanned: (String) -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + + var surfaceRequest by remember { mutableStateOf(null) } + var lastScannedTime by remember { mutableLongStateOf(0L) } + val isScanSuccess = lastScannedTime > 0L && (System.currentTimeMillis() - lastScannedTime < 300L) + + // ProcessCameraProvider is a singleton + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + + // Process scan result with feedback + val processScanResult: (String) -> Unit = remember(onQrCodeScanned) { + { result -> + lastScannedTime = System.currentTimeMillis() + onQrCodeScanned(result) + } + } + + LaunchedEffect(Unit) { + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().apply { + setSurfaceProvider { request -> + surfaceRequest = request + } + } + + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .apply { + setAnalyzer( + cameraExecutor, + QRImageAnalyzer { result -> + processScanResult(result) + } + ) + } + + // Use the back camera explicitly + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageAnalysis + ) + } catch (e: Exception) { + // Handle binding errors + } + } + + Box(modifier = modifier) { + surfaceRequest?.let { request -> + CameraXViewfinder( + surfaceRequest = request, + modifier = Modifier.fillMaxSize() + ) + } + + // Add the scanning overlay + QRScannerOverlay( + modifier = Modifier.fillMaxSize(), + scanAreaSize = 200.dp, + isSuccess = isScanSuccess + ) + } + + DisposableEffect(Unit) { + onDispose { + cameraExecutor.shutdown() + } + } +} + +/** + * A stylish overlay for the QR scanner with a cutout and animated scanning line. + */ +@Composable +fun QRScannerOverlay( + modifier: Modifier = Modifier, + scanAreaSize: Dp = 200.dp, + scanLineColor: Color = Color.Cyan, + isSuccess: Boolean = false +) { + val infiniteTransition = rememberInfiniteTransition(label = "ScanningLine") + val scanLineY by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "ScanLinePosition" + ) + + val successColor = Color.Green + val cornerColor by animateColorAsState( + targetValue = if (isSuccess) successColor else Color.White, + animationSpec = tween(durationMillis = 200), + label = "CornerColor" + ) + + Canvas(modifier = modifier) { + val width = size.width + val height = size.height + val scanAreaPx = scanAreaSize.toPx() + + val left = (width - scanAreaPx) / 2 + val top = (height - scanAreaPx) / 2 + val right = left + scanAreaPx + val bottom = top + scanAreaPx + + val scanRect = Rect(left, top, right, bottom) + + // 1. Draw semi-transparent overlay + drawPath( + path = Path().apply { + addRect(Rect(0f, 0f, width, height)) + addRect(scanRect) + fillType = PathFillType.EvenOdd + }, + color = if (isSuccess) successColor.copy(alpha = 0.2f) else Color.Black.copy(alpha = 0.6f) + ) + + // 2. Draw border/corners of the scan area + val cornerSize = 25.dp.toPx() + val strokeWidth = 3.dp.toPx() + + // Top-left corner + drawLine(cornerColor, Offset(left, top), Offset(left + cornerSize, top), strokeWidth) + drawLine(cornerColor, Offset(left, top), Offset(left, top + cornerSize), strokeWidth) + + // Top-right corner + drawLine(cornerColor, Offset(right, top), Offset(right - cornerSize, top), strokeWidth) + drawLine(cornerColor, Offset(right, top), Offset(right, top + cornerSize), strokeWidth) + + // Bottom-left corner + drawLine(cornerColor, Offset(left, bottom), Offset(left + cornerSize, bottom), strokeWidth) + drawLine(cornerColor, Offset(left, bottom), Offset(left, bottom - cornerSize), strokeWidth) + + // Bottom-right corner + drawLine(cornerColor, Offset(right, bottom), Offset(right - cornerSize, bottom), strokeWidth) + drawLine(cornerColor, Offset(right, bottom), Offset(right, bottom - cornerSize), strokeWidth) + + // 3. Draw animated scanning line + val lineY = top + (scanAreaPx * scanLineY) + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + scanLineColor.copy(alpha = 0f), + scanLineColor, + scanLineColor.copy(alpha = 0f) + ), + startY = lineY - 10.dp.toPx(), + endY = lineY + 10.dp.toPx() + ), + topLeft = Offset(left + 2.dp.toPx(), lineY - 1.dp.toPx()), + size = Size(scanAreaPx - 4.dp.toPx(), 2.dp.toPx()) + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/TextActionButton.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/TextActionButton.kt new file mode 100644 index 000000000..5c70982fc --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/TextActionButton.kt @@ -0,0 +1,28 @@ +package net.opendasharchive.openarchive.core.presentation.components + +import androidx.annotation.StringRes +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource + +@Composable +fun TextActionButton( + @StringRes label: Int, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + colors = ButtonDefaults.textButtonColors( + contentColor = Color.White + ) + ) { + Text( + stringResource(label), + style = MaterialTheme.typography.titleLarge + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaStatusOverlay.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaStatusOverlay.kt new file mode 100644 index 000000000..928059a86 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaStatusOverlay.kt @@ -0,0 +1,127 @@ +package net.opendasharchive.openarchive.core.presentation.media + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus + +/** + * Shared media status overlay component for showing upload states. + * Used in both PreviewMedia (grid) and UploadManager (list) screens. + * + * @param media The media item to show status for + * @param modifier Modifier for the overlay container + * @param showProgressText Whether to show percentage text for uploading state + * @param backgroundColor Background color for the overlay + * @param progressIndicatorSize Size of the circular progress indicator + * @param showQueuedState Whether to show overlay for queued state + * @param showUploadingState Whether to show overlay for uploading state + */ +@Composable +fun MediaStatusOverlay( + evidence: Evidence, + modifier: Modifier = Modifier, + showProgressText: Boolean = true, + backgroundColor: Color = colorResource(R.color.transparent_loading_overlay), + progressIndicatorSize: Int = 42, + showQueuedState: Boolean = true, + showUploadingState: Boolean = true +) { + when (evidence.status) { + EvidenceStatus.ERROR -> { + Box( + modifier = modifier + .fillMaxSize() + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_error), + contentDescription = stringResource(R.string.error), + tint = colorResource(R.color.colorDanger), + modifier = Modifier.size(32.dp) + ) + } + } + + EvidenceStatus.QUEUED -> { + if (showQueuedState) { + Box( + modifier = modifier + .fillMaxSize() + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(progressIndicatorSize.dp), + strokeWidth = 6.dp + ) + } + } + } + + EvidenceStatus.UPLOADING -> { + if (showUploadingState) { + val progressValue = evidence.uploadPercentage + ?: if (evidence.contentLength > 0) (evidence.progress.toFloat() / evidence.contentLength * 100).toInt() else 0 + Box( + modifier = modifier + .fillMaxSize() + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + if (progressValue > 2) { + CircularProgressIndicator( + progress = { progressValue / 100f }, + color = MaterialTheme.colorScheme.tertiary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.size(progressIndicatorSize.dp), + strokeWidth = 6.dp + ) + } else { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(progressIndicatorSize.dp), + strokeWidth = 6.dp + ) + } + + if (showProgressText) { + Text( + text = "$progressValue%", + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 12.dp), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.tertiary + ) + ) + } + } + } + } + + else -> Unit + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaThumbnail.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaThumbnail.kt new file mode 100644 index 000000000..32ced48e7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/MediaThumbnail.kt @@ -0,0 +1,239 @@ +package net.opendasharchive.openarchive.core.presentation.media + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import coil3.request.ImageRequest +import coil3.request.error +import coil3.video.VideoFrameDecoder +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Evidence +import java.io.File + +/** + * Unified media thumbnail component that handles all media types. + * Can be used in both grid (PreviewMedia) and list (UploadManager) layouts. + * + * @param media The media item to display + * @param modifier Modifier for the thumbnail container + * @param isSelected Whether the item is selected (for grid view) + * @param alpha Alpha value for the thumbnail + * @param showStatusOverlay Whether to show upload status overlay + * @param placeholderPadding Padding around placeholder icons + * @param onTitleVisibilityChanged Callback for when title should be shown/hidden + */ +@Composable +fun MediaThumbnail( + evidence: Evidence, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + alpha: Float = 1f, + contentScale: ContentScale = ContentScale.Crop, + showStatusOverlay: Boolean = true, + placeholderPadding: Dp = 24.dp, + pdfMaxDimensionPx: Int = 400, + onTitleVisibilityChanged: ((Boolean) -> Unit)? = null +) { + val context = LocalContext.current + val storedThumbnail = remember(evidence.thumbnail) { + evidence.thumbnail?.takeIf { it.isNotEmpty() } + } + val imageExists = remember(evidence.originalFilePath) { + runCatching { evidence.file.exists() }.getOrDefault(false) + } + val videoExists = remember(evidence.originalFilePath) { + runCatching { + val primary = evidence.originalFilePath.takeIf { it.isNotBlank() }?.let { File(it).exists() } ?: false + val secondary = evidence.fileUri.path?.let { File(it).exists() } ?: false + primary || secondary + }.getOrDefault(false) + } + + Box(modifier = modifier) { + when { + evidence.mimeType.startsWith("image") && imageExists -> { + SubcomposeAsyncImage( + model = ImageRequest.Builder(context) + .data(evidence.fileUri) + .error(R.drawable.ic_image) + .build(), + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .fillMaxSize() + .alpha(alpha) + ) { + SubcomposeAsyncImageContent() + } + onTitleVisibilityChanged?.invoke(false) + } + + evidence.mimeType.startsWith("video") && videoExists -> { + SubcomposeAsyncImage( + model = ImageRequest.Builder(context) + .data(evidence.originalFilePath.ifEmpty { evidence.fileUri.toString() }) + .decoderFactory(VideoFrameDecoder.Factory()) + .error(R.drawable.ic_video) + .build(), + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .fillMaxSize() + .alpha(alpha) + ) { + SubcomposeAsyncImageContent() + } + onTitleVisibilityChanged?.invoke(false) + } + + storedThumbnail != null -> { + SubcomposeAsyncImage( + model = ImageRequest.Builder(context) + .data(storedThumbnail) + .error( + when { + evidence.mimeType.startsWith("video") -> R.drawable.ic_video + evidence.mimeType == "application/pdf" -> R.drawable.ic_pdf + evidence.mimeType.startsWith("audio") -> R.drawable.ic_music + evidence.mimeType.startsWith("image") -> R.drawable.ic_image + else -> R.drawable.ic_unknown_file + } + ) + .build(), + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .fillMaxSize() + .alpha(alpha) + ) { + SubcomposeAsyncImageContent() + } + onTitleVisibilityChanged?.invoke(false) + } + + evidence.mimeType.startsWith("video") -> { + MediaPlaceholderIcon( + drawableRes = R.drawable.ic_video, + isSelected = isSelected, + alpha = alpha, + padding = placeholderPadding, + modifier = Modifier.fillMaxSize() + ) + onTitleVisibilityChanged?.invoke(true) + } + + evidence.mimeType.startsWith("image") -> { + MediaPlaceholderIcon( + drawableRes = R.drawable.ic_image, + isSelected = isSelected, + alpha = alpha, + padding = placeholderPadding, + modifier = Modifier.fillMaxSize() + ) + onTitleVisibilityChanged?.invoke(true) + } + + evidence.mimeType == "application/pdf" -> { + PdfThumbnailView( + uri = evidence.fileUri, + placeholderRes = R.drawable.ic_pdf, + maxDimensionPx = pdfMaxDimensionPx, + contentScale = contentScale, + modifier = Modifier + .fillMaxSize() + .alpha(alpha), + onPlaceholder = { onTitleVisibilityChanged?.invoke(true) }, + onResult = { success -> onTitleVisibilityChanged?.invoke(!success) } + ) + } + + evidence.mimeType.startsWith("audio") -> { + MediaPlaceholderIcon( + drawableRes = R.drawable.ic_music, + isSelected = isSelected, + alpha = alpha, + padding = placeholderPadding, + modifier = Modifier.fillMaxSize() + ) + onTitleVisibilityChanged?.invoke(true) + } + + else -> { + MediaPlaceholderIcon( + drawableRes = R.drawable.ic_unknown_file, + isSelected = isSelected, + alpha = alpha, + padding = placeholderPadding, + modifier = Modifier.fillMaxSize() + ) + onTitleVisibilityChanged?.invoke(true) + } + } + + if (evidence.mimeType.startsWith("video")) { + Icon( + painter = painterResource(id = R.drawable.ic_videocam_black_24dp), + contentDescription = stringResource(R.string.is_video), + tint = colorResource(R.color.colorMediaOverlayIcon), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(6.dp) + ) + } + } +} + +/** + * Shared placeholder icon component for media types without thumbnails. + * + * @param drawableRes Resource ID of the icon to display + * @param modifier Modifier for the icon container + * @param isSelected Whether the item is selected (changes tint color) + * @param alpha Alpha value for the icon + * @param padding Padding around the icon + */ +@Composable +fun MediaPlaceholderIcon( + drawableRes: Int, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + alpha: Float = 1f, + padding: Dp = 24.dp +) { + val tint = if (isSelected) { + colorResource(R.color.colorOnPrimaryContainer) + } else { + colorResource(R.color.colorOnSurfaceVariant) + } + + Box( + modifier = modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = drawableRes), + contentDescription = null, + tint = tint, + modifier = Modifier + .fillMaxSize() + .padding(padding) + .alpha(alpha) + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/PdfThumbnailView.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/PdfThumbnailView.kt new file mode 100644 index 000000000..23444c185 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/media/PdfThumbnailView.kt @@ -0,0 +1,95 @@ +package net.opendasharchive.openarchive.core.presentation.media + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import net.opendasharchive.openarchive.util.PdfThumbnailLoader + +/** + * Pure Compose PDF thumbnail viewer that uses ImageBitmap instead of AndroidView. + * This is more Compose-native and avoids view interop overhead. + */ +@Composable +fun PdfThumbnailView( + uri: Uri, + modifier: Modifier = Modifier, + maxDimensionPx: Int = 400, + placeholderRes: Int, + contentScale: ContentScale = ContentScale.Crop, + onPlaceholder: (() -> Unit)? = null, + onResult: ((Boolean) -> Unit)? = null +) { + val context = LocalContext.current + var thumbnail by remember(uri) { mutableStateOf(null) } + var isLoading by remember(uri) { mutableStateOf(true) } + var loadFailed by remember(uri) { mutableStateOf(false) } + + LaunchedEffect(uri) { + isLoading = true + loadFailed = false + thumbnail = PdfThumbnailLoader.loadPdfThumbnailBitmap(context, uri, maxDimensionPx) + isLoading = false + + if (thumbnail == null) { + loadFailed = true + onPlaceholder?.invoke() + onResult?.invoke(false) + } else { + onResult?.invoke(true) + } + } + + DisposableEffect(uri) { + onDispose { + // Cleanup if needed + } + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + when { + thumbnail != null -> { + Image( + bitmap = thumbnail!!, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale + ) + } + + isLoading -> { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + ) + } + + loadFailed -> { + Image( + painter = painterResource(id = placeholderRes), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale + ) + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Preview.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Preview.kt index 602ce5879..2e127d7d7 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Preview.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Preview.kt @@ -1,5 +1,6 @@ package net.opendasharchive.openarchive.core.presentation.theme +import android.content.res.Configuration import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme @@ -9,12 +10,35 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.opendasharchive.openarchive.core.di.passcodeModule import net.opendasharchive.openarchive.features.core.ComposeAppBar import org.koin.android.ext.koin.androidContext import org.koin.compose.KoinApplicationPreview +@Preview( + name = "Light Mode", + group = "Themes", + uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, + locale = "en" +) +annotation class PreviewLight + +@Preview( + name = "Dark Mode", + group = "Themes", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + locale = "en" +) +annotation class PreviewDark + +@PreviewLight +@PreviewDark +annotation class PreviewLightDark + @Composable fun DefaultScaffoldPreview( content: @Composable () -> Unit diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/SaveTextStyles.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/SaveTextStyles.kt index 356414cf0..bcdcf9c6c 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/SaveTextStyles.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/SaveTextStyles.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp /** @@ -20,129 +21,136 @@ import androidx.compose.ui.unit.sp * - SaveText.Text16pt("Welcome") * - Text("Custom", style = SaveTextStyles.text16pt) */ -object SaveText { - - @Composable - fun TitleLarge( - text: String, - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colorScheme.onBackground, - textAlign: TextAlign? = null, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE - ) { - Text( - text = text, - modifier = modifier, - style = SaveTextStyles.titleLarge.copy(color = color), - textAlign = textAlign, - overflow = overflow, - maxLines = maxLines - ) - } - - @Composable - fun TitleMedium( - text: String, - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colorScheme.onBackground, - textAlign: TextAlign? = null, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE - ) { - Text( - text = text, - modifier = modifier, - style = SaveTextStyles.titleMedium.copy(color = color), - textAlign = textAlign, - overflow = overflow, - maxLines = maxLines - ) - } - - @Composable - fun BodyLarge( - text: String, - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colorScheme.onBackground, - textAlign: TextAlign? = null, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE - ) { - Text( - text = text, - modifier = modifier, - style = SaveTextStyles.bodyLarge.copy(color = color), - textAlign = textAlign, - overflow = overflow, - maxLines = maxLines - ) - } - - @Composable - fun BodySmall( - text: String, - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colorScheme.onBackground, - textAlign: TextAlign? = null, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE - ) { - Text( - text = text, - modifier = modifier, - style = SaveTextStyles.bodySmall.copy(color = color), - textAlign = textAlign, - overflow = overflow, - maxLines = maxLines - ) - } - - @Composable - fun LabelLarge( - text: String, - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colorScheme.primary, - textAlign: TextAlign? = null, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE - ) { - Text( - text = text, - modifier = modifier, - style = SaveTextStyles.labelLarge.copy(color = color), - textAlign = textAlign, - overflow = overflow, - maxLines = maxLines - ) - } - - @Composable - fun BodySmallEmphasis( - text: String, - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign: TextAlign? = null, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE - ) { - Text( - text = text, - modifier = modifier, - style = SaveTextStyles.bodySmallEmphasis.copy(color = color), - textAlign = textAlign, - overflow = overflow, - maxLines = maxLines - ) - } + +@Composable +fun TitleLarge( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onBackground, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE +) { + Text( + text = text, + modifier = modifier, + style = SaveTextStyles.titleLarge.copy(color = color), + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun TitleMedium( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onBackground, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE +) { + Text( + text = text, + modifier = modifier, + style = SaveTextStyles.titleMedium.copy(color = color), + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun BodyLarge( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onBackground, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE +) { + Text( + text = text, + modifier = modifier, + style = SaveTextStyles.bodyLarge.copy(color = color), + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun BodySmall( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onBackground, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE +) { + Text( + text = text, + modifier = modifier, + style = SaveTextStyles.bodySmall.copy(color = color), + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) } +@Composable +fun LabelLarge( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.tertiary, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE +) { + Text( + text = text, + modifier = modifier, + style = SaveTextStyles.labelLarge.copy(color = color), + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun BodySmallEmphasis( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE +) { + Text( + text = text, + modifier = modifier, + style = SaveTextStyles.bodySmallEmphasis.copy(color = color), + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + + /** * Raw TextStyle objects based on Figma "Save App 3.0" * Only the 6 text styles from the design system */ object SaveTextStyles { + val headlineSmall = TextStyle( + fontFamily = MontserratFontFamily, + fontSize = 24.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = TextUnit.Unspecified, + letterSpacing = 0.04.sp + ) + // 18pt - SemiBold - For page titles, primary headers val titleBold = TextStyle( fontFamily = MontserratFontFamily, diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Type.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Type.kt index aaede27ba..ae3d1311b 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Type.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Type.kt @@ -68,12 +68,7 @@ val Typography = Typography( lineHeight = 36.sp, fontWeight = FontWeight.SemiBold ), - headlineSmall = TextStyle( - fontFamily = MontserratFontFamily, - fontSize = 24.sp, - lineHeight = 32.sp, - fontWeight = FontWeight.SemiBold - ), + headlineSmall = SaveTextStyles.headlineSmall, // 24sp, ExtraBold // Titles - Map to your Figma styles titleLarge = SaveTextStyles.titleLarge, // 18sp, SemiBold diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/ArchiveRepositoryImpl.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/ArchiveRepositoryImpl.kt new file mode 100644 index 000000000..b8a8b9af9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/ArchiveRepositoryImpl.kt @@ -0,0 +1,105 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Submission +import net.opendasharchive.openarchive.core.domain.mappers.toArchiveEntity +import net.opendasharchive.openarchive.core.domain.mappers.toDomain +import net.opendasharchive.openarchive.db.ArchiveDao +import net.opendasharchive.openarchive.db.SubmissionDao +import net.opendasharchive.openarchive.db.SubmissionEntity +import net.opendasharchive.openarchive.db.EvidenceDao +import net.opendasharchive.openarchive.db.VaultDao + +class ArchiveRepositoryImpl( + private val fileCleanupHelper: FileCleanupHelper, + private val archiveDao: ArchiveDao, + private val submissionDao: SubmissionDao, + private val vaultDao: VaultDao, + private val evidenceDao: EvidenceDao, + private val io: CoroutineDispatcher = Dispatchers.IO +) : ProjectRepository { + + override suspend fun getProjects(vaultId: Long, archived: Boolean): List = withContext(io) { + archiveDao.observeByVault(vaultId, archived).first().map { it.toDomain() } + } + + override fun observeProjects(vaultId: Long, archived: Boolean): Flow> = archiveDao.observeByVault(vaultId, archived) + .map { entities -> entities.map { it.toDomain() } } + .distinctUntilChanged() + + override suspend fun getProject(id: Long): Archive? = withContext(io) { + archiveDao.getById(id)?.toDomain() + } + + override fun observeProject(id: Long): Flow = archiveDao.observeById(id) + .map { it?.toDomain() } + .distinctUntilChanged() + + override suspend fun renameProject(id: Long, newName: String) { + withContext(io) { + archiveDao.getById(id)?.let { + archiveDao.upsert(it.copy(description = newName, isRemote = false)) + } + } + } + + override suspend fun archiveProject(id: Long, isArchived: Boolean): Boolean = withContext(io) { + archiveDao.getById(id)?.let { archive -> + var updatedArchive = archive.copy(archived = isArchived) + + // Port legacy behavior: apply space license if unarchiving and license is null + if (!isArchived) { + val space = vaultDao.getById(archive.vaultId) + if (updatedArchive.licenseUrl.isNullOrBlank()) { + updatedArchive = updatedArchive.copy(licenseUrl = space?.licenseUrl) + } + } + + archiveDao.upsert(updatedArchive) > 0 + } ?: false + } + + override suspend fun deleteProject(id: Long): Boolean = withContext(io) { + archiveDao.getById(id)?.let { archive -> + // 1. Fetch evidence association before DB deletion + val evidenceEntities = evidenceDao.getByArchive(id) + val evidenceList = evidenceEntities.map { it.toDomain(vaultId = archive.vaultId) } + + // 2. Perform DB deletion first + archiveDao.delete(archive) + + // 3. Clean up physical files after successful DB removal + evidenceList.forEach { evidence -> + fileCleanupHelper.deleteMediaFiles(evidence) + } + true + } ?: false + } + + override suspend fun getActiveSubmission(projectId: Long): Submission = withContext(io) { + val archive = archiveDao.getById(projectId) ?: throw IllegalStateException("Project not found") + var submission = submissionDao.getById(archive.openSubmissionId) + + if (submission == null || submission.uploadedAt != null) { + // Create new submission + val newId = + submissionDao.upsert(SubmissionEntity(archiveId = projectId, uploadedAt = null, serverUrl = null)) + archiveDao.upsert(archive.copy(openSubmissionId = newId)) + submission = submissionDao.getById(newId) + } + + submission!!.toDomain() + } + + override suspend fun getProjectByName(vaultId: Long, name: String): Archive? = withContext(io) { + archiveDao.getByName(vaultId, name)?.toDomain() + } + + override suspend fun addProject(archive: Archive): Long = withContext(io) { + archiveDao.upsert(archive.toArchiveEntity()) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/CacheCleanupWorker.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/CacheCleanupWorker.kt new file mode 100644 index 000000000..709227eb9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/CacheCleanupWorker.kt @@ -0,0 +1,76 @@ +package net.opendasharchive.openarchive.core.repositories + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.logger.AppLogger +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Periodic WorkManager worker that keeps the app's cache directory from growing unbounded. + * + * Policy: + * - Delete any file not accessed in the last [MAX_AGE_DAYS] days + * - If total size still exceeds [MAX_CACHE_SIZE_BYTES], evict oldest files first + * - Remove any empty directories left behind + */ +class CacheCleanupWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + companion object { + const val WORK_NAME = "cache_cleanup" + val REPEAT_INTERVAL_DAYS = 7L + private val MAX_AGE_MS = TimeUnit.DAYS.toMillis(7) + private const val MAX_CACHE_SIZE_BYTES = 100 * 1024 * 1024L // 100 MB + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + val cacheDir = applicationContext.cacheDir + deleteOldFiles(cacheDir) + enforceSizeCap(cacheDir) + AppLogger.i("CacheCleanupWorker: completed") + Result.success() + } catch (e: Exception) { + AppLogger.e("CacheCleanupWorker: failed", e) + Result.retry() + } + } + + private fun deleteOldFiles(dir: File) { + val cutoff = System.currentTimeMillis() - MAX_AGE_MS + dir.walkTopDown() + .filter { it.isFile && it.lastModified() < cutoff } + .forEach { file -> + if (file.delete()) { + AppLogger.d("CacheCleanup: deleted stale file ${file.name}") + } + } + // Remove empty directories (bottom-up so children are processed before parents) + dir.walkBottomUp() + .filter { it.isDirectory && it != dir && it.listFiles()?.isEmpty() == true } + .forEach { it.delete() } + } + + private fun enforceSizeCap(dir: File) { + val files = dir.walkTopDown() + .filter { it.isFile } + .sortedBy { it.lastModified() } // oldest first for eviction + .toMutableList() + + var totalSize = files.sumOf { it.length() } + for (file in files) { + if (totalSize <= MAX_CACHE_SIZE_BYTES) break + val size = file.length() + if (file.delete()) { + totalSize -= size + AppLogger.d("CacheCleanup: evicted ${file.name} to enforce size cap") + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/CollectionRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/CollectionRepository.kt new file mode 100644 index 000000000..0640e69b7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/CollectionRepository.kt @@ -0,0 +1,12 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.flow.Flow +import net.opendasharchive.openarchive.core.domain.Submission + +interface CollectionRepository { + suspend fun getCollections(projectId: Long): List + fun observeCollections(projectId: Long): Flow> + suspend fun getCollection(id: Long): Submission? + suspend fun updateCollection(submission: Submission) + suspend fun deleteCollection(id: Long) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/DwebRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/DwebRepository.kt new file mode 100644 index 000000000..2c79b93bf --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/DwebRepository.kt @@ -0,0 +1,22 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.flow.Flow +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.Vault + +interface DwebRepository { + fun observeVaults(): Flow> + fun observeVault(id: Long): Flow + suspend fun getVault(id: Long): Vault? + suspend fun updateVaultMetadata(vault: Vault) + + fun observeArchives(vaultId: Long, archived: Boolean): Flow> + fun observeArchive(id: Long): Flow + suspend fun getArchive(id: Long): Archive? + suspend fun updateArchiveMetadata(archive: Archive) + + fun observeEvidence(archiveId: Long): Flow> + suspend fun getEvidence(id: Long): Evidence? + suspend fun updateEvidenceMetadata(evidence: Evidence) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/DwebRepositoryImpl.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/DwebRepositoryImpl.kt new file mode 100644 index 000000000..1f937080d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/DwebRepositoryImpl.kt @@ -0,0 +1,85 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.mappers.* +import net.opendasharchive.openarchive.db.ArchiveDao +import net.opendasharchive.openarchive.db.DwebDao +import net.opendasharchive.openarchive.db.EvidenceDao +import net.opendasharchive.openarchive.db.VaultDao + +class DwebRepositoryImpl( + private val dwebDao: DwebDao, + private val vaultDao: VaultDao, + private val archiveDao: ArchiveDao, + private val evidenceDao: EvidenceDao, + private val io: CoroutineDispatcher = Dispatchers.IO +) : DwebRepository { + + override fun observeVaults(): Flow> = dwebDao.observeVaultsWithDweb() + .map { entities -> entities.map { it.toDomain() } } + .distinctUntilChanged() + + override fun observeVault(id: Long): Flow = dwebDao.observeVaultWithDwebById(id) + .map { it?.toDomain() } + .distinctUntilChanged() + + override suspend fun getVault(id: Long): Vault? = withContext(io) { + dwebDao.getVaultWithDwebById(id)?.toDomain() + } + + override suspend fun updateVaultMetadata(vault: Vault) { + withContext(io) { + vault.toDwebEntity()?.let { + dwebDao.upsertVaultMetadata(it) + } + } + } + + override fun observeArchives(vaultId: Long, archived: Boolean): Flow> = + dwebDao.observeArchivesWithDweb(vaultId, archived) + .map { entities -> entities.map { it.toDomain() } } + .distinctUntilChanged() + + override fun observeArchive(id: Long): Flow = dwebDao.observeArchiveWithDwebById(id) + .map { it?.toDomain() } + .distinctUntilChanged() + + override suspend fun getArchive(id: Long): Archive? = withContext(io) { + dwebDao.getArchiveWithDwebById(id)?.toDomain() + } + + override suspend fun updateArchiveMetadata(archive: Archive) { + withContext(io) { + archive.toDwebEntity()?.let { + dwebDao.upsertArchiveMetadata(it) + } + } + } + + override fun observeEvidence(archiveId: Long): Flow> = + dwebDao.observeEvidenceWithDweb(archiveId) + .map { entities -> + val project = archiveDao.getById(archiveId) + entities.map { it.toDomain(vaultId = project?.vaultId ?: 0L) } + } + .distinctUntilChanged() + + override suspend fun getEvidence(id: Long): Evidence? = withContext(io) { + dwebDao.getEvidenceWithDwebById(id)?.let { composite -> + val project = archiveDao.getById(composite.evidence.archiveId) + composite.toDomain(vaultId = project?.vaultId ?: 0L) + } + } + + override suspend fun updateEvidenceMetadata(evidence: Evidence) { + withContext(io) { + dwebDao.upsertEvidenceMetadata(evidence.toDwebEntity()) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/EvidenceRepositoryImpl.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/EvidenceRepositoryImpl.kt new file mode 100644 index 000000000..ad9c8a65b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/EvidenceRepositoryImpl.kt @@ -0,0 +1,158 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.domain.mappers.toDomain +import net.opendasharchive.openarchive.core.domain.mappers.toEvidenceEntity +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.db.ArchiveDao +import net.opendasharchive.openarchive.db.EvidenceDao +import net.opendasharchive.openarchive.db.SubmissionDao + +class EvidenceRepositoryImpl( + private val fileCleanupHelper: FileCleanupHelper, + private val evidenceDao: EvidenceDao, + private val submissionDao: SubmissionDao, + private val archiveDao: ArchiveDao, + private val io: CoroutineDispatcher = Dispatchers.IO +) : MediaRepository { + + override suspend fun getMediaForCollection(collectionId: Long): List = withContext(io) { + // Submission flow doesn't typically join DWeb metadata directly but could if needed + evidenceDao.observeBySubmission(collectionId).first().map { mapToDomain(it) } + } + + override fun observeMediaForCollection(collectionId: Long): Flow> = + evidenceDao.observeBySubmission(collectionId) + .map { entities -> entities.map { it.toDomain() } } + .distinctUntilChanged() + + override suspend fun getMediaForProject(projectId: Long): List = withContext(io) { + evidenceDao.observeByArchive(projectId).first().map { mapToDomain(it) } + } + + override fun observeMediaForProject(projectId: Long): Flow> = + evidenceDao.observeByArchive(projectId) + .map { entities -> entities.map { it.toDomain() } } + .distinctUntilChanged() + + override suspend fun getLocalMedia(): List = withContext(io) { + evidenceDao.getByStatus(listOf(EvidenceStatus.LOCAL)).map { mapToDomain(it) } + } + + override fun observeLocalMedia(): Flow> = + evidenceDao.observeByStatus(listOf(EvidenceStatus.LOCAL)) + .map { entities -> + entities.map { mapToDomain(it) } + } + .distinctUntilChanged() + + override suspend fun getEvidence(id: Long): Evidence? = withContext(io) { + evidenceDao.getById(id)?.let { mapToDomain(it) } + } + + private suspend fun mapToDomain(entity: net.opendasharchive.openarchive.db.EvidenceEntity): Evidence { + val project = archiveDao.getById(entity.archiveId) + return entity.toDomain(vaultId = project?.vaultId ?: 0L) + } + + override suspend fun setSelected(mediaId: Long, selected: Boolean) { + // Selection is not persisted in Room + } + + override suspend fun deleteMedia(mediaId: Long) { + withContext(io) { + evidenceDao.getById(mediaId)?.let { entity -> + val evidence = mapToDomain(entity) + val submissionId = entity.submissionId + val count = evidenceDao.getCountBySubmission(submissionId) + + // Perform DB deletion first + if (count < 2) { + submissionDao.getById(submissionId)?.let { + submissionDao.delete(it) // CASCADE will delete the media too + } + } else { + evidenceDao.deleteById(mediaId) + } + + InvalidationBus.invalidateMedia() + InvalidationBus.invalidateCollections() + + // Clean up physical files after successful DB removal + fileCleanupHelper.deleteMediaFiles(evidence) + } + } + } + + override suspend fun addEvidence(evidence: Evidence): Long = withContext(io) { + val id = evidenceDao.upsert(evidence.toEvidenceEntity()) + if (id > 0) { + InvalidationBus.invalidateMedia() + InvalidationBus.invalidateCollections() + } + id + } + + override suspend fun updateEvidence(evidence: Evidence) { + withContext(io) { + val entity = evidence.toEvidenceEntity() + if (evidenceDao.getById(entity.id) != null) { + evidenceDao.upsert(entity) + InvalidationBus.invalidateMedia() + } else { + AppLogger.w("Skipping update for media ${entity.id} as it was deleted from database") + } + } + } + + override suspend fun queueAllForUpload(mediaIds: List) { + withContext(io) { + mediaIds.forEach { id -> + evidenceDao.getById(id)?.let { + evidenceDao.upsert(it.copy(status = EvidenceStatus.QUEUED)) + } + } + InvalidationBus.invalidateMedia() + } + } + + override suspend fun getQueue(): List = withContext(io) { + val statuses = listOf(EvidenceStatus.UPLOADING, EvidenceStatus.QUEUED, EvidenceStatus.ERROR) + // Sorting is handled by evidenceDao.getByStatus (priority DESC, id DESC) + evidenceDao.getByStatus(statuses).map { mapToDomain(it) } + } + + override suspend fun updatePriority(mediaId: Long, priority: Int) { + withContext(io) { + evidenceDao.getById(mediaId)?.let { + evidenceDao.upsert(it.copy(priority = priority)) + InvalidationBus.invalidateMedia() + } + } + } + + override suspend fun updatePriorities(priorities: List>) { + withContext(io) { + priorities.forEach { (mediaId, priority) -> + evidenceDao.getById(mediaId)?.let { + evidenceDao.upsert(it.copy(priority = priority)) + } + } + InvalidationBus.invalidateMedia() + } + } + + override suspend fun retryMedia(mediaId: Long) { + withContext(io) { + evidenceDao.getById(mediaId)?.let { + evidenceDao.upsert(it.copy(status = EvidenceStatus.QUEUED, progress = 0, statusMessage = "")) + InvalidationBus.invalidateMedia() + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt new file mode 100644 index 000000000..833d61ae7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt @@ -0,0 +1,75 @@ +package net.opendasharchive.openarchive.core.repositories + +import android.content.Context +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.db.sugar.Media +import net.opendasharchive.openarchive.util.C2paHelper +import java.io.File + +/** + * Helper to handle physical file cleanup for media assets and their metadata. + */ +class FileCleanupHelper(private val context: Context) { + + /** + * Deletes local upload artifacts after a successful upload while keeping the DB record. + */ + fun deleteUploadedMediaFiles(evidence: Evidence) { + deleteInternalMediaFile(evidence) + deleteC2paSidecar(evidence.mediaHashString) + } + + /** + * Deletes physical files associated with an Evidence domain object. + */ + fun deleteMediaFiles(evidence: Evidence) { + deleteUploadedMediaFiles(evidence) + } + + /** + * Deletes physical files associated with a legacy Media Sugar entity. + */ + fun deleteMediaFiles(media: Media) { + if (media.originalFilePath.isNotEmpty()) { + try { + val file = media.file + if (isInternalFile(file) && file.exists() && file.delete()) { + AppLogger.i("Deleted internal media file: ${file.path}") + } + } catch (e: Exception) { + AppLogger.e("Failed to delete legacy media file for ${media.id}", e) + } + } + + deleteC2paSidecar(media.mediaHashString) + } + + private fun deleteInternalMediaFile(evidence: Evidence) { + if (evidence.originalFilePath.isNotEmpty()) { + try { + val file = evidence.file + if (isInternalFile(file) && file.exists() && file.delete()) { + AppLogger.i("Deleted internal media file: ${file.path}") + } + } catch (e: Exception) { + AppLogger.e("Failed to delete media file for ${evidence.id}", e) + } + } + } + + private fun deleteC2paSidecar(mediaHashString: String) { + if (mediaHashString.isNotEmpty()) { + C2paHelper.removeC2paFiles(context, mediaHashString) + } + } + + /** + * Checks if a file resides within the application's internal files directory. + * We only want to delete files we imported to our internal storage. + */ + private fun isInternalFile(file: File): Boolean { + val internalDir = context.filesDir.canonicalPath + return file.canonicalPath.startsWith(internalDir) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/InvalidationBus.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/InvalidationBus.kt new file mode 100644 index 000000000..bd6007d6e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/InvalidationBus.kt @@ -0,0 +1,51 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * InvalidationBus - A centralized signal bus for manual reactivity with Sugar ORM. + * Each flow acts as an "invalidation signal" that triggers re-queries in repositories. + * + * Write -> Signal Mapping: + * - Space write -> invalidateSpaces() + maybe invalidateCurrentSpace() + * - Current space change -> invalidateCurrentSpace() + invalidateProjects() + invalidateCollections() + invalidateMedia() + * - Project write -> invalidateProjects() + * - Collection/Media write -> invalidateCollections() + invalidateMedia() + */ +object InvalidationBus { + private val _spaces = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } + val spaces: SharedFlow = _spaces.asSharedFlow() + + private val _currentSpace = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } + val currentSpace: SharedFlow = _currentSpace.asSharedFlow() + + private val _projects = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } + val projects: SharedFlow = _projects.asSharedFlow() + + private val _collections = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } + val collections: SharedFlow = _collections.asSharedFlow() + + private val _media = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } + val media: SharedFlow = _media.asSharedFlow() + + private fun MutableSharedFlow.ping() = tryEmit(Unit) + + fun invalidateSpaces() = _spaces.ping() + fun invalidateCurrentSpace() = _currentSpace.ping() + fun invalidateProjects() = _projects.ping() + fun invalidateCollections() = _collections.ping() + fun invalidateMedia() = _media.ping() + + /** + * Trigger a full refresh across all domains. + */ + fun invalidateAll() { + _spaces.ping() + _currentSpace.ping() + _projects.ping() + _collections.ping() + _media.ping() + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/MediaRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/MediaRepository.kt new file mode 100644 index 000000000..42d3b3be3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/MediaRepository.kt @@ -0,0 +1,23 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.flow.Flow +import net.opendasharchive.openarchive.core.domain.Evidence + +interface MediaRepository { + suspend fun getMediaForCollection(collectionId: Long): List + fun observeMediaForCollection(collectionId: Long): Flow> + suspend fun getMediaForProject(projectId: Long): List + fun observeMediaForProject(projectId: Long): Flow> + suspend fun getLocalMedia(): List + fun observeLocalMedia(): Flow> + suspend fun getEvidence(id: Long): Evidence? + suspend fun setSelected(mediaId: Long, selected: Boolean) + suspend fun deleteMedia(mediaId: Long) + suspend fun addEvidence(evidence: Evidence): Long + suspend fun updateEvidence(evidence: Evidence) + suspend fun queueAllForUpload(mediaIds: List) + suspend fun getQueue(): List + suspend fun updatePriority(mediaId: Long, priority: Int) + suspend fun updatePriorities(priorities: List>) + suspend fun retryMedia(mediaId: Long) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/ProjectRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/ProjectRepository.kt new file mode 100644 index 000000000..5577af1c4 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/ProjectRepository.kt @@ -0,0 +1,18 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.flow.Flow +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Submission + +interface ProjectRepository { + suspend fun getProjects(vaultId: Long, archived: Boolean = false): List + fun observeProjects(vaultId: Long, archived: Boolean = false): Flow> + suspend fun getProject(id: Long): Archive? + fun observeProject(id: Long): Flow + suspend fun renameProject(id: Long, newName: String) + suspend fun archiveProject(id: Long, isArchived: Boolean): Boolean + suspend fun deleteProject(id: Long): Boolean + suspend fun getActiveSubmission(projectId: Long): Submission + suspend fun getProjectByName(vaultId: Long, name: String): Archive? + suspend fun addProject(archive: Archive): Long +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SettingsRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SettingsRepository.kt new file mode 100644 index 000000000..153a15e96 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SettingsRepository.kt @@ -0,0 +1,33 @@ +package net.opendasharchive.openarchive.core.repositories + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface SettingsRepository { + fun observeCurrentSpaceId(): Flow + suspend fun setCurrentSpaceId(id: Long) +} + +class SettingsRepositoryImpl( + private val dataStore: DataStore +) : SettingsRepository { + + private object PreferencesKeys { + val CURRENT_SPACE_ID = longPreferencesKey("current_space_id") + } + + override fun observeCurrentSpaceId(): Flow = dataStore.data + .map { preferences -> + preferences[PreferencesKeys.CURRENT_SPACE_ID] ?: -1L + } + + override suspend fun setCurrentSpaceId(id: Long) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.CURRENT_SPACE_ID] = id + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SpaceRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SpaceRepository.kt new file mode 100644 index 000000000..ae8e1d609 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SpaceRepository.kt @@ -0,0 +1,21 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.flow.Flow +import net.opendasharchive.openarchive.core.domain.VaultAuth +import net.opendasharchive.openarchive.core.domain.Vault + +interface SpaceRepository { + suspend fun getSpaces(): List + fun observeSpaces(): Flow> + fun observeHasDwebSpace(): Flow + suspend fun getCurrentSpace(): Vault? + fun observeCurrentSpace(): Flow + suspend fun setCurrentSpace(id: Long) + fun observeSpace(id: Long): Flow + + suspend fun getSpaceById(id: Long): Vault? + suspend fun getVaultAuth(vaultId: Long): VaultAuth? + suspend fun updateSpace(vaultId: Long, vault: Vault): Boolean + suspend fun addSpace(vault: Vault): Long + suspend fun deleteSpace(id: Long): Boolean +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SubmissionRepositoryImpl.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SubmissionRepositoryImpl.kt new file mode 100644 index 000000000..66c49991e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SubmissionRepositoryImpl.kt @@ -0,0 +1,42 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Submission +import net.opendasharchive.openarchive.core.domain.mappers.toDomain +import net.opendasharchive.openarchive.core.domain.mappers.toSubmissionEntity +import net.opendasharchive.openarchive.db.SubmissionDao + +class SubmissionRepositoryImpl( + private val submissionDao: SubmissionDao, + private val io: CoroutineDispatcher = Dispatchers.IO +) : CollectionRepository { + + override suspend fun getCollections(projectId: Long): List = withContext(io) { + submissionDao.observeByArchive(projectId).first().map { it.toDomain() } + } + + override fun observeCollections(projectId: Long): Flow> = submissionDao.observeByArchive(projectId) + .map { entities -> entities.map { it.toDomain() } } + .distinctUntilChanged() + + override suspend fun getCollection(id: Long): Submission? = withContext(io) { + submissionDao.getById(id)?.toDomain() + } + + override suspend fun updateCollection(submission: Submission) { + withContext(io) { + submissionDao.upsert(submission.toSubmissionEntity()) + } + } + + override suspend fun deleteCollection(id: Long) { + withContext(io) { + submissionDao.getById(id)?.let { + submissionDao.delete(it) + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarCollectionRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarCollectionRepository.kt new file mode 100644 index 000000000..43e893062 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarCollectionRepository.kt @@ -0,0 +1,44 @@ +package net.opendasharchive.openarchive.core.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Submission +import net.opendasharchive.openarchive.core.domain.mappers.toDomain +import net.opendasharchive.openarchive.core.domain.mappers.toEntity +import net.opendasharchive.openarchive.db.sugar.Collection + +class SugarCollectionRepository(private val io: CoroutineDispatcher = Dispatchers.IO) : CollectionRepository { + + override suspend fun getCollections(projectId: Long): List = + withContext(io) { + Collection.getByProjectRecentFirst(projectId).map { it.toDomain() } + } + + override fun observeCollections(projectId: Long): Flow> { + return InvalidationBus.collections + .map { getCollections(projectId) } + .distinctUntilChanged() + } + + override suspend fun getCollection(id: Long): Submission? = withContext(io) { + Collection.get(id)?.toDomain() + } + + override suspend fun updateCollection(submission: Submission) { + withContext(io) { + submission.toEntity().save() + InvalidationBus.invalidateCollections() + } + } + + override suspend fun deleteCollection(id: Long) { + withContext(io) { + val deleted = Collection.get(id)?.delete() ?: false + if (deleted) InvalidationBus.invalidateCollections() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarMediaRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarMediaRepository.kt new file mode 100644 index 000000000..d83c629c1 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarMediaRepository.kt @@ -0,0 +1,154 @@ +package net.opendasharchive.openarchive.core.repositories + +import com.orm.SugarRecord +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.mappers.toDomain +import net.opendasharchive.openarchive.core.domain.mappers.toEntity +import net.opendasharchive.openarchive.db.sugar.Collection +import net.opendasharchive.openarchive.db.sugar.Media + + +class SugarMediaRepository( + private val fileCleanupHelper: FileCleanupHelper, + private val io: CoroutineDispatcher = Dispatchers.IO +) : MediaRepository { + + override suspend fun getMediaForCollection(collectionId: Long): List = + withContext(io) { + Collection.get(collectionId)?.media?.map { it.toDomain() } ?: emptyList() + } + + override fun observeMediaForCollection(collectionId: Long): Flow> = InvalidationBus.media + .map { getMediaForCollection(collectionId) } + .distinctUntilChanged() + + override suspend fun getMediaForProject(projectId: Long): List = withContext(io) { + SugarRecord.find( + Media::class.java, "project_id = ?", + arrayOf(projectId.toString()), null, "id DESC", null + ) + .map { it.toDomain() } + } + + override fun observeMediaForProject(projectId: Long): Flow> = InvalidationBus.media + .map { getMediaForProject(projectId) } + .distinctUntilChanged() + + override suspend fun getLocalMedia(): List = withContext(io) { + Media.getByStatus(listOf(Media.Status.Local), Media.ORDER_CREATED) + .map { it.toDomain() } + } + + override fun observeLocalMedia(): Flow> = InvalidationBus.media + .map { getLocalMedia() } + .distinctUntilChanged() + + override suspend fun getEvidence(id: Long): Evidence? = withContext(io) { + Media.get(id)?.toDomain() + } + + override suspend fun setSelected(mediaId: Long, selected: Boolean) { + withContext(io) { + Media.get(mediaId)?.let { + it.selected = selected + it.save() + InvalidationBus.invalidateMedia() + } + } + } + + override suspend fun deleteMedia(mediaId: Long) { + withContext(io) { + Media.get(mediaId)?.let { media -> + // Perform DB deletion first + val collection = media.collection + if ((collection?.size ?: 0) < 2) { + collection?.delete() + } else { + media.delete() + } + InvalidationBus.invalidateMedia() + + // Clean up physical files after successful DB removal + fileCleanupHelper.deleteMediaFiles(media) + } + } + } + + override suspend fun addEvidence(evidence: Evidence): Long = withContext(io) { + val id = evidence.toEntity().save() + if (id > 0) { + InvalidationBus.invalidateMedia() + InvalidationBus.invalidateCollections() + } + id + } + + override suspend fun updateEvidence(evidence: Evidence) { + withContext(io) { + // Check if still exists before saving to avoid re-inserting deleted items + if (Media.get(evidence.id) != null) { + evidence.toEntity().save() + InvalidationBus.invalidateMedia() + } + } + } + + override suspend fun queueAllForUpload(mediaIds: List) { + withContext(io) { + mediaIds.forEach { id -> + Media.get(id)?.let { + it.sStatus = Media.Status.Queued + it.selected = false + it.save() + } + } + InvalidationBus.invalidateMedia() + } + } + + override suspend fun getQueue(): List = withContext(io) { + val statuses = listOf(Media.Status.Uploading, Media.Status.Queued, Media.Status.Error) + Media.getByStatus(statuses, Media.ORDER_PRIORITY).map { it.toDomain() } + } + + override suspend fun updatePriority(mediaId: Long, priority: Int) { + withContext(io) { + Media.get(mediaId)?.let { + it.priority = priority + it.save() + InvalidationBus.invalidateMedia() + } + } + } + + override suspend fun updatePriorities(priorities: List>) { + withContext(io) { + priorities.forEach { (mediaId, priority) -> + Media.get(mediaId)?.let { + it.priority = priority + it.save() + } + } + InvalidationBus.invalidateMedia() + } + } + + override suspend fun retryMedia(mediaId: Long) { + withContext(io) { + Media.get(mediaId)?.let { + it.sStatus = Media.Status.Queued + it.uploadPercentage = 0 + it.statusMessage = "" + it.save() + InvalidationBus.invalidateMedia() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarProjectRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarProjectRepository.kt new file mode 100644 index 000000000..4d2cff9d0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarProjectRepository.kt @@ -0,0 +1,104 @@ +package net.opendasharchive.openarchive.core.repositories + +import com.orm.SugarRecord +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Submission +import net.opendasharchive.openarchive.core.domain.mappers.toDomain +import net.opendasharchive.openarchive.core.domain.mappers.toEntity +import net.opendasharchive.openarchive.db.sugar.Project +import net.opendasharchive.openarchive.db.sugar.Space + +import net.opendasharchive.openarchive.db.sugar.Media + +class SugarProjectRepository( + private val fileCleanupHelper: FileCleanupHelper, + private val io: CoroutineDispatcher = Dispatchers.IO +) : ProjectRepository { + + override suspend fun getProjects(vaultId: Long, archived: Boolean): List = withContext(io) { + val projects = Space.get(vaultId)?.projects ?: emptyList() + projects.filter { it.isArchived == archived }.map { it.toDomain() } + } + + override fun observeProjects(vaultId: Long, archived: Boolean): Flow> = InvalidationBus.projects + .map { getProjects(vaultId, archived) } + .distinctUntilChanged() + + override suspend fun getProject(id: Long): Archive? = withContext(io) { + Project.getById(id)?.toDomain() + } + + override fun observeProject(id: Long): Flow { + return InvalidationBus.projects + .map { getProject(id) } + .distinctUntilChanged() + } + + override suspend fun renameProject(id: Long, newName: String) { + withContext(io) { + Project.getById(id)?.let { + it.description = newName + it.save() + InvalidationBus.invalidateProjects() + } + } + } + + override suspend fun archiveProject(id: Long, isArchived: Boolean): Boolean = withContext(io) { + Project.getById(id)?.let { + it.isArchived = isArchived + val saved = it.save() > 0 + if (saved) InvalidationBus.invalidateProjects() + saved + } ?: false + } + + override suspend fun deleteProject(id: Long): Boolean = withContext(io) { + val project = Project.getById(id) ?: return@withContext false + + // 1. Fetch media association before DB deletion + val mediaList = SugarRecord.find( + Media::class.java, "project_id = ?", + id.toString() + ) + + // 2. Perform DB deletion first + val deleted = project.delete() + if (deleted) { + InvalidationBus.invalidateProjects() + + // 3. Clean up physical files after successful DB removal + mediaList.forEach { media -> + fileCleanupHelper.deleteMediaFiles(media) + } + } + deleted + } + + override suspend fun getActiveSubmission(projectId: Long): Submission = + withContext(io) { + Project.getById(projectId)!!.openCollection.toDomain() + } + + override suspend fun getProjectByName(vaultId: Long, name: String): Archive? = + withContext(io) { + SugarRecord.find( + Project::class.java, + "space_id = ? AND description = ?", + vaultId.toString(), + name + ).firstOrNull()?.toDomain() + } + + override suspend fun addProject(archive: Archive): Long = withContext(io) { + val id = archive.toEntity().save() + if (id > 0) InvalidationBus.invalidateProjects() + id + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarSpaceRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarSpaceRepository.kt new file mode 100644 index 000000000..f8d06aa3a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/SugarSpaceRepository.kt @@ -0,0 +1,153 @@ +package net.opendasharchive.openarchive.core.repositories + +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.VaultAuth +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.domain.mappers.toDomain +import net.opendasharchive.openarchive.core.domain.mappers.toEntity +import net.opendasharchive.openarchive.core.security.VaultCredentialStore +import net.opendasharchive.openarchive.db.sugar.Space + + +/** + * Sugar-backed implementations; keep all ORM calls off the main thread. + * + * Credentials (passwords) are stored in [VaultCredentialStore] (AES-GCM + Android Keystore) + * rather than in the SugarORM SQLite database. Legacy plaintext passwords are migrated lazily + * on first access via [getVaultAuth]. + */ +class SugarSpaceRepository( + private val context: Context, + private val credentialStore: VaultCredentialStore, + private val io: CoroutineDispatcher = Dispatchers.IO +) : SpaceRepository { + + override suspend fun getSpaces(): List = withContext(io) { + Space.getAll().asSequence() + .toList() + .map { it.toDomain() } + .filter { it.type == VaultType.INTERNET_ARCHIVE || it.type == VaultType.PRIVATE_SERVER } + } + + override fun observeSpaces(): Flow> = InvalidationBus.spaces + .map { getSpaces() } + .distinctUntilChanged() + + override fun observeHasDwebSpace(): Flow = InvalidationBus.spaces + .map { + Space.getAll().asSequence().toList().any { entity -> + entity.toDomain().type == VaultType.DWEB_STORAGE + } + } + .distinctUntilChanged() + + override suspend fun getCurrentSpace(): Vault? = withContext(io) { + Space.current?.toDomain() + } + + override fun observeCurrentSpace(): Flow = kotlinx.coroutines.flow.combine( + InvalidationBus.spaces, + InvalidationBus.currentSpace + ) { _, _ -> + getCurrentSpace() ?: getSpaces().firstOrNull() + }.distinctUntilChanged() + + override fun observeSpace(id: Long): Flow = InvalidationBus.spaces + .map { getSpaceById(id) } + .distinctUntilChanged() + + override suspend fun setCurrentSpace(id: Long) { + withContext(io) { + val space = Space.get(id) + if (space != null) { + Space.current = space + InvalidationBus.invalidateCurrentSpace() + InvalidationBus.invalidateProjects() + InvalidationBus.invalidateCollections() + InvalidationBus.invalidateMedia() + } + } + } + + override suspend fun updateSpace(vaultId: Long, vault: Vault): Boolean = + withContext(io) { + // Guard: migrate any legacy plaintext password from Sugar DB to SecureStorage + // before wiping it. The domain model always carries password="" (toDomain()), + // so if this call happens before getVaultAuth() the credential would be lost. + val existingSpace = Space.get(vaultId) + if (existingSpace != null && existingSpace.password.isNotBlank() && !credentialStore.hasSecret(vaultId)) { + credentialStore.putSecret(vaultId, existingSpace.password) + } + + val entity = vault.toEntity() + entity.id = vaultId + entity.password = "" // never persist password in Sugar DB + val savedId = entity.save() + if (savedId > 0) { + if (vault.password.isNotBlank()) { + credentialStore.putSecret(vaultId, vault.password) + } + InvalidationBus.invalidateSpaces() + InvalidationBus.invalidateCurrentSpace() + } + return@withContext savedId > 0 + } + + override suspend fun addSpace(vault: Vault): Long = withContext(io) { + val entity = vault.toEntity() + entity.password = "" // never persist password in Sugar DB + val id = entity.save() + if (id > 0) { + if (vault.password.isNotBlank()) { + credentialStore.putSecret(id, vault.password) + } + InvalidationBus.invalidateSpaces() + } + id + } + + override suspend fun getSpaceById(id: Long): Vault? = withContext(io) { + Space.get(id)?.toDomain() + } + + override suspend fun getVaultAuth(vaultId: Long): VaultAuth? = withContext(io) { + val space = Space.get(vaultId) ?: return@withContext null + + // Lazy migration: if a plaintext password exists in the Sugar DB and the + // secure store has no entry yet, migrate it now and wipe it from Sugar. + val secret = if (credentialStore.hasSecret(vaultId)) { + credentialStore.getSecret(vaultId) + } else if (space.password.isNotBlank()) { + credentialStore.putSecret(vaultId, space.password) + space.password = "" + space.save() + credentialStore.getSecret(vaultId) + } else { + null + } + + VaultAuth( + vaultId = space.id, + type = space.toDomain().type, + username = space.username, + secret = secret.orEmpty() + ) + } + + override suspend fun deleteSpace(id: Long): Boolean = withContext(io) { + val deleted = Space.get(id)?.delete() ?: false + if (deleted) { + credentialStore.deleteSecret(id) + InvalidationBus.invalidateSpaces() + InvalidationBus.invalidateCurrentSpace() + } + deleted + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/VaultRepositoryImpl.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/VaultRepositoryImpl.kt new file mode 100644 index 000000000..2004ae487 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/VaultRepositoryImpl.kt @@ -0,0 +1,127 @@ +package net.opendasharchive.openarchive.core.repositories + +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.VaultAuth +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.domain.mappers.toDomain +import net.opendasharchive.openarchive.core.domain.mappers.toVaultEntity +import net.opendasharchive.openarchive.core.security.VaultCredentialStore +import net.opendasharchive.openarchive.db.ArchiveDao +import net.opendasharchive.openarchive.db.VaultDao + + +class VaultRepositoryImpl( + private val context: Context, + private val vaultDao: VaultDao, + private val archiveDao: ArchiveDao, + private val settingsRepository: SettingsRepository, + private val credentialStore: VaultCredentialStore, + private val io: CoroutineDispatcher = Dispatchers.IO +) : SpaceRepository { + + override suspend fun getSpaces(): List = withContext(io) { + vaultDao.getAll().map { it.toDomain() } + } + + override fun observeSpaces(): Flow> = vaultDao.observeVaults() + .map { entities -> entities.map { it.toDomain() } } + .distinctUntilChanged() + + override fun observeHasDwebSpace(): Flow = vaultDao.observeHasDwebSpace() + .distinctUntilChanged() + + override suspend fun getCurrentSpace(): Vault? = withContext(io) { + val id = settingsRepository.observeCurrentSpaceId().first() + val regularSpaces = vaultDao.getAll() + val entity = if (id == -1L) { + regularSpaces.firstOrNull() + } else { + val selected = vaultDao.getById(id) + if (selected?.type == VaultType.INTERNET_ARCHIVE || selected?.type == VaultType.PRIVATE_SERVER) { + selected + } else { + regularSpaces.firstOrNull() + } + } + entity?.toDomain() + } + + override fun observeCurrentSpace(): Flow = settingsRepository.observeCurrentSpaceId() + .flatMapLatest { id -> + if (id == -1L) { + vaultDao.observeVaults().map { it.firstOrNull() } + } else { + vaultDao.observeById(id).flatMapLatest { entity -> + if (entity == null || entity.type == VaultType.DWEB_STORAGE) { + vaultDao.observeVaults().map { it.firstOrNull() } + } else { + flowOf(entity) + } + } + } + } + .map { it?.toDomain() } + .distinctUntilChanged() + + override fun observeSpace(id: Long): Flow = vaultDao.observeById(id) + .map { it?.toDomain() } + .distinctUntilChanged() + + override suspend fun setCurrentSpace(id: Long) { + withContext(io) { + settingsRepository.setCurrentSpaceId(id) + } + } + + override suspend fun getSpaceById(id: Long): Vault? = withContext(io) { + vaultDao.getById(id)?.toDomain() + } + + override suspend fun getVaultAuth(vaultId: Long): VaultAuth? = withContext(io) { + val vault = vaultDao.getById(vaultId)?.toDomain() ?: return@withContext null + val secret = credentialStore.getSecret(vaultId) ?: return@withContext null + VaultAuth( + vaultId = vault.id, + type = vault.type, + username = vault.username, + secret = secret + ) + } + + override suspend fun updateSpace(vaultId: Long, vault: Vault): Boolean = withContext(io) { + val oldVault = vaultDao.getById(vaultId) + val entity = vault.toVaultEntity().copy(id = vaultId) + val success = vaultDao.upsert(entity) > 0 + if (success) { + if (vault.password.isNotBlank()) { + credentialStore.putSecret(vaultId, vault.password) + } + if (oldVault?.host != entity.host || oldVault.username != entity.username) { + archiveDao.resetRemoteStatusForVault(vaultId) + } + archiveDao.updateLicenseForVault(vaultId, vault.licenseUrl) + } + success + } + + override suspend fun addSpace(vault: Vault): Long = withContext(io) { + val vaultId = vaultDao.upsert(vault.toVaultEntity()) + if (vaultId > 0 && vault.password.isNotBlank()) { + credentialStore.putSecret(vaultId, vault.password) + } + vaultId + } + + override suspend fun deleteSpace(id: Long): Boolean = withContext(io) { + vaultDao.getById(id)?.let { + vaultDao.delete(it) + credentialStore.deleteSecret(id) + true + } ?: false + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/security/C2paKeyStore.kt b/app/src/main/java/net/opendasharchive/openarchive/core/security/C2paKeyStore.kt new file mode 100644 index 000000000..c802b7626 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/security/C2paKeyStore.kt @@ -0,0 +1,63 @@ +package net.opendasharchive.openarchive.core.security + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import net.opendasharchive.openarchive.core.security.SecureStorage + +/** + * Secure storage for C2PA signing keys using Android Keystore (AES-GCM). + * Replaces plaintext storage in SharedPreferences. + */ +class C2paKeyStore(context: Context) { + + private val secureStorage = SecureStorage(context, alias = "C2paKeyStore") + + companion object { + private const val KEY_SIGNING_KEY = "c2pa_signing_key" + private const val KEY_PASSPHRASE = "c2pa_passphrase" + + // Legacy SharedPreferences keys used before this class was introduced + private const val LEGACY_KEY_SIGNING_KEY = "c2pa_signing_key" + private const val LEGACY_KEY_PASSPHRASE = "c2pa_encrypted_passphrase" + } + + fun getSigningKey(): String? = secureStorage.getString(KEY_SIGNING_KEY) + + fun putSigningKey(value: String?) = secureStorage.putString(KEY_SIGNING_KEY, value) + + fun getPassphrase(): ByteArray? = secureStorage.getString(KEY_PASSPHRASE) + ?.let { Base64.decode(it, Base64.DEFAULT) } + + fun putPassphrase(value: ByteArray?) { + val encoded = value?.let { Base64.encodeToString(it, Base64.DEFAULT) } + secureStorage.putString(KEY_PASSPHRASE, encoded) + } + + fun clear() { + secureStorage.remove(KEY_SIGNING_KEY) + secureStorage.remove(KEY_PASSPHRASE) + } + + /** + * One-time migration: moves C2PA keys from plaintext SharedPreferences to SecureStorage, + * then removes them from SharedPreferences. + */ + fun migrateFromPrefsIfNeeded(prefs: SharedPreferences) { + val legacyKey = prefs.getString(LEGACY_KEY_SIGNING_KEY, null) + if (legacyKey != null && secureStorage.getString(KEY_SIGNING_KEY) == null) { + secureStorage.putString(KEY_SIGNING_KEY, legacyKey) + } + if (legacyKey != null) { + prefs.edit().remove(LEGACY_KEY_SIGNING_KEY).apply() + } + + val legacyPassphrase = prefs.getString(LEGACY_KEY_PASSPHRASE, null) + if (legacyPassphrase != null && secureStorage.getString(KEY_PASSPHRASE) == null) { + secureStorage.putString(KEY_PASSPHRASE, legacyPassphrase) + } + if (legacyPassphrase != null) { + prefs.edit().remove(LEGACY_KEY_PASSPHRASE).apply() + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/security/SecureStorage.kt b/app/src/main/java/net/opendasharchive/openarchive/core/security/SecureStorage.kt new file mode 100644 index 000000000..0d6fa3af0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/security/SecureStorage.kt @@ -0,0 +1,111 @@ +package net.opendasharchive.openarchive.core.security + +import android.content.Context +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.core.content.edit +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * General-purpose AES-GCM secure storage backed by Android Keystore. + * Replaces deprecated EncryptedSharedPreferences. + */ +class SecureStorage( + private val context: Context, + private val alias: String = "SaveSecureStorage", +) { + private val keyStore: KeyStore by lazy { + KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + } + + private val sharedPreferences: SharedPreferences by lazy { + context.getSharedPreferences("${alias}_prefs", Context.MODE_PRIVATE) + } + + companion object { + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH = 16 + } + + init { + generateKeyIfNeeded() + } + + private fun generateKeyIfNeeded() { + if (!keyStore.containsAlias(alias)) { + val keyGenerator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val keyGenParameterSpec = + KeyGenParameterSpec + .Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .build() + + keyGenerator.init(keyGenParameterSpec) + keyGenerator.generateKey() + } + } + + private fun getSecretKey(): SecretKey = keyStore.getKey(alias, null) as SecretKey + + private fun encrypt(data: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) + + val iv = cipher.iv + val encryptedData = cipher.doFinal(data.toByteArray(Charsets.UTF_8)) + + val combined = iv + encryptedData + return Base64.encodeToString(combined, Base64.DEFAULT) + } + + private fun decrypt(encryptedData: String): String { + val combined = Base64.decode(encryptedData, Base64.DEFAULT) + + val iv = combined.sliceArray(0 until GCM_IV_LENGTH) + val encrypted = combined.sliceArray(GCM_IV_LENGTH until combined.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(GCM_TAG_LENGTH * 8, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) + + return String(cipher.doFinal(encrypted), Charsets.UTF_8) + } + + fun putString(key: String, value: String?) { + sharedPreferences.edit { + if (value != null) putString(key, encrypt(value)) else remove(key) + } + } + + fun getString(key: String, defaultValue: String? = null): String? { + val encryptedValue = sharedPreferences.getString(key, null) ?: return defaultValue + return try { + decrypt(encryptedValue) + } catch (_: Exception) { + defaultValue + } + } + + fun remove(key: String) { + sharedPreferences.edit { remove(key) } + } + + fun contains(key: String): Boolean = sharedPreferences.contains(key) + + fun clear() { + sharedPreferences.edit { clear() } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/security/SecurityManager.kt b/app/src/main/java/net/opendasharchive/openarchive/core/security/SecurityManager.kt new file mode 100644 index 000000000..144f84525 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/security/SecurityManager.kt @@ -0,0 +1,41 @@ +package net.opendasharchive.openarchive.core.security + +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import net.opendasharchive.openarchive.util.Prefs + +/** + * Manages window security requirements (like screenshot prevention) reactively. + */ +class SecurityManager( + private val sharedPreferences: SharedPreferences +) { + private val _isSecureRequired = MutableStateFlow(calculateSecureRequirement()) + val isSecureRequired: StateFlow = _isSecureRequired.asStateFlow() + + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == Prefs.PASSCODE_ENABLED || key == Prefs.PROHIBIT_SCREENSHOTS) { + _isSecureRequired.value = calculateSecureRequirement() + } + } + + init { + sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + private fun calculateSecureRequirement(): Boolean { + // We use Prefs object for convenience as it logic is already centralized there + // but we react to the SharedPreferences changes. + return Prefs.passcodeEnabled || Prefs.prohibitScreenshots + } + + /** + * Call this when the manager is no longer needed (e.g. app process termination), + * though as a singleton it will live for the app lifecycle. + */ + fun teardown() { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + } +} 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 new file mode 100644 index 000000000..a38b94106 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/security/TinkVaultCredentialStore.kt @@ -0,0 +1,89 @@ +package net.opendasharchive.openarchive.core.security + +import android.content.Context +import android.util.Base64 +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +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 + +class TinkVaultCredentialStore( + context: Context, + private val io: CoroutineDispatcher = Dispatchers.IO +) : VaultCredentialStore { + + private val appContext = context.applicationContext + + private val dataStore: DataStore by lazy { + PreferenceDataStoreFactory.create( + scope = CoroutineScope(SupervisorJob() + io), + produceFile = { appContext.preferencesDataStoreFile(DATASTORE_FILE_NAME) } + ) + } + + 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) + } + + override suspend fun putSecret(vaultId: Long, secret: String) = withContext(io) { + val encrypted = aead.encrypt( + secret.toByteArray(Charsets.UTF_8), + associatedData(vaultId) + ) + dataStore.edit { prefs -> + prefs[secretKey(vaultId)] = Base64.encodeToString(encrypted, 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) + } + + override suspend fun hasSecret(vaultId: Long): Boolean = withContext(io) { + dataStore.data.first().contains(secretKey(vaultId)) + } + + override suspend fun deleteSecret(vaultId: Long) = withContext(io) { + 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" + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/security/VaultCredentialStore.kt b/app/src/main/java/net/opendasharchive/openarchive/core/security/VaultCredentialStore.kt new file mode 100644 index 000000000..77703a757 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/security/VaultCredentialStore.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.core.security + +interface VaultCredentialStore { + suspend fun putSecret(vaultId: Long, secret: String) + suspend fun getSecret(vaultId: Long): String? + suspend fun hasSecret(vaultId: Long): Boolean + suspend fun deleteSecret(vaultId: Long) +} + diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Dispatcher.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Dispatcher.kt deleted file mode 100644 index c03cb9f33..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/state/Dispatcher.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.opendasharchive.openarchive.core.state - -typealias Dispatch = (A) -> Unit - -fun interface Dispatcher { - - fun dispatch(action: Action) -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Effects.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Effects.kt deleted file mode 100644 index 677c100c9..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/state/Effects.kt +++ /dev/null @@ -1,4 +0,0 @@ -package net.opendasharchive.openarchive.core.state - - -typealias Effects = suspend (T, A) -> Unit \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Listener.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Listener.kt deleted file mode 100644 index 9fdc43a3f..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/state/Listener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package net.opendasharchive.openarchive.core.state - -import kotlinx.coroutines.flow.Flow - -interface Listener { - val actions: Flow -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Notifier.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Notifier.kt deleted file mode 100644 index c5901fb71..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/state/Notifier.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.opendasharchive.openarchive.core.state - - -typealias Notify = suspend (A) -> Unit - -fun interface Notifier { - suspend fun notify(action: Action) -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Reducer.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Reducer.kt deleted file mode 100644 index 0f20ef84f..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/state/Reducer.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.opendasharchive.openarchive.core.state - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.updateAndGet - -typealias Reducer = (T, A) -> T - -fun MutableStateFlow.apply(action: A, reducer: Reducer) = - updateAndGet { reducer(it, action) } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/StateDispatcher.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/StateDispatcher.kt deleted file mode 100644 index e9ec57b7d..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/state/StateDispatcher.kt +++ /dev/null @@ -1,25 +0,0 @@ -package net.opendasharchive.openarchive.core.state - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -class StateDispatcher( - private val scope: CoroutineScope, - initialState: T, - private val reducer: Reducer, - private val effects: Effects -) : Dispatcher, Stateful { - private val _state = MutableStateFlow(initialState) - - override val state = _state.asStateFlow() - - override fun dispatch(action: A) { - val state = _state.apply(action, reducer) - scope.launch(Dispatchers.Default) { - effects(state, action) - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Stateful.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Stateful.kt deleted file mode 100644 index 9dfcda1e7..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/state/Stateful.kt +++ /dev/null @@ -1,7 +0,0 @@ -package net.opendasharchive.openarchive.core.state - -import kotlinx.coroutines.flow.StateFlow - -interface Stateful { - val state: StateFlow -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Store.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Store.kt deleted file mode 100644 index c5453fb3e..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/state/Store.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.opendasharchive.openarchive.core.state - -interface Store : Dispatcher, Listener, Notifier { - - operator fun invoke(action: Action) = dispatch(action) -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/StoreObserver.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/StoreObserver.kt deleted file mode 100644 index 94f7fa3c8..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/state/StoreObserver.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.opendasharchive.openarchive.core.state - -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow - -class StoreObserver : Notifier, Listener { - private val _actions = Channel() - override val actions = _actions.receiveAsFlow() - - override suspend fun notify(action: T) { - _actions.send(action) - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/ApiError.kt b/app/src/main/java/net/opendasharchive/openarchive/db/ApiError.kt deleted file mode 100644 index cc2222ffa..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/ApiError.kt +++ /dev/null @@ -1,46 +0,0 @@ -package net.opendasharchive.openarchive.db - -import kotlinx.serialization.Serializable - -@Serializable -sealed class ApiError: SerializableMarker { - @Serializable - data class HttpError(val code: Int, val message: String) : ApiError() - - @Serializable - data class NetworkError(val message: String) : ApiError() - - @Serializable - data class ServerError(val message: String) : ApiError() - - @Serializable - data class ClientError(val message: String) : ApiError() - - @Serializable - data class UnexpectedError(val message: String) : ApiError() - - @Serializable - data object Unauthorized : ApiError() - - @Serializable - data object ResourceNotFound : ApiError() - - @Serializable - data object TimedOut : ApiError() - - @Serializable - data object None : ApiError() - - val friendlyMessage: String - get() = when (this) { - is HttpError -> "HTTP Error $code: $message" - is NetworkError -> message - is ServerError -> message - is ClientError -> message - is UnexpectedError -> message - Unauthorized -> "Unauthorized: Please log in and try again" - ResourceNotFound -> "The requested resource was not found" - TimedOut -> "The request timed out" - None -> "No error" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/AppDatabase.kt b/app/src/main/java/net/opendasharchive/openarchive/db/AppDatabase.kt new file mode 100644 index 000000000..42ed582c3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/AppDatabase.kt @@ -0,0 +1,48 @@ +package net.opendasharchive.openarchive.db + +import android.annotation.SuppressLint +import androidx.room3.Database +import androidx.room3.RoomDatabase +import androidx.room3.TypeConverters +import androidx.room3.AutoMigration +import androidx.room3.DeleteColumn +import androidx.room3.migration.AutoMigrationSpec + +@SuppressLint("RestrictedApi") +@Database( + entities = [ + VaultEntity::class, + ArchiveEntity::class, + SubmissionEntity::class, + EvidenceEntity::class, + MigrationStateEntity::class, + VaultDwebEntity::class, + ArchiveDwebEntity::class, + EvidenceDwebEntity::class + ], + autoMigrations = [ + AutoMigration( + from = 1, + to = 2, + spec = RemoveVaultPasswordColumnMigration::class + ), + AutoMigration( + from = 2, + to = 3 + ) + ], + version = 3, + exportSchema = true +) +@TypeConverters(Converters::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun vaultDao(): VaultDao + abstract fun archiveDao(): ArchiveDao + abstract fun submissionDao(): SubmissionDao + abstract fun evidenceDao(): EvidenceDao + abstract fun migrationDao(): MigrationDao + abstract fun dwebDao(): DwebDao +} + +@DeleteColumn(tableName = "vaults", columnName = "password") +class RemoveVaultPasswordColumnMigration : AutoMigrationSpec diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDao.kt new file mode 100644 index 000000000..45431a028 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDao.kt @@ -0,0 +1,35 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface ArchiveDao { + @Query("SELECT * FROM archives WHERE vaultId = :vaultId AND archived = :archived ORDER BY id DESC") + fun observeByVault(vaultId: Long, archived: Boolean): Flow> + + @Query("SELECT * FROM archives WHERE id = :id") + fun observeById(id: Long): Flow + + @Query("SELECT * FROM archives WHERE id = :id") + suspend fun getById(id: Long): ArchiveEntity? + + @Query("SELECT * FROM archives WHERE vaultId = :vaultId AND description = :name LIMIT 1") + suspend fun getByName(vaultId: Long, name: String): ArchiveEntity? + + @Query("UPDATE archives SET licenseUrl = :licenseUrl WHERE vaultId = :vaultId") + suspend fun updateLicenseForVault(vaultId: Long, licenseUrl: String?) + + @Query("UPDATE archives SET isRemote = 0 WHERE vaultId = :vaultId") + suspend fun resetRemoteStatusForVault(vaultId: Long) + + + + @Upsert + suspend fun upsert(entity: ArchiveEntity): Long + + + + @Delete + suspend fun delete(entity: ArchiveEntity) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDwebEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDwebEntity.kt new file mode 100644 index 000000000..b0a06672e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveDwebEntity.kt @@ -0,0 +1,24 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.PrimaryKey +import net.opendasharchive.openarchive.core.domain.ArchivePermission + +@Entity( + tableName = "archive_dweb_metadata", + foreignKeys = [ + ForeignKey( + entity = ArchiveEntity::class, + parentColumns = ["id"], + childColumns = ["archiveId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class ArchiveDwebEntity( + @PrimaryKey val archiveId: Long, + val archiveKey: String, + val archiveHash: String, + val permissions: ArchivePermission +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveEntity.kt new file mode 100644 index 000000000..ac16f9ccf --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/ArchiveEntity.kt @@ -0,0 +1,30 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import kotlinx.datetime.LocalDateTime + +@Entity( + tableName = "archives", + foreignKeys = [ + ForeignKey( + entity = VaultEntity::class, + parentColumns = ["id"], + childColumns = ["vaultId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("vaultId")] +) +data class ArchiveEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val description: String?, + val createdAt: LocalDateTime?, + val vaultId: Long, + val archived: Boolean, + val openSubmissionId: Long, + val licenseUrl: String?, + val isRemote: Boolean +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Converters.kt b/app/src/main/java/net/opendasharchive/openarchive/db/Converters.kt new file mode 100644 index 000000000..3833a5083 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/Converters.kt @@ -0,0 +1,53 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.TypeConverter +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import kotlin.time.Instant + +class Converters { + @TypeConverter + fun fromTimestamp(value: Long?): LocalDateTime? { + return value?.let { + Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.currentSystemDefault()) + } + } + + @TypeConverter + fun dateToTimestamp(date: LocalDateTime?): Long? { + return date?.toInstant(TimeZone.currentSystemDefault())?.toEpochMilliseconds() + } + + @TypeConverter + fun fromVaultType(value: VaultType): Int = when (value) { + VaultType.PRIVATE_SERVER -> 0 + VaultType.INTERNET_ARCHIVE -> 1 + VaultType.DWEB_STORAGE -> 5 + } + + @TypeConverter + fun toVaultType(value: Int): VaultType = when (value) { + 0 -> VaultType.PRIVATE_SERVER + 1 -> VaultType.INTERNET_ARCHIVE + 5 -> VaultType.DWEB_STORAGE + else -> VaultType.PRIVATE_SERVER + } + + @TypeConverter + fun fromEvidenceStatus(value: EvidenceStatus): Int = value.id + + @TypeConverter + fun toEvidenceStatus(value: Int): EvidenceStatus = when (value) { + 0 -> EvidenceStatus.NEW + 1 -> EvidenceStatus.LOCAL + 2 -> EvidenceStatus.QUEUED + 4 -> EvidenceStatus.UPLOADING + 5 -> EvidenceStatus.UPLOADED + 9 -> EvidenceStatus.ERROR + else -> EvidenceStatus.NEW + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/DwebCompositeEntities.kt b/app/src/main/java/net/opendasharchive/openarchive/db/DwebCompositeEntities.kt new file mode 100644 index 000000000..c427a3d32 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/DwebCompositeEntities.kt @@ -0,0 +1,31 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Embedded +import androidx.room3.Relation + +data class VaultWithDweb( + @Embedded val vault: VaultEntity, + @Relation( + parentColumn = "id", + entityColumn = "vaultId" + ) + val dwebMetadata: VaultDwebEntity? +) + +data class ArchiveWithDweb( + @Embedded val archive: ArchiveEntity, + @Relation( + parentColumn = "id", + entityColumn = "archiveId" + ) + val dwebMetadata: ArchiveDwebEntity? +) + +data class EvidenceWithDweb( + @Embedded val evidence: EvidenceEntity, + @Relation( + parentColumn = "id", + entityColumn = "evidenceId" + ) + val dwebMetadata: EvidenceDwebEntity? +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/DwebDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/DwebDao.kt new file mode 100644 index 000000000..b03923440 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/DwebDao.kt @@ -0,0 +1,183 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.* +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.LocalDateTime + +@Dao +interface DwebDao { + + companion object { + // VaultType.DWEB_STORAGE converter value + private const val DWEB_VAULT_TYPE = 5 + } + + // --- Vaults --- + + @Transaction + @Query( + """ + SELECT v.* FROM vaults v + INNER JOIN vault_dweb_metadata m ON v.id = m.vaultId + WHERE v.type = $DWEB_VAULT_TYPE + """ + ) + fun observeVaultsWithDweb(): Flow> + + @Transaction + @Query( + """ + SELECT v.* FROM vaults v + INNER JOIN vault_dweb_metadata m ON v.id = m.vaultId + WHERE v.id = :id AND v.type = $DWEB_VAULT_TYPE + """ + ) + fun observeVaultWithDwebById(id: Long): Flow + + @Transaction + @Query( + """ + SELECT v.* FROM vaults v + INNER JOIN vault_dweb_metadata m ON v.id = m.vaultId + WHERE m.vaultKey = :key AND v.type = $DWEB_VAULT_TYPE + """ + ) + suspend fun getVaultWithDwebByKey(key: String): VaultWithDweb? + + @Transaction + @Query( + """ + SELECT v.* FROM vaults v + INNER JOIN vault_dweb_metadata m ON v.id = m.vaultId + WHERE v.id = :id AND v.type = $DWEB_VAULT_TYPE + """ + ) + suspend fun getVaultWithDwebById(id: Long): VaultWithDweb? + + @Query("SELECT vaultId FROM vault_dweb_metadata WHERE vaultKey = :key LIMIT 1") + suspend fun getVaultIdByKey(key: String): Long? + + @Upsert + suspend fun upsertVaultMetadata(entity: VaultDwebEntity) + + // --- Archives --- + + @Transaction + @Query( + """ + SELECT a.* FROM archives a + INNER JOIN vaults v ON v.id = a.vaultId + INNER JOIN archive_dweb_metadata m ON m.archiveId = a.id + WHERE a.vaultId = :vaultId + AND a.archived = :archived + AND v.type = $DWEB_VAULT_TYPE + ORDER BY a.id DESC + """ + ) + fun observeArchivesWithDweb(vaultId: Long, archived: Boolean): Flow> + + @Transaction + @Query( + """ + SELECT a.* FROM archives a + INNER JOIN vaults v ON v.id = a.vaultId + INNER JOIN archive_dweb_metadata m ON m.archiveId = a.id + WHERE a.id = :id AND v.type = $DWEB_VAULT_TYPE + """ + ) + fun observeArchiveWithDwebById(id: Long): Flow + + @Transaction + @Query( + """ + SELECT a.* FROM archives a + INNER JOIN vaults v ON v.id = a.vaultId + INNER JOIN archive_dweb_metadata m ON m.archiveId = a.id + WHERE a.id = :id AND v.type = $DWEB_VAULT_TYPE + """ + ) + suspend fun getArchiveWithDwebById(id: Long): ArchiveWithDweb? + + @Query("SELECT archiveId FROM archive_dweb_metadata WHERE archiveKey = :key LIMIT 1") + suspend fun getArchiveIdByKey(key: String): Long? + + @Upsert + suspend fun upsertArchiveMetadata(entity: ArchiveDwebEntity) + + // --- Evidence --- + + @Transaction + @Query( + """ + SELECT e.* FROM evidence e + INNER JOIN archives a ON a.id = e.archiveId + INNER JOIN vaults v ON v.id = a.vaultId + INNER JOIN evidence_dweb_metadata m ON m.evidenceId = e.id + WHERE e.archiveId = :archiveId + AND v.type = $DWEB_VAULT_TYPE + """ + ) + fun observeEvidenceWithDweb(archiveId: Long): Flow> + + @Transaction + @Query( + """ + SELECT e.* FROM evidence e + INNER JOIN archives a ON a.id = e.archiveId + INNER JOIN vaults v ON v.id = a.vaultId + INNER JOIN evidence_dweb_metadata m ON m.evidenceId = e.id + WHERE e.id = :id + AND v.type = $DWEB_VAULT_TYPE + """ + ) + suspend fun getEvidenceWithDwebById(id: Long): EvidenceWithDweb? + + // --- Submissions --- + + @Query( + """ + SELECT s.* FROM submissions s + INNER JOIN archives a ON a.id = s.archiveId + INNER JOIN vaults v ON v.id = a.vaultId + WHERE s.archiveId = :archiveId + AND v.type = $DWEB_VAULT_TYPE + ORDER BY s.id DESC + """ + ) + fun observeSubmissionsForDwebArchive(archiveId: Long): Flow> + + @Upsert + suspend fun upsertEvidenceMetadata(entity: EvidenceDwebEntity) + + @Query( + """ + UPDATE evidence + SET originalFilePath = :localFilePath, + mimeType = :mimeType, + updatedAt = :updatedAt + WHERE id = :evidenceId + """ + ) + suspend fun updateEvidenceLocalFilePath( + evidenceId: Long, + localFilePath: String, + mimeType: String, + updatedAt: LocalDateTime + ) + + @Transaction + suspend fun markEvidenceDownloaded( + evidenceId: Long, + localFilePath: String, + mimeType: String, + updatedAt: LocalDateTime + ) { + updateEvidenceLocalFilePath(evidenceId, localFilePath, mimeType, updatedAt) + upsertEvidenceMetadata( + EvidenceDwebEntity( + evidenceId = evidenceId, + isDownloaded = true + ) + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt new file mode 100644 index 000000000..2b91d91e7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt @@ -0,0 +1,69 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.* +import kotlinx.coroutines.flow.Flow +import net.opendasharchive.openarchive.core.domain.EvidenceStatus + +@Dao +interface EvidenceDao { + @Query(""" + SELECT * FROM evidence + WHERE submissionId = :submissionId + ORDER BY + CASE + WHEN status IN (2, 4) THEN 0 -- Active (Queued, Uploading) + WHEN status = 9 THEN 1 -- Error + WHEN status IN (0, 1) THEN 2 -- Local/New + WHEN status = 5 THEN 3 -- Uploaded + ELSE 4 + END, + CASE WHEN status = 5 THEN uploadedAt ELSE 0 END DESC, + priority DESC, + id DESC + """) + fun observeBySubmission(submissionId: Long): Flow> + + @Query(""" + SELECT * FROM evidence + WHERE archiveId = :archiveId + ORDER BY + CASE + WHEN status IN (2, 4) THEN 0 -- Active + WHEN status = 9 THEN 1 -- Error + WHEN status IN (0, 1) THEN 2 -- Local/New + WHEN status = 5 THEN 3 -- Uploaded + ELSE 4 + END, + CASE WHEN status = 5 THEN uploadedAt ELSE 0 END DESC, + priority DESC, + id DESC + """) + fun observeByArchive(archiveId: Long): Flow> + + @Query("SELECT * FROM evidence WHERE archiveId = :archiveId") + suspend fun getByArchive(archiveId: Long): List + + + @Query("SELECT * FROM evidence WHERE status IN (:statuses) ORDER BY priority DESC, id DESC") + suspend fun getByStatus(statuses: List): List + + @Query("SELECT * FROM evidence WHERE status IN (:statuses) ORDER BY priority DESC, id DESC") + fun observeByStatus(statuses: List): Flow> + + @Query("SELECT * FROM evidence WHERE id = :id") + suspend fun getById(id: Long): EvidenceEntity? + + @Query("SELECT COUNT(*) FROM evidence WHERE submissionId = :submissionId") + suspend fun getCountBySubmission(submissionId: Long): Long + + @Query("SELECT id FROM evidence WHERE archiveId = :archiveId AND mediaHashString = :hash LIMIT 1") + suspend fun getEvidenceIdByHash(archiveId: Long, hash: String): Long? + + @Upsert + suspend fun upsert(entity: EvidenceEntity): Long + + + + @Query("DELETE FROM evidence WHERE id = :id") + suspend fun deleteById(id: Long) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDwebEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDwebEntity.kt new file mode 100644 index 000000000..e24cc1a98 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDwebEntity.kt @@ -0,0 +1,21 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.PrimaryKey + +@Entity( + tableName = "evidence_dweb_metadata", + foreignKeys = [ + ForeignKey( + entity = EvidenceEntity::class, + parentColumns = ["id"], + childColumns = ["evidenceId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class EvidenceDwebEntity( + @PrimaryKey val evidenceId: Long, + val isDownloaded: Boolean +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceEntity.kt new file mode 100644 index 000000000..e592a5c28 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceEntity.kt @@ -0,0 +1,58 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import kotlinx.datetime.LocalDateTime +import net.opendasharchive.openarchive.core.domain.EvidenceStatus + +@Entity( + tableName = "evidence", + foreignKeys = [ + ForeignKey( + entity = ArchiveEntity::class, + parentColumns = ["id"], + childColumns = ["archiveId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = SubmissionEntity::class, + parentColumns = ["id"], + childColumns = ["submissionId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index("archiveId"), + Index("submissionId"), + Index("status"), + Index("priority"), + Index("createdAt") + ] +) +data class EvidenceEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val originalFilePath: String, + val mimeType: String, + val createdAt: LocalDateTime?, + val updatedAt: LocalDateTime?, + val uploadedAt: LocalDateTime?, + val serverUrl: String, + val title: String, + val description: String, + val author: String, + val location: String, + val tags: String, + val licenseUrl: String?, + val mediaHashString: String, + val status: EvidenceStatus, + val statusMessage: String, + val archiveId: Long, + val submissionId: Long, + val contentLength: Long, + val progress: Long, + val flag: Boolean, + val priority: Int, + val thumbnail: ByteArray? = null +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/FileUploadResult.kt b/app/src/main/java/net/opendasharchive/openarchive/db/FileUploadResult.kt deleted file mode 100644 index 6f296dbb9..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/FileUploadResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.opendasharchive.openarchive.db - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class FileUploadResult ( - var name: String, - @SerialName("updated_collection_hash") var updatedCollectionHash: String -) : SerializableMarker \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/JoinGroupResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/db/JoinGroupResponse.kt deleted file mode 100644 index 7c26ded29..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/JoinGroupResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.opendasharchive.openarchive.db - -import kotlinx.serialization.Serializable - -@Serializable -data class JoinGroupResponse( - val group: SnowbirdGroup -): SerializableMarker \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/MigrationDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationDao.kt new file mode 100644 index 000000000..275f18547 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationDao.kt @@ -0,0 +1,12 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.* + +@Dao +interface MigrationDao { + @Query("SELECT * FROM migration_state WHERE id = 1") + suspend fun getMigrationState(): MigrationStateEntity? + + @Upsert + suspend fun upsert(state: MigrationStateEntity) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/MigrationStateEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationStateEntity.kt new file mode 100644 index 000000000..57239c588 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationStateEntity.kt @@ -0,0 +1,13 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Entity +import androidx.room3.PrimaryKey + +@Entity(tableName = "migration_state") +data class MigrationStateEntity( + @PrimaryKey val id: Int = 1, + val stage: String, // IDLE, SPACES, PROJECTS, COLLECTIONS, MEDIA, DONE + val processedCount: Int, + val totalCount: Int, + val completedAt: Long? = null +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/MigrationWorker.kt b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationWorker.kt new file mode 100644 index 000000000..30a22645c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/MigrationWorker.kt @@ -0,0 +1,185 @@ +package net.opendasharchive.openarchive.db + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.orm.SugarRecord +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.security.VaultCredentialStore +import net.opendasharchive.openarchive.util.DateUtils +import net.opendasharchive.openarchive.util.Prefs +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.db.sugar.Collection as SugarCollection +import net.opendasharchive.openarchive.db.sugar.Media as SugarMedia +import net.opendasharchive.openarchive.db.sugar.Project as SugarProject +import net.opendasharchive.openarchive.db.sugar.Space as SugarSpace +import net.opendasharchive.openarchive.util.toLocalDateTime + +class MigrationWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams), KoinComponent { + + private val vaultDao: VaultDao by inject() + private val archiveDao: ArchiveDao by inject() + private val submissionDao: SubmissionDao by inject() + private val evidenceDao: EvidenceDao by inject() + private val migrationDao: MigrationDao by inject() + private val credentialStore: VaultCredentialStore by inject() + + override suspend fun doWork(): Result { + if (Prefs.isRoomMigrated) return Result.success() + + Prefs.isMigrationInProgress = true + + try { + var state = migrationDao.getMigrationState() ?: MigrationStateEntity( + stage = "IDLE", + processedCount = 0, + totalCount = 0 + ) + + if (state.stage == "IDLE" || state.stage == "SPACES") { + migrateSpaces() + state = migrationDao.getMigrationState()!! + } + + if (state.stage == "PROJECTS") { + migrateProjects() + state = migrationDao.getMigrationState()!! + } + + if (state.stage == "COLLECTIONS") { + migrateCollections() + state = migrationDao.getMigrationState()!! + } + + if (state.stage == "MEDIA") { + migrateMedia() + state = migrationDao.getMigrationState()!! + } + + migrationDao.upsert(state.copy(stage = "DONE", completedAt = DateUtils.now)) + + Prefs.isRoomMigrated = true + Prefs.isMigrationInProgress = false + AppLogger.i("Migration to Room completed successfully") + return Result.success() + } catch (e: Exception) { + AppLogger.e("Migration to Room failed", e) + Prefs.isMigrationInProgress = false + return Result.retry() + } + } + + private suspend fun migrateSpaces() { + val spaces = SugarSpace.getAll().asSequence().toList() + AppLogger.i("Migrating ${spaces.size} spaces") + + spaces.forEach { space -> + val vaultId = vaultDao.upsert( + VaultEntity( + id = space.id, + type = when (space.tType) { + SugarSpace.Type.WEBDAV -> VaultType.PRIVATE_SERVER + SugarSpace.Type.INTERNET_ARCHIVE -> VaultType.INTERNET_ARCHIVE + SugarSpace.Type.RAVEN -> VaultType.DWEB_STORAGE + }, + name = space.name, + username = space.username, + displayName = space.displayname, + host = space.host, + metaData = space.metaData, + licenseUrl = space.license, + createdAt = DateUtils.now.toLocalDateTime() + ) + ) + if (space.password.isNotBlank()) { + credentialStore.putSecret(vaultId, space.password) + } + } + migrationDao.upsert(MigrationStateEntity(stage = "PROJECTS", processedCount = 0, totalCount = 0)) + } + + private suspend fun migrateProjects() { + val projects = SugarRecord.findAll(SugarProject::class.java).asSequence().toList() + AppLogger.i("Migrating ${projects.size} projects") + + projects.forEach { project -> + archiveDao.upsert( + ArchiveEntity( + id = project.id, + description = project.description, + createdAt = project.created?.time?.toLocalDateTime(), + vaultId = project.spaceId ?: -1, + archived = project.isArchived, + openSubmissionId = project.openCollectionId, + licenseUrl = project.licenseUrl, + isRemote = false + ) + ) + } + migrationDao.upsert(MigrationStateEntity(stage = "COLLECTIONS", processedCount = 0, totalCount = 0)) + } + + private suspend fun migrateCollections() { + val collections = SugarRecord.findAll(SugarCollection::class.java).asSequence().toList() + AppLogger.i("Migrating ${collections.size} collections") + + collections.forEach { collection -> + submissionDao.upsert( + SubmissionEntity( + id = collection.id, + archiveId = collection.projectId ?: -1, + uploadedAt = collection.uploadDate?.time?.toLocalDateTime(), + serverUrl = collection.serverUrl + ) + ) + } + migrationDao.upsert(MigrationStateEntity(stage = "MEDIA", processedCount = 0, totalCount = 0)) + } + + private suspend fun migrateMedia() { + val mediaList = SugarRecord.findAll(SugarMedia::class.java).asSequence().toList() + AppLogger.i("Migrating ${mediaList.size} media items") + + mediaList.forEach { media -> + evidenceDao.upsert( + EvidenceEntity( + id = media.id, + originalFilePath = media.originalFilePath, + mimeType = media.mimeType, + createdAt = media.createDate?.time?.toLocalDateTime(), + updatedAt = media.updateDate?.time?.toLocalDateTime(), + uploadedAt = media.uploadDate?.time?.toLocalDateTime(), + serverUrl = media.serverUrl, + title = media.title, + description = media.description, + author = media.author, + location = media.location, + tags = media.tags, + licenseUrl = media.licenseUrl, + mediaHashString = media.mediaHashString, + status = when (media.sStatus) { + SugarMedia.Status.Local -> EvidenceStatus.LOCAL + SugarMedia.Status.Queued -> EvidenceStatus.QUEUED + SugarMedia.Status.Uploading -> EvidenceStatus.UPLOADING + SugarMedia.Status.Uploaded -> EvidenceStatus.UPLOADED + SugarMedia.Status.Error -> EvidenceStatus.ERROR + else -> EvidenceStatus.NEW + }, + statusMessage = media.statusMessage, + archiveId = media.projectId, + submissionId = media.collectionId, + contentLength = media.contentLength, + progress = media.progress, + flag = media.flag, + priority = media.priority + ) + ) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/RefreshGroupResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/db/RefreshGroupResponse.kt deleted file mode 100644 index 280916b6f..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/RefreshGroupResponse.kt +++ /dev/null @@ -1,39 +0,0 @@ -package net.opendasharchive.openarchive.db - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class RefreshGroupResponse( - val status: String, - @SerialName("repos") - val refreshedRepos: List -) : SerializableMarker - - -@Serializable -data class RefreshedRepo( - @SerialName("repo_id") - val repoId: String, - @SerialName("repo_hash") - val hash: String? = null, - val name: String, - @SerialName("can_write") - val canWrite: Boolean, - @SerialName("all_files") - val allFiles: List, - @SerialName("refreshed_files") - val refreshedFiles: List, - @SerialName("error") - val error: String? = null, -) - -fun RefreshedRepo.toRepo(): SnowbirdRepo { - return SnowbirdRepo( - key = repoId, - name = name, - hash = hash, - permissions = if (canWrite) "READ_WRITE" else "READ_ONLY", - createdAt = null, // No createdAt in this response - ) -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/RequestNameDTO.kt b/app/src/main/java/net/opendasharchive/openarchive/db/RequestNameDTO.kt deleted file mode 100644 index 08146df33..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/RequestNameDTO.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.opendasharchive.openarchive.db - -import kotlinx.serialization.Serializable - -@Serializable -data class RequestName(val name: String): SerializableMarker - -@Serializable -data class MembershipRequest(val uri: String): SerializableMarker \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/SerializableMarker.kt b/app/src/main/java/net/opendasharchive/openarchive/db/SerializableMarker.kt deleted file mode 100644 index a71d32d64..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/SerializableMarker.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.opendasharchive.openarchive.db - -import kotlinx.serialization.Serializable - -@Serializable -sealed interface SerializableMarker - -@Serializable -data object EmptyRequest : SerializableMarker \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdError.kt b/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdError.kt deleted file mode 100644 index 354bbc2e0..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdError.kt +++ /dev/null @@ -1,14 +0,0 @@ -package net.opendasharchive.openarchive.db - -sealed class SnowbirdError: SerializableMarker { - data class NetworkError(val code: Int, val message: String) : SnowbirdError() - data class GeneralError(val message: String) : SnowbirdError() - data object TimedOut : SnowbirdError() - - val friendlyMessage: String - get() = when (this) { - is GeneralError -> message - is NetworkError -> message - is TimedOut -> "The current operation took too long." - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdFileItem.kt b/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdFileItem.kt deleted file mode 100644 index da219eee6..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdFileItem.kt +++ /dev/null @@ -1,100 +0,0 @@ -package net.opendasharchive.openarchive.db - -import android.database.sqlite.SQLiteException -import com.orm.SugarRecord -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import kotlinx.serialization.json.JsonIgnoreUnknownKeys - -@Serializable -data class SnowbirdFileList( - var files: List -) : SerializableMarker - -@OptIn(ExperimentalSerializationApi::class) -@Serializable -@JsonIgnoreUnknownKeys -data class FetchFileResponse( - val name: String, - val hash: String, - @SerialName("is_downloaded") - val isDownloaded: Boolean = false, - val size: Long? = null, - @SerialName("created_at") - val createdAt: String? = null, -) - -fun FetchFileResponse.toFile( - groupKey: String, - repoKey: String, -): SnowbirdFileItem { - val file = SnowbirdFileItem( - name = name, - hash = hash, - isDownloaded = isDownloaded, - size = size ?: 0L, - groupKey = groupKey, - repoKey = repoKey, - ) - - return file -} - -@Serializable -data class SnowbirdFileItem( - var hash: String = "", - var name: String = "", - var size: Long = 0L, - @Transient var groupKey: String = "", - @Transient var repoKey: String = "", - @SerialName("is_downloaded") var isDownloaded: Boolean = false -) : SugarRecord(), SerializableMarker { - companion object { - fun clear() { - try { - deleteAll(SnowbirdFileItem::class.java) - } catch (e: SQLiteException) { - // Probably because table doesn't exist. Ignore. - } - } - - fun findBy(groupKey: String, repoKey: String): List { - val whereClause = "GROUP_KEY = ? AND REPO_KEY = ?" - val whereArgs = mutableListOf(groupKey, repoKey) - - val items = find( - SnowbirdFileItem::class.java, - whereClause, - whereArgs.toTypedArray(), - null, - null, - null - ) - - return items - } - - fun findBy(groupKey: String, repoKey: String, name: String): SnowbirdFileItem? { - val whereClause = "GROUP_KEY = ? AND REPO_KEY = ? AND NAME = ?" - val whereArgs = arrayOf(groupKey, repoKey, name) - return find( - SnowbirdFileItem::class.java, - whereClause, - whereArgs, - null, - null, - null - ).firstOrNull() - } - } - - fun saveWith(groupKey: String, repoKey: String) { - this.groupKey = groupKey - this.repoKey = repoKey - save() - } -} - - diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdGroup.kt b/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdGroup.kt deleted file mode 100644 index c12a385bd..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdGroup.kt +++ /dev/null @@ -1,57 +0,0 @@ -package net.opendasharchive.openarchive.db - -import android.database.sqlite.SQLiteException -import com.orm.SugarRecord -import kotlinx.serialization.Serializable - -@Serializable -data class SnowbirdGroupList( - var groups: List -) : SerializableMarker - -@Serializable -data class SnowbirdGroup( - var key: String = "", - var name: String? = null, - var uri: String? = null -) : SugarRecord(), SerializableMarker { - companion object { - fun clear() { - try { - deleteAll(SnowbirdGroup::class.java) - } catch (e: SQLiteException) { - // Probably because table doesn't exist. Ignore. - } - } - - fun getAll(): List { - return findAll(SnowbirdGroup::class.java).asSequence().toList() - } - - fun exists(name: String): Boolean { - val whereClause = "name = ?" - val whereArgs = mutableListOf(name) - - return find( - SnowbirdGroup::class.java, whereClause, whereArgs.toTypedArray(), - null, - null, - null).isNotEmpty() - } - - fun get(key: String): SnowbirdGroup? { - val whereClause = "key = ?" - val whereArgs = mutableListOf(key) - - return find( - SnowbirdGroup::class.java, whereClause, whereArgs.toTypedArray(), - null, - null, - null).firstOrNull() - } - } -} - -fun SnowbirdGroup.shortHash(): String { - return key.take(10) -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdRepo.kt b/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdRepo.kt deleted file mode 100644 index 66c4aa847..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/SnowbirdRepo.kt +++ /dev/null @@ -1,108 +0,0 @@ -package net.opendasharchive.openarchive.db - -import com.orm.SugarRecord -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class SnowbirdRepoList( - var repos: List -) : SerializableMarker - -@Serializable -data class FetchRepoResponse( - val name: String, - val key: String, - @SerialName("can_write") - val canWrite: Boolean, -) - -@Serializable -data class CreateRepoResponse( - val key: String, - val name: String, - @SerialName("can_write") - val canWrite: Boolean? = null, - @SerialName("created_at") - val createdAt: String? = null, -) : SerializableMarker - -fun FetchRepoResponse.toRepo(groupKey: String): SnowbirdRepo { - return SnowbirdRepo( - key = key, - name = name, - groupKey = groupKey, - permissions = if (canWrite) "READ_WRITE" else "READ_ONLY", - ) -} - -fun CreateRepoResponse.toRepo(groupKey: String): SnowbirdRepo { - return SnowbirdRepo( - key = key, - name = name, - groupKey = groupKey, - permissions = if (canWrite == true) "READ_WRITE" else "READ_ONLY", - createdAt = createdAt - ) -} - -@Serializable -data class SnowbirdRepo( - var key: String = "", - var name: String? = null, - var hash: String? = null, - var groupKey: String = "", - var permissions: String = "READ_ONLY", - var createdAt: String? = null -) : SugarRecord(), SerializableMarker { - - companion object { - - fun clear(groupKey: String) { - val whereClause = "GROUP_KEY = ?" - - deleteAll(SnowbirdRepo::class.java, whereClause, groupKey) - } - - fun getAll(): List { - return findAll(SnowbirdRepo::class.java).asSequence().toList() - } - - fun getAllFor(group: SnowbirdGroup?): List { - if (group == null) return emptyList() - - val whereClause = "GROUP_KEY = ?" - val whereArgs = mutableListOf(group.key) - - return find( - SnowbirdRepo::class.java, whereClause, whereArgs.toTypedArray(), - null, - null, - null - ) - } - - fun findByKey(key: String): SnowbirdRepo? { - val whereClause = "KEY = ?" - val whereArgs = arrayOf(key) - return find( - SnowbirdRepo::class.java, - whereClause, - whereArgs, - null, - null, - null - ).firstOrNull() - } - - fun getAllForGroupKey(groupKey: String): List { - val whereClause = "GROUP_KEY = ?" - val whereArgs = arrayOf(groupKey) - return find(SnowbirdRepo::class.java, whereClause, whereArgs, null, null, null) - } - } -} - -fun SnowbirdRepo.shortHash(): String { - return key.take(10) -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionDao.kt new file mode 100644 index 000000000..785658b64 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionDao.kt @@ -0,0 +1,24 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Dao +import androidx.room3.Delete +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy +import androidx.room3.Query +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface SubmissionDao { + @Query("SELECT * FROM submissions WHERE archiveId = :archiveId ORDER BY id DESC") + fun observeByArchive(archiveId: Long): Flow> + + @Query("SELECT * FROM submissions WHERE id = :id") + suspend fun getById(id: Long): SubmissionEntity? + + @Upsert + suspend fun upsert(entity: SubmissionEntity): Long + + @Delete + suspend fun delete(entity: SubmissionEntity) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionEntity.kt new file mode 100644 index 000000000..021463ad0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/SubmissionEntity.kt @@ -0,0 +1,26 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import kotlinx.datetime.LocalDateTime + +@Entity( + tableName = "submissions", + foreignKeys = [ + ForeignKey( + entity = ArchiveEntity::class, + parentColumns = ["id"], + childColumns = ["archiveId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("archiveId")] +) +data class SubmissionEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val archiveId: Long, + val uploadedAt: LocalDateTime?, + val serverUrl: String? +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaAdapter.kt deleted file mode 100644 index f0f964742..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaAdapter.kt +++ /dev/null @@ -1,259 +0,0 @@ -package net.opendasharchive.openarchive.db - -import android.app.Activity -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.RvMediaRowSmallBinding -import net.opendasharchive.openarchive.upload.BroadcastManager -import java.lang.ref.WeakReference - -class UploadMediaAdapter( - activity: Activity?, - mediaItems: List, - private val recyclerView: RecyclerView, - private val checkSelecting: (() -> Unit)? = null, - private val onDeleteClick: (Media, Int) -> Unit, -) : RecyclerView.Adapter() { - - var media: ArrayList = ArrayList(mediaItems) - private set - - var doImageFade = true - - private var mActivity = WeakReference(activity) - - init { - setHasStableIds(true) - } - - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UploadMediaViewHolder { - val binding = - RvMediaRowSmallBinding.inflate(LayoutInflater.from(parent.context), parent, false) - val mvh = UploadMediaViewHolder( - binding = binding, - onDeleteClick = { position -> - deleteItem(position) - } - ) - - mvh.itemView.setOnClickListener { v -> - val position = recyclerView.getChildLayoutPosition(v) - val item = media[position] - - if (item.sStatus == Media.Status.Error) { - onDeleteClick.invoke(item, position) - } else { - if (checkSelecting != null) { - selectView(v) - } - } - } - - if (checkSelecting != null) { - mvh.itemView.setOnLongClickListener { v -> - selectView(v) - - true - } - } - - return mvh - } - - override fun getItemCount(): Int = media.size - - override fun getItemId(position: Int): Long { - return media[position].id - } - - override fun onBindViewHolder(holder: UploadMediaViewHolder, position: Int) { - AppLogger.i("onBindViewHolder called for position $position") - holder.bind(media[position], doImageFade) - } - - override fun onBindViewHolder( - holder: UploadMediaViewHolder, - position: Int, - payloads: MutableList - ) { - if (payloads.isNotEmpty()) { - val payload = payloads[0] - when (payload) { - "progress" -> { - - } - - "full" -> { - holder.bind(media[position], doImageFade) - } - } - } else { - holder.bind(media[position], doImageFade) - } - } - - fun updateItem(mediaId: Long, progress: Int, isUploaded: Boolean = false): Boolean { - val mediaIndex = media.indexOfFirst { it.id == mediaId } - AppLogger.i("updateItem: mediaId=$mediaId idx=$mediaIndex") - if (mediaIndex < 0) return false - - val item = media[mediaIndex] - - if (isUploaded) { - item.status = Media.Status.Uploaded.id - AppLogger.i("Media item $mediaId uploaded, notifying item changed at position $mediaIndex") - notifyItemChanged(mediaIndex, "full") - } else if (progress >= 0) { - item.uploadPercentage = progress - item.status = Media.Status.Uploading.id - notifyItemChanged(mediaIndex, "progress") - } else { - item.status = Media.Status.Queued.id - notifyItemChanged(mediaIndex, "full") - } - - return true - } - - fun removeItem(mediaId: Long): Boolean { - val idx = media.indexOfFirst { it.id == mediaId } - if (idx < 0) return false - - media.removeAt(idx) - - notifyItemRemoved(idx) - - checkSelecting?.invoke() - - return true - } - - fun updateData(newMediaList: List) { - val diffCallback = MediaDiffCallback(this.media, newMediaList) - val diffResult = DiffUtil.calculateDiff(diffCallback) - - this.media.clear() - this.media.addAll(newMediaList) - - diffResult.dispatchUpdatesTo(this) - } - - private fun selectView(view: View) { - val mediaId = view.tag as? Long ?: return - - val m = media.firstOrNull { it.id == mediaId } ?: return - m.selected = !m.selected - m.save() - - notifyItemChanged(media.indexOf(m)) - - checkSelecting?.invoke() - } - - fun onItemMove(oldPos: Int, newPos: Int) { - - val mediaToMov = media.removeAt(oldPos) - media.add(newPos, mediaToMov) - - var priority = media.size - - for (item in media) { - item.priority = priority-- - item.save() - } - - notifyItemMoved(oldPos, newPos) - } - - fun deleteItem(pos: Int) { - if (pos < 0 || pos >= media.size) return - - val item = media[pos] -// var undone = false - -// val snackbar = Snackbar.make(recyclerView, R.string.confirm_remove_media, Snackbar.LENGTH_LONG) -// snackbar.setAction(R.string.undo) { _ -> -// undone = true -// media.add(pos, item) -// -// notifyItemInserted(pos) -// } - -// snackbar.addCallback(object : Snackbar.Callback() { -// override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { -// if (!undone) { - val collection = item.collection - - // Delete collection along with the item, if the collection - // would become empty. - if ((collection?.size ?: 0) < 2) { - collection?.delete() - } else { - item.delete() - } - - -// } -// -// super.onDismissed(transientBottomBar, event) -// } -// }) - - // snackbar.show() - - removeItem(item.id) - - BroadcastManager.postDelete(recyclerView.context, item.id) - } - - - fun deleteSelected(): Boolean { - var hasDeleted = false - - for (item in media.filter { it.selected }) { - val idx = media.indexOf(item) - media.remove(item) - - notifyItemRemoved(idx) - - item.delete() - - hasDeleted = true - } - - checkSelecting?.invoke() - - return hasDeleted - } -} - -class MediaDiffCallback( - private val oldList: List, - private val newList: List -) : DiffUtil.Callback() { - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldList[oldItemPosition].id == newList[newItemPosition].id - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - // Compare only the fields that affect the UI - - val oldItem = oldList[oldItemPosition] - val newItem = newList[newItemPosition] - - return oldItem.status == newItem.status && - oldItem.uploadPercentage == newItem.uploadPercentage && - oldItem.selected == newItem.selected && - oldItem.title == newItem.title - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaViewHolder.kt b/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaViewHolder.kt deleted file mode 100644 index d163a81d0..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaViewHolder.kt +++ /dev/null @@ -1,263 +0,0 @@ -package net.opendasharchive.openarchive.db - -import android.content.res.ColorStateList -import android.text.format.Formatter -import android.widget.ImageView -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.CircularProgressDrawable -import coil3.load -import coil3.request.Disposable -import coil3.request.crossfade -import coil3.request.error -import coil3.request.placeholder -import java.io.InputStream -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.RvMediaRowSmallBinding -import net.opendasharchive.openarchive.util.PdfThumbnailLoader -import net.opendasharchive.openarchive.util.extensions.hide -import net.opendasharchive.openarchive.util.extensions.show -import timber.log.Timber - -class UploadMediaViewHolder( - private val binding: RvMediaRowSmallBinding, - private val onDeleteClick: (Int) -> Unit -) : RecyclerView.ViewHolder(binding.root) { - - private val mContext = itemView.context - private val pdfScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private var pdfThumbnailJob: Job? = null - private var imageRequest: Disposable? = null - - init { - binding.btnDelete.setOnClickListener { - val position = bindingAdapterPosition - if (position != RecyclerView.NO_POSITION) { - onDeleteClick(position) - } - } - } - - fun bind(media: Media? = null, doImageFade: Boolean = true) { - AppLogger.i("Binding media item ${media?.id} with status ${media?.sStatus} and progress ${media?.uploadPercentage}") - itemView.tag = media?.id - - resetImage() - binding.image.tag = media?.id - - binding.image.alpha = - if (media?.sStatus == Media.Status.Uploaded || !doImageFade) 1f else 0.5f - - when { - media?.mimeType?.startsWith("image") == true -> { - val progress = CircularProgressDrawable(mContext).apply { - strokeWidth = 5f - centerRadius = 30f - start() - } - - binding.image.apply { - scaleType = ImageView.ScaleType.CENTER_CROP - setPadding(0, 0, 0, 0) - show() - imageRequest = load(media.fileUri) { - placeholder(progress) - error(R.drawable.ic_image) - crossfade(true) - listener(onError = { _, result -> - AppLogger.w("Failed to load image: ${result.throwable.message}") - showPlaceholderIcon(R.drawable.ic_image) - }) - } - } - } - - media?.mimeType?.startsWith("video") == true -> { - val progress = CircularProgressDrawable(mContext).apply { - strokeWidth = 5f - centerRadius = 30f - start() - } - - binding.image.apply { - scaleType = ImageView.ScaleType.CENTER_CROP - setPadding(0, 0, 0, 0) - show() - imageRequest = load(media.fileUri) { - placeholder(progress) - error(R.drawable.ic_video) - crossfade(true) - listener(onError = { _, result -> - AppLogger.w("Failed to load video thumbnail: ${result.throwable.message}") - showPlaceholderIcon(R.drawable.ic_video) - }) - } - } - } - - media?.mimeType?.startsWith("audio") == true -> { - showPlaceholderIcon(R.drawable.ic_music) - } - - media?.mimeType == "application/pdf" -> { - // Load PDF thumbnail without hiding the title - loadPdfThumbnail(media) - } - - media?.mimeType?.startsWith("application") == true -> { - showPlaceholderIcon(R.drawable.ic_unknown_file) - } - - else -> { - showPlaceholderIcon(R.drawable.ic_unknown_file) - } - } - - if (media != null) { - val file = media.file - - if (file.exists()) { - binding.fileInfo.text = Formatter.formatShortFileSize(mContext, file.length()) - } else { - if (media.contentLength == -1L) { - var iStream: InputStream? = null - try { - iStream = mContext.contentResolver.openInputStream(media.fileUri) - - if (iStream != null) { - media.contentLength = iStream.available().toLong() - media.save() - } - } catch (e: Throwable) { - Timber.e(e) - } finally { - iStream?.close() - } - } - - binding.fileInfo.text = if (media.contentLength > 0) { - Formatter.formatShortFileSize(mContext, media.contentLength) - } else { - media.formattedCreateDate - } - } - - binding.fileInfo.show() - } else { - binding.fileInfo.hide() - } - - val sbTitle = StringBuffer() - - if (media?.sStatus == Media.Status.Error) { - AppLogger.i("Media Item ${media.id} is error") - sbTitle.append(mContext.getString(R.string.error)) - - binding.overlayContainer.show() - binding.error.show() - - if (media.statusMessage.isNotBlank()) { - binding.fileInfo.text = media.statusMessage - binding.fileInfo.show() - } - } else if (media?.sStatus == Media.Status.Queued) { - AppLogger.i("Media Item ${media.id} is queued") - binding.overlayContainer.show() - binding.error.hide() - } else if (media?.sStatus == Media.Status.Uploading) { - AppLogger.i("Media Item ${media.id} is uploading") - binding.overlayContainer.show() - binding.error.hide() - } else { - binding.overlayContainer.hide() - binding.error.hide() - } - - if (sbTitle.isNotEmpty()) sbTitle.append(": ") - sbTitle.append(media?.title) - - // Always show title for PDFs, show for other types if not blank - if (sbTitle.isNotBlank()) { - binding.title.text = sbTitle.toString() - binding.title.show() - } else { - binding.title.hide() - } - } - - private fun resetImage() { - pdfThumbnailJob?.cancel() - pdfThumbnailJob = null - imageRequest?.dispose() - imageRequest = null - binding.image.apply { - setImageDrawable(null) - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - setPadding(0, 0, 0, 0) - scaleType = ImageView.ScaleType.CENTER_CROP - clearColorFilter() - imageTintList = null - } - } - - private fun showPlaceholderIcon(drawableRes: Int) { - val padding = (12 * mContext.resources.displayMetrics.density).toInt() - binding.image.apply { - scaleType = ImageView.ScaleType.FIT_CENTER - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - setPadding(padding, padding, padding, padding) - imageRequest = load(drawableRes) { - crossfade(false) - } - applyPlaceholderTint() // Apply tint so icons are visible in dark mode - show() - } - } - - private fun loadPdfThumbnail(media: Media?) { - if (media == null) { - showPdfPlaceholder() - return - } - - val uri = media.fileUri - val file = media.file - if (uri.scheme == "file" && !file.exists()) { - showPdfPlaceholder() - return - } - - pdfThumbnailJob = PdfThumbnailLoader.loadThumbnail( - imageView = binding.image, - uri = uri, - placeholderRes = R.drawable.ic_pdf, - scope = pdfScope, - maxDimensionPx = 400, - context = mContext, - requestKey = media.id, - onPlaceholder = { showPdfPlaceholder() } - ) - } - - private fun showPdfPlaceholder() { - val padding = (12 * mContext.resources.displayMetrics.density).toInt() - binding.image.apply { - scaleType = ImageView.ScaleType.FIT_CENTER - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - setPadding(padding, padding, padding, padding) - setImageResource(R.drawable.ic_pdf) - applyPlaceholderTint() - show() - } - } - - private fun applyPlaceholderTint() { - val tint = ContextCompat.getColor(mContext, R.color.colorOnSurfaceVariant) - binding.image.imageTintList = ColorStateList.valueOf(tint) - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/VaultDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/VaultDao.kt new file mode 100644 index 000000000..cd019d91d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/VaultDao.kt @@ -0,0 +1,30 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface VaultDao { + @Query("SELECT * FROM vaults WHERE type IN (0, 1)") + fun observeVaults(): Flow> + + @Query("SELECT EXISTS(SELECT 1 FROM vaults WHERE type = 5)") + fun observeHasDwebSpace(): Flow + + @Query("SELECT * FROM vaults WHERE id = :id") + fun observeById(id: Long): Flow + + @Query("SELECT * FROM vaults WHERE id = :id") + suspend fun getById(id: Long): VaultEntity? + + @Query("SELECT * FROM vaults WHERE type IN (0, 1)") + suspend fun getAll(): List + + @Upsert + suspend fun upsert(entity: VaultEntity): Long + + + + @Delete + suspend fun delete(entity: VaultEntity) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/VaultDwebEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/VaultDwebEntity.kt new file mode 100644 index 000000000..3ec729f7c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/VaultDwebEntity.kt @@ -0,0 +1,21 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.PrimaryKey + +@Entity( + tableName = "vault_dweb_metadata", + foreignKeys = [ + ForeignKey( + entity = VaultEntity::class, + parentColumns = ["id"], + childColumns = ["vaultId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class VaultDwebEntity( + @PrimaryKey val vaultId: Long, + val vaultKey: String +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/VaultEntity.kt b/app/src/main/java/net/opendasharchive/openarchive/db/VaultEntity.kt new file mode 100644 index 000000000..8b5d800a5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/VaultEntity.kt @@ -0,0 +1,19 @@ +package net.opendasharchive.openarchive.db + +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import kotlinx.datetime.LocalDateTime +import net.opendasharchive.openarchive.core.domain.VaultType + +@Entity(tableName = "vaults") +data class VaultEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val type: VaultType, + val name: String, + val username: String, + val displayName: String, + val host: String, + val metaData: String, + val licenseUrl: String?, + val createdAt: LocalDateTime +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/WebDAVModel.kt b/app/src/main/java/net/opendasharchive/openarchive/db/WebDAVModel.kt deleted file mode 100644 index 9735db34c..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/db/WebDAVModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -package net.opendasharchive.openarchive.db - -data class WebDAVModel ( - val ocs: Ocs -) - -data class Ocs ( - val meta: Meta, - val data: Data -) - -data class Data ( - val storageLocation: String, - val id: String, - val lastLogin: Long, - val backend: String, - val subadmin: List, - val quota: Quota, - val avatarScope: String, - val email: String, - val emailScope: String, - val additionalMail: List, - val additionalMailScope: List, - val displayname: String, - val displaynameScope: String, - val phone: String, - val phoneScope: String, - val address: String, - val addressScope: String, - val website: String, - val websiteScope: String, - val twitter: String, - val twitterScope: String, - val groups: List, - val language: String, - val locale: String, - val notifyEmail: Any? = null, - val backendCapabilities: BackendCapabilities -) - -data class BackendCapabilities ( - val setDisplayName: Boolean, - val setPassword: Boolean -) - -data class Quota ( - val free: Long, - val used: Long, - val total: Long, - val relative: Double, - val quota: Long -) - -data class Meta ( - val status: String, - val statuscode: Long, - val message: String, - val totalitems: String, - val itemsperpage: String -) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Collection.kt b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/Collection.kt similarity index 71% rename from app/src/main/java/net/opendasharchive/openarchive/db/Collection.kt rename to app/src/main/java/net/opendasharchive/openarchive/db/sugar/Collection.kt index 22be6dc6a..70761601a 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/Collection.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/Collection.kt @@ -1,7 +1,7 @@ -package net.opendasharchive.openarchive.db +package net.opendasharchive.openarchive.db.sugar import com.orm.SugarRecord -import java.util.* +import java.util.Date data class Collection( var projectId: Long? = null, @@ -16,6 +16,11 @@ data class Collection( null, "id ASC", null) } + fun getByProjectRecentFirst(projectId: Long): List { + return find(Collection::class.java, "project_id = ?", arrayOf(projectId.toString()), + null, "id DESC", null) + } + fun get(collectionId: Long?): Collection? { @Suppress("NAME_SHADOWING") val collectionId = collectionId ?: return null @@ -41,4 +46,10 @@ data class Collection( return super.delete() } + + fun copyWithId(): Collection { + val copiedCollection = this.copy() + copiedCollection.id = null + return copiedCollection + } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/sugar/DatabaseSeeder.kt b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/DatabaseSeeder.kt new file mode 100644 index 000000000..536a62ad2 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/DatabaseSeeder.kt @@ -0,0 +1,37 @@ +package net.opendasharchive.openarchive.db.sugar + + +import android.content.Context +import com.orm.SugarRecord +import net.opendasharchive.openarchive.BuildConfig +import net.opendasharchive.openarchive.db.sugar.Space + +object DatabaseSeeder { + + /** + * Clears all tables and saves initial dummy data in a transaction. + * This is useful for resetting the database in debug/testing builds. + */ + fun seed(context: Context) { + // Run this only for debug builds or specific test environments + if (BuildConfig.DEBUG) { + // Optional: Clear existing data before seeding + clearDatabase() + + // Save all data collections in a single transaction for efficiency + // Combine all lists into one collection for saveInTx + val allEntities = dummySpaceList + dummyProjectList// + dummyCollectionList + dummyMediaList + SugarRecord.saveInTx(allEntities) + } + } + + /** + * Deletes all records from all relevant tables. + */ + private fun clearDatabase() { + SugarRecord.deleteAll(Media::class.java) + SugarRecord.deleteAll(Collection::class.java) + SugarRecord.deleteAll(Project::class.java) + SugarRecord.deleteAll(Space::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/sugar/DummyData.kt b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/DummyData.kt new file mode 100644 index 000000000..3f86ebb13 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/DummyData.kt @@ -0,0 +1,433 @@ +package net.opendasharchive.openarchive.db.sugar + + +import net.opendasharchive.openarchive.db.sugar.Space +import java.util.* // Needed for Date objects + +// --- 1. Spaces (Container) --- + +val dummySpaceList = listOf( + Space( + type = Space.Type.WEBDAV.id, + username = "test01", + password = "test01", + name = "Elelan Server", + host = "https://nx27277.your-storageshare.de/remote.php/webdav/", + licenseUrl = "https://creativecommons.org/licenses/by-sa/4.0/" + ).apply { id = 1L }, + Space( + type = Space.Type.INTERNET_ARCHIVE.id, + username = "test02", + password = "test02", + name = "IA Server", + host = "https://nx27277.your-storageshare.de/remote.php/webdav/", + licenseUrl = "https://creativecommons.org/licenses/by-sa/4.0/" + ).apply { id = 2L }, + Space( + type = Space.Type.RAVEN.id, + username = "test03", + password = "test03", + name = "SaveDweb", + host = "https://nx27277.your-storageshare.de/remote.php/webdav/", + licenseUrl = "https://creativecommons.org/licenses/by-sa/4.0/" + ).apply { id = 3L }, + Space( + type = Space.Type.WEBDAV.id, + username = "test04", + password = "test04", + name = "NextCloud", + host = "https://nx27277.your-storageshare.de/remote.php/webdav/", + licenseUrl = "https://creativecommons.org/licenses/by-sa/4.0/" + ).apply { id = 4L }, +) + +// ---------------------------------------- + +// --- 2. Projects (Folder) --- +// Linked to dummySpaceList: 1L, 2L, 4L + +val dummyProjectList = listOf( + // Linked to Space 1L (WEBDAV) + Project( + spaceId = 1L, + description = "Recent Vacation" + ).apply { id = 1L; created = Date(System.currentTimeMillis() - 86400000) }, // 1 day ago + Project( + spaceId = 1L, + description = "Archived Content", + archived = true // For testing archived projects + ).apply { id = 2L; created = Date(System.currentTimeMillis() - 2 * 86400000) }, // 2 days ago + Project( + spaceId = 1L, + description = "Work Documents" + ).apply { id = 5L; created = Date(System.currentTimeMillis() - 5 * 86400000) }, // 5 days ago + Project( + spaceId = 1L, + description = "Family Photos" + ).apply { id = 6L; created = Date(System.currentTimeMillis() - 7 * 86400000) }, // 7 days ago + + // Linked to Space 2L (Internet Archive) + Project( + spaceId = 2L, + description = "Archive Test" + ).apply { id = 3L; created = Date(System.currentTimeMillis() - 3 * 86400000) }, // 3 days ago + + // Linked to Space 4L (NextCloud) + Project( + spaceId = 4L, + description = "Media Uploads" + ).apply { id = 4L; created = Date() }, // Now +) + +// ---------------------------------------- + +// --- 3. Collections (Upload Batches) --- +// Linked to dummyProjectList: 1L, 2L, 3L, 4L. +// Includes 'open' (uploadDate = null) and 'uploaded' collections. + +val dummyCollectionList = listOf( + // Collections for Project 1 (Recent Vacation) + Collection( + projectId = 1L, + uploadDate = null, // Open Collection (has uploading media) + ).apply { id = 1L }, + Collection( + projectId = 1L, + uploadDate = Date(System.currentTimeMillis() - 3600000), // Uploaded an hour ago + serverUrl = "server/url/p1c2" + ).apply { id = 2L }, + + // Collections for Project 2 (Archived Content) + Collection( + projectId = 2L, + uploadDate = Date(System.currentTimeMillis() - 7200000), // Uploaded two hours ago + serverUrl = "server/url/p2c3" + ).apply { id = 3L }, + + // Collections for Project 3 (Public Archive Test) + Collection( + projectId = 3L, + uploadDate = null, // Open Collection (has error media) + ).apply { id = 4L }, + Collection( + projectId = 3L, + uploadDate = Date(System.currentTimeMillis() - 10800000), // Uploaded three hours ago + serverUrl = "server/url/p3c5" + ).apply { id = 5L }, + + // Collections for Project 4 (Mixed Media Uploads) + Collection( + projectId = 4L, + uploadDate = null, // Open Collection (has queued media) + ).apply { id = 6L }, + + // Collections for Project 5 (Work Documents) - 4 collections with multiple media + Collection( + projectId = 5L, + uploadDate = null, // Open Collection (has uploading media) + ).apply { id = 7L }, + Collection( + projectId = 5L, + uploadDate = Date(System.currentTimeMillis() - 14400000), // Uploaded 4 hours ago + serverUrl = "server/url/p5c8" + ).apply { id = 8L }, + Collection( + projectId = 5L, + uploadDate = Date(System.currentTimeMillis() - 21600000), // Uploaded 6 hours ago + serverUrl = "server/url/p5c9" + ).apply { id = 9L }, + Collection( + projectId = 5L, + uploadDate = Date(System.currentTimeMillis() - 28800000), // Uploaded 8 hours ago + serverUrl = "server/url/p5c10" + ).apply { id = 10L }, + + // Collections for Project 6 (Family Photos) + Collection( + projectId = 6L, + uploadDate = null, // Open Collection + ).apply { id = 11L }, + Collection( + projectId = 6L, + uploadDate = Date(System.currentTimeMillis() - 172800000), // Uploaded 2 days ago + serverUrl = "server/url/p6c12" + ).apply { id = 12L }, +) + +// ---------------------------------------- + +// --- 4. Media (Files) --- +// Linked to dummyProjectList and dummyCollectionList + +val dummyMediaList = listOf( + // Media for Project 1 (Vacation), Open Collection 1 + Media( + projectId = 1L, + collectionId = 1L, + mimeType = "image/jpeg", + title = "IMG_001_Uploading.jpg", + status = Media.Status.Uploading.id, // Actively uploading + createDate = Date(System.currentTimeMillis() - 10000), + contentLength = 5_000_000, // 5MB + progress = 2_500_000 // 50% uploaded + ).apply { id = 1L }, + Media( + projectId = 1L, + collectionId = 1L, + mimeType = "video/mp4", + title = "VID_002_New.mp4", + status = Media.Status.New.id, // Ready to be queued + createDate = Date(System.currentTimeMillis() - 20000), + contentLength = 50_000_000 // 50MB + ).apply { id = 2L }, + + // Media for Project 1, Uploaded Collection 2 + Media( + projectId = 1L, + collectionId = 2L, + mimeType = "image/png", + title = "Uploaded_3_Completed.png", + status = Media.Status.Uploaded.id, // Uploaded + uploadDate = Date(System.currentTimeMillis() - 3600000), + contentLength = 1_000_000 + ).apply { id = 3L }, + + // Media for Project 3 (Public Archive), Open Collection 4 + Media( + projectId = 3L, + collectionId = 4L, + mimeType = "audio/mp3", + title = "Error_4_FailedUpload.mp3", + status = Media.Status.Error.id, // Failed upload + statusMessage = "Server connection lost.", + contentLength = 8_000_000 + ).apply { id = 4L }, + + // Media for Project 3, Uploaded Collection 5 + Media( + projectId = 3L, + collectionId = 5L, + mimeType = "video/mov", + title = "Uploaded_5_PublicVideo.mov", + status = Media.Status.Uploaded.id, // Uploaded + uploadDate = Date(System.currentTimeMillis() - 10800000), + contentLength = 30_000_000 + ).apply { id = 5L }, + + // Media for Project 4, Open Collection 6 + Media( + projectId = 4L, + collectionId = 6L, + mimeType = "image/gif", + title = "Queued_6_Animation.gif", + status = Media.Status.Queued.id, // Waiting for upload + contentLength = 2_000_000 + ).apply { id = 6L }, + + // ======================================== + // Media for Project 5 (Work Documents) - Collection 7 (Open, has uploading items) + // ======================================== + Media( + projectId = 5L, + collectionId = 7L, + mimeType = "application/pdf", + title = "Report_Q4_2024.pdf", + status = Media.Status.Uploading.id, + createDate = Date(System.currentTimeMillis() - 5000), + contentLength = 12_000_000, // 12MB + progress = 8_000_000 // 67% uploaded + ).apply { id = 7L }, + Media( + projectId = 5L, + collectionId = 7L, + mimeType = "application/vnd.ms-excel", + title = "Budget_Analysis.xlsx", + status = Media.Status.New.id, + createDate = Date(System.currentTimeMillis() - 15000), + contentLength = 3_500_000 // 3.5MB + ).apply { id = 8L }, + Media( + projectId = 5L, + collectionId = 7L, + mimeType = "application/vnd.ms-powerpoint", + title = "Presentation_Slides.pptx", + status = Media.Status.Queued.id, + createDate = Date(System.currentTimeMillis() - 25000), + contentLength = 18_000_000 // 18MB + ).apply { id = 9L }, + Media( + projectId = 5L, + collectionId = 7L, + mimeType = "image/jpeg", + title = "Chart_Screenshot.jpg", + status = Media.Status.New.id, + createDate = Date(System.currentTimeMillis() - 30000), + contentLength = 2_200_000 // 2.2MB + ).apply { id = 10L }, + + // Media for Project 5 - Collection 8 (Uploaded 4 hours ago) + Media( + projectId = 5L, + collectionId = 8L, + mimeType = "application/pdf", + title = "Meeting_Notes_Jan.pdf", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 14400000), + contentLength = 1_800_000 + ).apply { id = 11L }, + Media( + projectId = 5L, + collectionId = 8L, + mimeType = "application/msword", + title = "Project_Proposal.docx", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 14400000), + contentLength = 4_200_000 + ).apply { id = 12L }, + Media( + projectId = 5L, + collectionId = 8L, + mimeType = "image/png", + title = "Logo_Design_v2.png", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 14400000), + contentLength = 850_000 + ).apply { id = 13L }, + + // Media for Project 5 - Collection 9 (Uploaded 6 hours ago) + Media( + projectId = 5L, + collectionId = 9L, + mimeType = "video/mp4", + title = "Training_Video_Part1.mp4", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 21600000), + contentLength = 85_000_000 // 85MB + ).apply { id = 14L }, + Media( + projectId = 5L, + collectionId = 9L, + mimeType = "video/mp4", + title = "Training_Video_Part2.mp4", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 21600000), + contentLength = 92_000_000 // 92MB + ).apply { id = 15L }, + Media( + projectId = 5L, + collectionId = 9L, + mimeType = "application/pdf", + title = "Training_Materials.pdf", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 21600000), + contentLength = 6_500_000 + ).apply { id = 16L }, + Media( + projectId = 5L, + collectionId = 9L, + mimeType = "image/jpeg", + title = "Certificate_Template.jpg", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 21600000), + contentLength = 1_200_000 + ).apply { id = 17L }, + + // Media for Project 5 - Collection 10 (Uploaded 8 hours ago) + Media( + projectId = 5L, + collectionId = 10L, + mimeType = "application/zip", + title = "Source_Code_Backup.zip", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 28800000), + contentLength = 145_000_000 // 145MB + ).apply { id = 18L }, + Media( + projectId = 5L, + collectionId = 10L, + mimeType = "text/plain", + title = "README.txt", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 28800000), + contentLength = 15_000 + ).apply { id = 19L }, + Media( + projectId = 5L, + collectionId = 10L, + mimeType = "application/json", + title = "config_backup.json", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 28800000), + contentLength = 45_000 + ).apply { id = 20L }, + + // ======================================== + // Media for Project 6 (Family Photos) - Collection 11 (Open) + // ======================================== + Media( + projectId = 6L, + collectionId = 11L, + mimeType = "image/jpeg", + title = "Birthday_Party_2024_01.jpg", + status = Media.Status.Uploading.id, + createDate = Date(System.currentTimeMillis() - 8000), + contentLength = 4_500_000, + progress = 3_000_000 // 67% uploaded + ).apply { id = 21L }, + Media( + projectId = 6L, + collectionId = 11L, + mimeType = "image/jpeg", + title = "Birthday_Party_2024_02.jpg", + status = Media.Status.Queued.id, + createDate = Date(System.currentTimeMillis() - 12000), + contentLength = 4_800_000 + ).apply { id = 22L }, + Media( + projectId = 6L, + collectionId = 11L, + mimeType = "video/mp4", + title = "Birthday_Cake_Candles.mp4", + status = Media.Status.New.id, + createDate = Date(System.currentTimeMillis() - 18000), + contentLength = 35_000_000 + ).apply { id = 23L }, + + // Media for Project 6 - Collection 12 (Uploaded 2 days ago) + Media( + projectId = 6L, + collectionId = 12L, + mimeType = "image/jpeg", + title = "Beach_Sunset_01.jpg", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 172800000), + contentLength = 6_200_000 + ).apply { id = 24L }, + Media( + projectId = 6L, + collectionId = 12L, + mimeType = "image/jpeg", + title = "Beach_Sunset_02.jpg", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 172800000), + contentLength = 5_800_000 + ).apply { id = 25L }, + Media( + projectId = 6L, + collectionId = 12L, + mimeType = "image/jpeg", + title = "Kids_Playing_Sand.jpg", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 172800000), + contentLength = 4_100_000 + ).apply { id = 26L }, + Media( + projectId = 6L, + collectionId = 12L, + mimeType = "video/mov", + title = "Beach_Waves_Timelapse.mov", + status = Media.Status.Uploaded.id, + uploadDate = Date(System.currentTimeMillis() - 172800000), + contentLength = 78_000_000 + ).apply { id = 27L }, +) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Media.kt b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/Media.kt similarity index 93% rename from app/src/main/java/net/opendasharchive/openarchive/db/Media.kt rename to app/src/main/java/net/opendasharchive/openarchive/db/sugar/Media.kt index 411813425..96fb05262 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/Media.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/Media.kt @@ -1,4 +1,4 @@ -package net.opendasharchive.openarchive.db +package net.opendasharchive.openarchive.db.sugar import android.net.Uri import androidx.core.net.toFile @@ -9,6 +9,7 @@ import java.io.File import java.text.SimpleDateFormat import java.util.* import androidx.core.net.toUri +import net.opendasharchive.openarchive.db.sugar.Space data class Media( var originalFilePath: String = "", @@ -140,4 +141,11 @@ data class Media( @Transient var uploadPercentage: Int? = null + + + fun copyWithId(): Media { + val copiedMedia = this.copy() + copiedMedia.id = this.id + return copiedMedia + } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Project.kt b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/Project.kt similarity index 86% rename from app/src/main/java/net/opendasharchive/openarchive/db/Project.kt rename to app/src/main/java/net/opendasharchive/openarchive/db/sugar/Project.kt index f937da949..44edc2ba9 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/Project.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/Project.kt @@ -1,6 +1,7 @@ -package net.opendasharchive.openarchive.db +package net.opendasharchive.openarchive.db.sugar import com.orm.SugarRecord +import net.opendasharchive.openarchive.db.sugar.Space import java.util.Date /** @@ -20,7 +21,7 @@ data class Project( var created: Date? = null, var spaceId: Long? = null, private var archived: Boolean = false, - private var openCollectionId: Long = -1, + var openCollectionId: Long = -1, var licenseUrl: String? = null ) : SugarRecord() { @@ -37,7 +38,7 @@ data class Project( var isArchived: Boolean get() = archived set(value) { - archived = value + archived = value // When the space has a license, that needs to be applied when de-archived. // Otherwise the wrong license setting might get transmitted to the server. @@ -77,6 +78,12 @@ data class Project( return super.delete() } + fun copyWithId(): Project { + val copiedProject = this.copy() + copiedProject.id = this.id + return copiedProject + } + val space: Space? get() = findById(Space::class.java, spaceId) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/Space.kt similarity index 70% rename from app/src/main/java/net/opendasharchive/openarchive/db/Space.kt rename to app/src/main/java/net/opendasharchive/openarchive/db/sugar/Space.kt index 8be81f971..966083efa 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/sugar/Space.kt @@ -1,19 +1,17 @@ -package net.opendasharchive.openarchive.db +package net.opendasharchive.openarchive.db.sugar import android.content.Context -import android.content.Intent import android.graphics.drawable.Drawable import android.widget.ImageView -import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.core.content.ContextCompat import com.orm.SugarRecord +import kotlinx.serialization.Serializable import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity -import net.opendasharchive.openarchive.services.internetarchive.IaConduit +import net.opendasharchive.openarchive.services.internetarchive.data.IaConduit import net.opendasharchive.openarchive.util.Prefs import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -43,37 +41,35 @@ data class Space( private var licenseUrl: String? = null, // private var chunking: Boolean? = null ) : SugarRecord() { - constructor(type: Type) : this() { tType = type when (type) { Type.WEBDAV -> {} Type.INTERNET_ARCHIVE -> { - name = IaConduit.NAME - host = IaConduit.ARCHIVE_API_ENDPOINT + name = IaConduit.Companion.NAME + host = IaConduit.Companion.ARCHIVE_API_ENDPOINT } Type.RAVEN -> "Raven" } } + @Serializable enum class Type(val id: Int, val friendlyName: String) { WEBDAV(0, "Private Server"), - INTERNET_ARCHIVE(1, IaConduit.NAME), + INTERNET_ARCHIVE(1, IaConduit.Companion.NAME), RAVEN(5, "DWeb Storage"), } - enum class IconStyle { - SOLID, OUTLINE - } - companion object { - fun getAll(): Iterator { - return findAll(Space::class.java) - } + fun getAll(): Iterator = findAll(Space::class.java) - fun get(type: Type, host: String? = null, username: String? = null): List { + fun get( + type: Type, + host: String? = null, + username: String? = null, + ): List { var whereClause = "type = ?" val whereArgs = mutableListOf(type.id.toString()) @@ -88,14 +84,20 @@ data class Space( } return find( - Space::class.java, whereClause, whereArgs.toTypedArray(), - null, null, null + Space::class.java, + whereClause, + whereArgs.toTypedArray(), + null, + null, + null, ) } - fun has(type: Type, host: String? = null, username: String? = null): Boolean { - return get(type, host, username).isNotEmpty() - } + fun has( + type: Type, + host: String? = null, + username: String? = null, + ): Boolean = get(type, host, username).isNotEmpty() var current: Space? get() { @@ -107,18 +109,7 @@ data class Space( Prefs.currentSpaceId = value?.id ?: -1 } - fun get(id: Long): Space? { - return findById(Space::class.java, id) - } - - fun navigate(activity: AppCompatActivity) { - if (getAll().hasNext()) { - activity.finish() - } else { - activity.finishAffinity() - activity.startActivity(Intent(activity, SpaceSetupActivity::class.java)) - } - } + fun get(id: Long): Space? = findById(Space::class.java, id) } val friendlyName: String @@ -161,24 +152,26 @@ data class Space( // } val projects: List - get() = find( - Project::class.java, - "space_id = ? AND NOT archived", - arrayOf(id.toString()), - null, - "id DESC", - null - ) + get() = + find( + Project::class.java, + "space_id = ? AND NOT archived", + arrayOf(id.toString()), + null, + "id DESC", + null, + ) val archivedProjects: List - get() = find( - Project::class.java, - "space_id = ? AND archived", - arrayOf(id.toString()), - null, - "id DESC", - null - ) + get() = + find( + Project::class.java, + "space_id = ? AND archived", + arrayOf(id.toString()), + null, + "id DESC", + null, + ) fun hasProject(description: String): Boolean { // Cannot use `count` from Kotlin due to strange in method signature. @@ -186,34 +179,32 @@ data class Space( Project::class.java, "space_id = ? AND description = ?", id.toString(), - description + description, ).isNotEmpty() } - fun getAvatar(context: Context): Drawable? { - - - return when (tType) { + fun getAvatar(context: Context): Drawable? = + when (tType) { Type.WEBDAV -> ContextCompat.getDrawable(context, R.drawable.ic_private_server) - Type.INTERNET_ARCHIVE -> ContextCompat.getDrawable(context, R.drawable.ic_internet_archive) + Type.INTERNET_ARCHIVE -> + ContextCompat.getDrawable( + context, + R.drawable.ic_internet_archive, + ) Type.RAVEN -> ContextCompat.getDrawable(context, R.drawable.ic_dweb) - } - } @Composable - fun getAvatar(): Painter { - - return when (tType) { + fun getAvatar(): Painter = + when (tType) { Type.WEBDAV -> painterResource(R.drawable.ic_space_private_server) Type.INTERNET_ARCHIVE -> painterResource(R.drawable.ic_space_interent_archive) Type.RAVEN -> painterResource(R.drawable.ic_space_dweb) } - } fun setAvatar(view: ImageView) { when (tType) { @@ -223,7 +214,6 @@ data class Space( else -> { view.setImageDrawable(getAvatar(view.context)) - } } } @@ -235,4 +225,10 @@ data class Space( return super.delete() } -} \ No newline at end of file + + fun copyWithId(): Space { + val copiedSpace = this.copy() + copiedSpace.id = this.id + return copiedSpace + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/extensions/ActivityExtension.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/ActivityExtension.kt deleted file mode 100644 index 9a5985d0a..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/extensions/ActivityExtension.kt +++ /dev/null @@ -1,23 +0,0 @@ -package net.opendasharchive.openarchive.extensions - -import android.app.Activity -import androidx.activity.OnBackPressedCallback -import androidx.fragment.app.FragmentActivity - -fun Activity.onBackButtonPressed(callback: () -> Boolean) { - (this as? FragmentActivity)?.onBackPressedDispatcher?.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - // If callback() returns false, we let the system handle back - // If callback() returns true, we override it (and do nothing else) - if (!callback()) { - remove() - this@onBackButtonPressed - .onBackPressedDispatcher - .onBackPressed() - } - } - } - ) -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/extensions/ApplicationExtensions.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/ApplicationExtensions.kt deleted file mode 100644 index b61c1b296..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/extensions/ApplicationExtensions.kt +++ /dev/null @@ -1,22 +0,0 @@ -package net.opendasharchive.openarchive.extensions - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.fragment.app.Fragment -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.ViewModel -import org.koin.android.ext.android.getKoin -import org.koin.core.parameter.parametersOf -import org.koin.androidx.viewmodel.ext.android.viewModel - -inline fun Application.getViewModel(vararg parameters: Any): T { - return getKoin().get { parametersOf(*parameters) } -} - -inline fun Fragment.androidViewModel(): Lazy { - return viewModel { parametersOf(requireActivity().application) } -} - -inline fun ComponentActivity.androidViewModel(): Lazy { - return viewModel { parametersOf(application) } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/extensions/BottomSheetExtensions.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/BottomSheetExtensions.kt deleted file mode 100644 index 6a9f4f18e..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/extensions/BottomSheetExtensions.kt +++ /dev/null @@ -1,57 +0,0 @@ -package net.opendasharchive.openarchive.extensions - -import android.annotation.SuppressLint -import android.content.res.Resources -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.TextView -import androidx.annotation.IdRes -import androidx.annotation.LayoutRes -import androidx.fragment.app.Fragment -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import net.opendasharchive.openarchive.R - -fun Fragment.showBottomSheetDialog( - @LayoutRes layout: Int, - @IdRes textViewToSet: Int? = null, - textToSet: String? = null, - fullScreen: Boolean = true, - expand: Boolean = true -) { - val dialog = BottomSheetDialog(context!!) - dialog.setOnShowListener { - val bottomSheet: FrameLayout = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return@setOnShowListener - val bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) - if (fullScreen && bottomSheet.layoutParams != null) { showFullScreenBottomSheet(bottomSheet) } - - if (!expand) return@setOnShowListener - - bottomSheet.setBackgroundResource(android.R.color.transparent) - expandBottomSheet(bottomSheetBehavior) - } - - @SuppressLint("InflateParams") // dialog does not need a root view here - val sheetView = layoutInflater.inflate(layout, null) - textViewToSet?.also { - sheetView.findViewById(it).text = textToSet - } - -// sheetView.findViewById(R.id.closeButton)?.setOnClickListener { -// dialog.dismiss() -// } - - dialog.setContentView(sheetView) - dialog.show() -} - -private fun showFullScreenBottomSheet(bottomSheet: FrameLayout) { - val layoutParams = bottomSheet.layoutParams - layoutParams.height = Resources.getSystem().displayMetrics.heightPixels - bottomSheet.layoutParams = layoutParams -} - -private fun expandBottomSheet(bottomSheetBehavior: BottomSheetBehavior) { - bottomSheetBehavior.skipCollapsed = true - bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/extensions/DrawableExtensions.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/DrawableExtensions.kt deleted file mode 100644 index f213f7780..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/extensions/DrawableExtensions.kt +++ /dev/null @@ -1,49 +0,0 @@ -package net.opendasharchive.openarchive.extensions - -import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.graphics.drawable.LayerDrawable -import android.os.Build -import android.util.TypedValue -import androidx.core.graphics.drawable.toBitmap -import kotlin.math.max -import kotlin.math.roundToInt - -fun Drawable.scaled(biggerSideDipLength: Int, context: Context): Drawable { - val length = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, - biggerSideDipLength.toFloat(), context.resources.displayMetrics) - - return scaled(length.toDouble() / max(intrinsicWidth, intrinsicHeight), context) -} - -fun Drawable.scaled(factor: Double, context: Context): Drawable { - if (factor == 1.0) return this - - return scaled((intrinsicWidth * factor).roundToInt(), - (intrinsicHeight * factor).roundToInt(), - context) -} - -fun Drawable.scaled(width: Int, height: Int, context: Context): Drawable { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - LayerDrawable(arrayOf(this)).also { - it.setLayerSize(0, width, height) - } - } - else { - BitmapDrawable(context.resources, toBitmap(width, height)) - } -} - -fun Drawable.tint(color: Int): Drawable { - colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) - - return this -} - -fun Drawable.clone(): Drawable? { - return constantState?.newDrawable()?.mutate() -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/extensions/StringExtensions.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/StringExtensions.kt index 1b367e5c8..2ebce7cd1 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/extensions/StringExtensions.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/extensions/StringExtensions.kt @@ -6,13 +6,14 @@ import android.util.Patterns import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter -import timber.log.Timber +import net.opendasharchive.openarchive.core.logger.AppLogger import java.io.File import java.io.InputStream import java.net.URI import java.net.URLDecoder import java.net.URLEncoder import java.nio.charset.StandardCharsets +import androidx.core.graphics.createBitmap /** * Generates a QR code bitmap from a given string. @@ -37,12 +38,28 @@ fun String.asQRCode(size: Int = 512, quietZone: Int = 4): Bitmap { } } -fun String.createInputStream(): InputStream? { - return try { - File(this).inputStream() - } catch (e: Exception) { - Timber.e(e, "Failed to create InputStream from path: $this") - null +fun String.asQRCode( + size: Int = 512, + onColor: Int = Color.BLACK, + offColor: Int = Color.WHITE, + quietZone: Int = 4 +): Bitmap { + val hints = hashMapOf().apply { + put(EncodeHintType.MARGIN, quietZone) + } + val bits = QRCodeWriter().encode(this, BarcodeFormat.QR_CODE, size, size, hints) + + // Optimized: Use setPixels (plural) to push the whole array at once + val pixels = IntArray(size * size) + for (y in 0 until size) { + val offset = y * size + for (x in 0 until size) { + pixels[offset + x] = if (bits[x, y]) onColor else offColor + } + } + + return createBitmap(size, size).apply { + setPixels(pixels, 0, size, 0, 0, size, size) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/extensions/ThrowableExceptions.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/ThrowableExceptions.kt index 19d480a27..3f904a93d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/extensions/ThrowableExceptions.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/extensions/ThrowableExceptions.kt @@ -1,21 +1,21 @@ package net.opendasharchive.openarchive.extensions -import net.opendasharchive.openarchive.db.SnowbirdError +import net.opendasharchive.openarchive.core.domain.DomainError import net.opendasharchive.openarchive.services.snowbird.service.HttpLikeException import retrofit2.HttpException import java.net.SocketTimeoutException -fun Throwable.toSnowbirdError(): SnowbirdError { +fun Throwable.toDomainError(): DomainError { return when (this) { - is HttpLikeException -> SnowbirdError.NetworkError( + is HttpLikeException -> DomainError.Network( code = code, message = message ) - is HttpException -> SnowbirdError.NetworkError( + is HttpException -> DomainError.Server( code = response()?.code() ?: 0, message = message() ?: "HTTP Error" ) - is SocketTimeoutException -> SnowbirdError.TimedOut - else -> SnowbirdError.GeneralError(message ?: "Unknown error occurred") + is SocketTimeoutException -> DomainError.Timeout() + else -> DomainError.Unknown(message ?: "Unknown error occurred") } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/extensions/UriExtensions.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/UriExtensions.kt index f3e173919..088d74aaa 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/extensions/UriExtensions.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/extensions/UriExtensions.kt @@ -5,10 +5,6 @@ import android.net.Uri import android.provider.OpenableColumns import java.io.InputStream -fun Uri.createInputStream(applicationContext: Context): InputStream? { - return applicationContext.contentResolver.openInputStream(this) -} - fun Uri.getFilename(applicationContext: Context): String? { var result: String? = null if (this.scheme == "content") { diff --git a/app/src/main/java/net/opendasharchive/openarchive/extensions/ViewExtension.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/ViewExtension.kt deleted file mode 100644 index fe3594283..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/extensions/ViewExtension.kt +++ /dev/null @@ -1,103 +0,0 @@ -package net.opendasharchive.openarchive.extensions - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.content.Context -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import com.google.android.material.snackbar.Snackbar - -private object ViewHelper { - const val ANIMATION_DURATION: Long = 250 // ms - - fun hide(view: View, visibility: Int, animate: Boolean) { - if (animate && view.isVisible) { - view.animate() - .alpha(0f) - .setDuration(ANIMATION_DURATION) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - view.visibility = visibility - view.alpha = 1f - - view.animate().setListener(null) - } - }) - } - else { - view.visibility = visibility - } - } -} - - -fun View.show(animate: Boolean = false) { - if (isVisible) return - - if (animate) { - alpha = 0f - visibility = View.VISIBLE - - animate().alpha(1f).duration = ViewHelper.ANIMATION_DURATION - } - else { - visibility = View.VISIBLE - } -} - -fun View.hide(animate: Boolean = false) { - ViewHelper.hide(this, View.GONE, animate) -} - -fun View.cloak(animate: Boolean = false) { - ViewHelper.hide(this, View.INVISIBLE, animate) -} - -fun View.toggle(state: Boolean? = null, animate: Boolean = false) { - if (state ?: !isVisible) { - show(animate) - } - else { - hide(animate) - } -} - -fun View.disableAnimation(around: () -> Unit) { - val p = parent as? ViewGroup - - val original = p?.layoutTransition - p?.layoutTransition = null - - around() - - p?.layoutTransition = original -} - -val View.isVisible: Boolean - get() = visibility == View.VISIBLE - -fun View.makeSnackBar(message: CharSequence, duration: Int = Snackbar.LENGTH_INDEFINITE): Snackbar { - return Snackbar.make(this, message, duration) -} -fun View.getMeasurments(): Pair { - measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) - val width = measuredWidth - val height = measuredHeight - return width to height -} - -fun View.propagateClickToParent() { - var parent = this.parent as? View - while (parent != null && !parent.isClickable) { - parent = parent.parent as? View - } - parent?.performClick() -} - -fun View.showKeyboard() { - if (requestFocus()) { - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt deleted file mode 100644 index 2859dc800..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt +++ /dev/null @@ -1,167 +0,0 @@ -package net.opendasharchive.openarchive.features.core - -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.ui.platform.ComposeView -import androidx.lifecycle.lifecycleScope -import com.google.android.material.appbar.MaterialToolbar -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.analytics.api.AnalyticsManager -import net.opendasharchive.openarchive.analytics.api.session.SessionTracker -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.features.core.dialog.DialogHost -import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager -import net.opendasharchive.openarchive.util.Prefs -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel - -abstract class BaseActivity : AppCompatActivity() { - - val dialogManager: DialogStateManager by viewModel() - - // Inject analytics dependencies - protected val analyticsManager: AnalyticsManager by inject() - protected val sessionTracker: SessionTracker by inject() - - // Screen tracking variables - private var screenStartTime: Long = 0 - private var previousScreen: String = "" - - protected open fun getScreenName(): String { - return this::class.simpleName ?: "UnknownActivity" - } - - companion object { - const val EXTRA_DATA_SPACE = "space" - } - - override fun setContentView(layoutResID: Int) { - super.setContentView(layoutResID) - ensureComposeDialogHost() - } - - override fun setContentView(view: View?) { - super.setContentView(view) - ensureComposeDialogHost() - } - - fun ensureComposeDialogHost() { - // Get root view of the window - val rootView = findViewById(android.R.id.content) - - // Add ComposeView if not already present - if (rootView.findViewById(R.id.compose_dialog_host) == null) { - ComposeView(this).apply { - id = R.id.compose_dialog_host - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - - rootView.addView(this) - - setContent { - SaveAppTheme { - DialogHost(dialogStateManager = this@BaseActivity.dialogManager) - } - } - } - } - - } - - override fun dispatchTouchEvent(event: MotionEvent?): Boolean { - if (event != null) { - val obscuredTouch = event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED != 0 - if (obscuredTouch) return false - } - - return super.dispatchTouchEvent(event) - } - - override fun onResume() { - super.onResume() - - // updating this in onResume (previously was in onCreate) to make sure setting changes get - // applied instantly instead after the next app restart - updateScreenshotPrevention() - - // Track screen view - screenStartTime = System.currentTimeMillis() - val screenName = getScreenName() - - // Set current screen for error tracking breadcrumbs - AppLogger.setCurrentScreen(screenName) - - lifecycleScope.launch { - analyticsManager.trackScreenView(screenName, null, previousScreen) - } - sessionTracker.setCurrentScreen(screenName) - - // Track navigation if coming from another screen - if (previousScreen.isNotEmpty() && previousScreen != screenName) { - lifecycleScope.launch { - analyticsManager.trackNavigation(previousScreen, screenName) - } - } - } - - override fun onPause() { - super.onPause() - - // Track time spent on screen - val timeSpent = (System.currentTimeMillis() - screenStartTime) / 1000 - val screenName = getScreenName() - - lifecycleScope.launch { - analyticsManager.trackScreenView(screenName, timeSpent, previousScreen) - } - - // Store as previous screen for navigation tracking - previousScreen = screenName - } - - fun updateScreenshotPrevention() { - if (Prefs.passcodeEnabled || Prefs.prohibitScreenshots) { - // Prevent screenshots and recent apps preview - window.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE - ) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - } - - fun setupToolbar( - title: String = "", - subtitle: String? = null, - showBackButton: Boolean = true - ) { - val toolbar: MaterialToolbar = findViewById(R.id.common_toolbar) - setSupportActionBar(toolbar) - supportActionBar?.title = title - - if (subtitle != null) { - supportActionBar?.subtitle = subtitle - } - - if (showBackButton) { - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_ios) - toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } - } else { - supportActionBar?.setDisplayHomeAsUpEnabled(false) - } - } - - override fun onDestroy() { - super.onDestroy() - dialogManager.dismissDialog() - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseComposeActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseComposeActivity.kt index ad69572e3..9ab5193eb 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseComposeActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseComposeActivity.kt @@ -6,25 +6,57 @@ import android.view.ViewGroup import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.MaterialToolbar +import kotlinx.coroutines.launch import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.features.core.dialog.DialogHost -import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager -import net.opendasharchive.openarchive.util.Prefs +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.session.SessionTracker +import net.opendasharchive.openarchive.core.security.SecurityManager +import org.koin.android.ext.android.inject import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import android.os.Bundle +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import kotlin.getValue abstract class BaseComposeActivity : AppCompatActivity() { - val dialogManager: DialogStateManager by viewModel() + val dialogManager: DialogStateManager by inject() + protected val securityManager: SecurityManager by inject() + + // Inject analytics dependencies + protected val analyticsManager: AnalyticsManager by inject() + protected val sessionTracker: SessionTracker by inject() + + // Screen tracking variables + private var screenStartTime: Long = 0 + private var previousScreen: String = "" - companion object { - const val EXTRA_DATA_SPACE = "space" + protected open fun getScreenName(): String { + return this::class.simpleName ?: "UnknownActivity" } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + observeSecuritySettings() + } + + private fun observeSecuritySettings() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + securityManager.isSecureRequired.collect { isRequired -> + applySecureFlag(isRequired) + } + } + } + } + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { if (event != null) { val obscuredTouch = event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED != 0 @@ -37,13 +69,44 @@ abstract class BaseComposeActivity : AppCompatActivity() { override fun onResume() { super.onResume() - // updating this in onResume (previously was in onCreate) to make sure setting changes get - // applied instantly instead after the next app restart - updateScreenshotPrevention() + // Track screen view + screenStartTime = System.currentTimeMillis() + val screenName = getScreenName() + + // Set current screen for error tracking breadcrumbs + AppLogger.setCurrentScreen(screenName) + + lifecycleScope.launch { + analyticsManager.trackScreenView(screenName, null, previousScreen) + } + + sessionTracker.setCurrentScreen(screenName) + + // Track navigation if coming from another screen + if (previousScreen.isNotEmpty() && previousScreen != screenName) { + lifecycleScope.launch { + analyticsManager.trackNavigation(previousScreen, screenName) + } + } } - fun updateScreenshotPrevention() { - if (Prefs.passcodeEnabled || Prefs.prohibitScreenshots) { + override fun onPause() { + super.onPause() + + // Track time spent on screen + val timeSpent = (System.currentTimeMillis() - screenStartTime) / 1000 + val screenName = getScreenName() + + lifecycleScope.launch { + analyticsManager.trackScreenView(screenName, timeSpent, previousScreen) + } + + // Store as previous screen for navigation tracking + previousScreen = screenName + } + + fun applySecureFlag(isRequired: Boolean) { + if (isRequired) { // Prevent screenshots and recent apps preview window.setFlags( WindowManager.LayoutParams.FLAG_SECURE, @@ -53,4 +116,14 @@ abstract class BaseComposeActivity : AppCompatActivity() { window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } + + @Deprecated("Use applySecureFlag via SecurityManager observation") + fun updateScreenshotPrevention() { + applySecureFlag(securityManager.isSecureRequired.value) + } + + override fun onDestroy() { + super.onDestroy() + dialogManager.dismissDialog() + } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt index 3e95230d9..110b93305 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt @@ -4,15 +4,17 @@ import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.analytics.api.AnalyticsManager import net.opendasharchive.openarchive.analytics.api.session.SessionTracker import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager -import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showDialog import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.activityViewModel -import kotlin.getValue abstract class BaseFragment : Fragment(), ToolbarConfigurable { @@ -36,14 +38,13 @@ abstract class BaseFragment : Fragment(), ToolbarConfigurable { } private fun ensureComposeDialogHost() { - (requireActivity() as? BaseActivity)?.ensureComposeDialogHost() + // Dialog host is managed by BaseComposeActivity in the Compose architecture } override fun onResume() { super.onResume() - (activity as? SpaceSetupActivity)?.updateToolbarFromFragment(this) // Track screen view screenStartTime = System.currentTimeMillis() @@ -79,4 +80,5 @@ abstract class BaseFragment : Fragment(), ToolbarConfigurable { // Store as previous screen for navigation tracking previousScreen = screenName } + } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/ComposeAppBar.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/ComposeAppBar.kt index a195fbb52..5c62cd885 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/ComposeAppBar.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/ComposeAppBar.kt @@ -1,6 +1,8 @@ package net.opendasharchive.openarchive.features.core +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -13,13 +15,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import net.opendasharchive.openarchive.R @OptIn(ExperimentalMaterial3Api::class) @Composable fun ComposeAppBar( title: String = "", - onNavigationAction: () -> Unit = {} + actions: @Composable (RowScope.() -> Unit) = {}, + onNavigateBack: () -> Unit = {}, + showNavigationIcon: Boolean = true ) { TopAppBar( modifier = Modifier.fillMaxWidth(), @@ -32,13 +37,16 @@ fun ComposeAppBar( ) }, navigationIcon = { - IconButton(onClick = onNavigationAction) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back_ios), - contentDescription = null - ) + if (showNavigationIcon) { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back_ios), + contentDescription = null + ) + } } }, + actions = actions, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.tertiary, navigationIconContentColor = Color.White, diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/ToolbarConfigurable.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/ToolbarConfigurable.kt index 403a67503..93dbbc772 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/ToolbarConfigurable.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/ToolbarConfigurable.kt @@ -1,7 +1,15 @@ package net.opendasharchive.openarchive.features.core +data class ToolbarAction( + val iconRes: Int, + val label: String, + val onClick: () -> Unit, +) + interface ToolbarConfigurable { fun getToolbarTitle(): String fun getToolbarSubtitle(): String? = null fun shouldShowBackButton(): Boolean = true + fun isToolbarVisible(): Boolean = true + fun getToolbarActions(): List = emptyList() } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/UiColor.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/UiColor.kt new file mode 100644 index 000000000..964926c37 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/UiColor.kt @@ -0,0 +1,39 @@ +package net.opendasharchive.openarchive.features.core + +import androidx.annotation.ColorRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.core.content.ContextCompat + +@Immutable +sealed interface UiColor { + + @Immutable + data class Dynamic(val color: Color) : UiColor + + @Immutable + data class Resource(@param:ColorRes val resId: Int) : UiColor + + /** + * Resolve into a Compose Color within the UI layer. + */ + @Composable + fun asColor(): Color = when (this) { + is Dynamic -> color + is Resource -> colorResource(resId) + } + + /** + * Resolve into a Compose Color using a Context (e.g., for Legacy Views or Services). + */ + fun asColor(context: android.content.Context): Color = when (this) { + is Dynamic -> color + is Resource -> Color(ContextCompat.getColor(context, resId)) + } +} + +// Helper Extensions +fun Color.asUiColor() = UiColor.Dynamic(this) +fun @receiver:ColorRes Int.asUiColor() = UiColor.Resource(this) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/UiImage.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/UiImage.kt index 04e5a5f45..38e5a547e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/UiImage.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/UiImage.kt @@ -3,14 +3,16 @@ package net.opendasharchive.openarchive.features.core import androidx.annotation.DrawableRes import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource -sealed class UiImage { - data class DynamicVector(val vector: ImageVector) : UiImage() - data class DrawableResource(@DrawableRes val resId: Int) : UiImage() +@Immutable +sealed interface UiImage { + data class DynamicVector(val imageVector: ImageVector) : UiImage + data class DrawableResource(@param:DrawableRes val resId: Int) : UiImage /** @@ -20,23 +22,23 @@ sealed class UiImage { @Composable fun asIcon( contentDescription: String? = null, - tint: Color? = null, + tint: Color = Color.Unspecified, modifier: Modifier = Modifier ): @Composable () -> Unit { return { when (this) { is DynamicVector -> Icon( - imageVector = vector, + imageVector = imageVector, contentDescription = contentDescription, modifier = modifier, - tint = tint ?: Color.Unspecified + tint = tint ) is DrawableResource -> Icon( painter = painterResource(id = resId), contentDescription = contentDescription, modifier = modifier, - tint = tint ?: Color.Unspecified + tint = tint ) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/UiText.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/UiText.kt index 68bf024fe..df7a1151e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/UiText.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/UiText.kt @@ -1,34 +1,77 @@ package net.opendasharchive.openarchive.features.core +import android.content.Context +import androidx.annotation.PluralsRes import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -sealed class UiText { +/** + * Represents user-visible text without requiring a Context in ViewModels. + * Marked @Immutable to allow Compose to skip recomposition when state hasn't changed. + */ +@Immutable +sealed interface UiText { - data class DynamicString(val value: String) : UiText() - data class StringResource(@StringRes val resId: Int) : UiText() + @Immutable + data class Dynamic(val value: String) : UiText - fun asString(context: android.content.Context): String { - return when (this) { - is DynamicString -> value - is StringResource -> context.getString(resId) - } - } + @Immutable // @param: targets constructor to avoid compiler warnings + data class Resource(@param:StringRes val resId: Int, val args: List = emptyList()) : UiText + + @Immutable + data class PluralResource(@param:PluralsRes val resId: Int, val quantity: Int, val args: List = emptyList()) : UiText - @Composable - fun asString(): String { - return when (this) { - is DynamicString -> value - is StringResource -> stringResource(resId) - } + /** + * Resolves the [UiText] into a String in a non-composable context (e.g., Unit Tests). + */ + fun asString(context: Context): String = when (this) { + is Dynamic -> value + is Resource -> context.getString(resId, *args.toFormatArgs(context)) + is PluralResource -> context.resources.getQuantityString(resId, quantity, *args.toFormatArgs(context)) } } -fun @receiver:StringRes Int.asUiText(): UiText { - return UiText.StringResource(this) +/** + * Type-safe formatting arguments. Supports nesting another [UiText] inside a resource. + */ +@Immutable +sealed interface UiTextArg { + @Immutable data class Str(val value: String) : UiTextArg + @Immutable data class Num(val value: Number) : UiTextArg // Int, Long, Double + @Immutable data class Nested(val value: UiText) : UiTextArg } -fun String.asUiText(): UiText { - return UiText.DynamicString(this) -} \ No newline at end of file +/** + * Resolves [UiText] inside a Composable function. + */ +@Composable +fun UiText.asString(): String = when (this) { + is UiText.Dynamic -> value + is UiText.Resource -> stringResource(resId, *args.toFormatArgs()) + is UiText.PluralResource -> pluralStringResource(resId, quantity, *args.toFormatArgs()) +} + +@Composable +private fun List.toFormatArgs(): Array = map { arg -> + when (arg) { + is UiTextArg.Str -> arg.value + is UiTextArg.Num -> arg.value + is UiTextArg.Nested -> arg.value.asString() + } +}.toTypedArray() + +private fun List.toFormatArgs(context: android.content.Context): Array = map { arg -> + when (arg) { + is UiTextArg.Str -> arg.value + is UiTextArg.Num -> arg.value + is UiTextArg.Nested -> arg.value.asString(context) + } +}.toTypedArray() + + +// Helper Extensions +fun String.asUiText() = UiText.Dynamic(this) +fun @receiver:StringRes Int.asUiText(vararg args: UiTextArg) = UiText.Resource(this, args.toList()) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/BaseDialog.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/BaseDialog.kt index 24991e6fa..e4463af37 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/BaseDialog.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/BaseDialog.kt @@ -10,11 +10,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox @@ -38,12 +33,14 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.ViewModel import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview import net.opendasharchive.openarchive.features.core.BaseButton import net.opendasharchive.openarchive.features.core.BaseDestructiveButton import net.opendasharchive.openarchive.features.core.BaseNeutralButton import net.opendasharchive.openarchive.features.core.UiImage import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.asString import net.opendasharchive.openarchive.features.core.asUiImage @Composable @@ -207,11 +204,17 @@ fun BaseDialogMessage( ) } - +/** + * A Global Manager for Dialogs. + * Registered as a 'single' in Koin so every screen shares this state. + */ class DialogStateManager(private val resourceProvider: ResourceProvider) : ViewModel() { private val _dialogConfig = mutableStateOf(null) val dialogConfig: State = _dialogConfig + init { + AppLogger.i("DialogStateManager initialized....") + } fun showDialog(config: DialogConfig) { _dialogConfig.value = config } @@ -243,7 +246,7 @@ fun DialogHost(dialogStateManager: DialogStateManager) { config.onDismissAction?.invoke() }, icon = config.icon, - iconColor = config.iconColor, + iconColor = config.iconColor?.asColor(), title = config.title.asString(), message = config.message.asString(), positiveButton = config.positiveButton, @@ -264,11 +267,11 @@ private fun BaseDialogPreview() { BaseDialog( onDismiss = {}, - icon = Icons.Filled.Check.asUiImage(), + icon = UiImage.DrawableResource(R.drawable.ic_warning), iconColor = MaterialTheme.colorScheme.tertiary, title = stringResource(R.string.label_success_title), message = stringResource(R.string.create_folder_ok_message), - positiveButton = ButtonData(UiText.StringResource(R.string.lbl_ok)), + positiveButton = ButtonData(UiText.Resource(R.string.lbl_ok)), ) } @@ -282,12 +285,12 @@ private fun WarningDialogPreview() { BaseDialog( onDismiss = {}, - icon = Icons.Default.Warning.asUiImage(), + icon = UiImage.DrawableResource(R.drawable.ic_warning), iconColor = MaterialTheme.colorScheme.tertiary, title = "Warning", message = stringResource(R.string.once_uploaded_you_will_not_be_able_to_edit_media), - positiveButton = ButtonData(UiText.DynamicString("OK")), - neutralButton = ButtonData(UiText.DynamicString("Cancel")), + positiveButton = ButtonData(UiText.Dynamic("OK")), + neutralButton = ButtonData(UiText.Dynamic("Cancel")), hasCheckbox = true, checkBoxHint = "Do not show me this again", onCheckBoxStateChanged = { }, @@ -303,12 +306,12 @@ private fun ErrorDialogPreview() { BaseDialog( onDismiss = {}, - icon = Icons.Default.ErrorOutline.asUiImage(), + icon = UiImage.DrawableResource(R.drawable.ic_error), iconColor = MaterialTheme.colorScheme.error, title = "Image upload unsuccessful", message = "Give a reason here? Lorem Ipsum text can go here if needed", - positiveButton = ButtonData(UiText.DynamicString("Retry")), - destructiveButton = ButtonData(UiText.DynamicString("Remove Image")), + positiveButton = ButtonData(UiText.Dynamic("Retry")), + destructiveButton = ButtonData(UiText.Dynamic("Remove Image")), ) } } @@ -321,11 +324,11 @@ private fun TorWarningDialogPreview() { BaseDialog( onDismiss = {}, - icon = Icons.Default.Info.asUiImage(), + icon = UiImage.DrawableResource(R.drawable.ic_info_outline), iconColor = MaterialTheme.colorScheme.tertiary, title = stringResource(R.string.tor_disabled_title), message = stringResource(R.string.tor_disabled_message), - positiveButton = ButtonData(UiText.DynamicString(stringResource(R.string.lbl_ok))), + positiveButton = ButtonData(UiText.Dynamic(stringResource(R.string.lbl_ok))), ) } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/DialogConfigBuilder.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/DialogConfigBuilder.kt index d789007c9..49bf6fc1f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/DialogConfigBuilder.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/DialogConfigBuilder.kt @@ -4,10 +4,6 @@ import android.content.Context import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.outlined.Error import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color @@ -16,8 +12,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.features.core.UiColor import net.opendasharchive.openarchive.features.core.UiImage import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.asUiColor // -------------------------------------------------------------------- // 1. Dialog Types @@ -34,14 +32,14 @@ data class DialogConfig( val title: UiText, val message: UiText, val icon: UiImage? = null, - val iconColor: Color? = null, + val iconColor: UiColor? = null, val positiveButton: ButtonData? = null, val neutralButton: ButtonData? = null, val destructiveButton: ButtonData? = null, val showCheckbox: Boolean = false, val checkboxText: UiText? = null, val onCheckboxChanged: (Boolean) -> Unit = {}, - val backgroundColor: Color? = null, + val backgroundColor: UiColor? = null, val cornerRadius: Dp? = null, val onDismissAction: (() -> Unit)? = null, ) @@ -79,8 +77,8 @@ class DialogBuilder { var icon: UiImage? = null var title: UiText? = null var message: UiText? = null - var iconColor: Color? = null - var backgroundColor: Color? = null + var iconColor: UiColor? = null + var backgroundColor: UiColor? = null var cornerRadius: Dp? = null // Buttons (initially null) @@ -117,14 +115,14 @@ class DialogBuilder { // Default texts based on type. private fun defaultPositiveTextFor(type: DialogType): UiText = when (type) { - DialogType.Success -> UiText.StringResource(R.string.lbl_ok) - DialogType.Error -> UiText.StringResource(R.string.lbl_retry) - DialogType.Warning -> UiText.StringResource(R.string.lbl_ok) - DialogType.Info -> UiText.StringResource(R.string.lbl_got_it) - DialogType.Custom -> UiText.StringResource(R.string.lbl_ok) + DialogType.Success -> UiText.Resource(R.string.lbl_ok) + DialogType.Error -> UiText.Resource(R.string.lbl_retry) + DialogType.Warning -> UiText.Resource(R.string.lbl_ok) + DialogType.Info -> UiText.Resource(R.string.lbl_got_it) + DialogType.Custom -> UiText.Resource(R.string.lbl_ok) } - private fun defaultNeutralText(): UiText = UiText.StringResource(R.string.lbl_Cancel) - private fun defaultDestructiveText(): UiText = UiText.StringResource(R.string.lbl_Cancel) + private fun defaultNeutralText(): UiText = UiText.Resource(R.string.lbl_Cancel) + private fun defaultDestructiveText(): UiText = UiText.Resource(R.string.lbl_Cancel) // ------------------------------- // 5a. Compose build() – use MaterialTheme defaults. @@ -135,32 +133,32 @@ class DialogBuilder { if (icon == null) { icon = when (type) { DialogType.Success -> UiImage.DrawableResource(R.drawable.ic_done) - DialogType.Error -> UiImage.DynamicVector(Icons.Outlined.Error) - DialogType.Warning -> UiImage.DynamicVector(Icons.Default.Warning) - DialogType.Info -> UiImage.DynamicVector(Icons.Filled.Info) + DialogType.Error -> UiImage.DrawableResource(R.drawable.ic_error) + DialogType.Warning -> UiImage.DrawableResource(R.drawable.ic_warning) + DialogType.Info -> UiImage.DrawableResource(R.drawable.ic_info_outline) DialogType.Custom -> null } } val finalIconColor = iconColor ?: when (type) { - DialogType.Error -> MaterialTheme.colorScheme.error - DialogType.Warning -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.onBackground + DialogType.Error -> MaterialTheme.colorScheme.error.asUiColor() + DialogType.Warning -> MaterialTheme.colorScheme.tertiary.asUiColor() + else -> MaterialTheme.colorScheme.onBackground.asUiColor() } - val finalBackgroundColor = backgroundColor ?: MaterialTheme.colorScheme.surfaceVariant + val finalBackgroundColor = backgroundColor ?: MaterialTheme.colorScheme.surfaceVariant.asUiColor() val finalCornerRadius = cornerRadius ?: 12.dp val finalTitle = title ?: when (type) { - DialogType.Success -> UiText.StringResource(R.string.label_success_title) - DialogType.Error -> UiText.StringResource(R.string.error) - DialogType.Warning -> UiText.StringResource(R.string.label_warning_title) - DialogType.Info -> UiText.StringResource(R.string.label_info_title) - DialogType.Custom -> UiText.DynamicString("") + DialogType.Success -> UiText.Resource(R.string.label_success_title) + DialogType.Error -> UiText.Resource(R.string.error) + DialogType.Warning -> UiText.Resource(R.string.label_warning_title) + DialogType.Info -> UiText.Resource(R.string.label_info_title) + DialogType.Custom -> UiText.Dynamic("") } return DialogConfig( type = type, title = finalTitle, - message = message ?: UiText.DynamicString(""), + message = message ?: UiText.Dynamic(""), icon = icon, iconColor = finalIconColor, positiveButton = _positiveButton, //?: ButtonData(defaultPositiveTextFor(type)), @@ -184,32 +182,32 @@ class DialogBuilder { icon = when (type) { DialogType.Success -> UiImage.DrawableResource(R.drawable.ic_done) - DialogType.Error -> UiImage.DynamicVector(Icons.Outlined.Error) - DialogType.Warning -> UiImage.DynamicVector(Icons.Default.Warning) - DialogType.Info -> UiImage.DynamicVector(Icons.Filled.Info) + DialogType.Error -> UiImage.DrawableResource(R.drawable.ic_error) + DialogType.Warning -> UiImage.DrawableResource(R.drawable.ic_warning) + DialogType.Info -> UiImage.DrawableResource(R.drawable.ic_info_outline) DialogType.Custom -> null } } // Convert resource colors (ints) to Compose Colors. val finalIconColor = iconColor ?: when (type) { - DialogType.Error -> resourceProvider.getColor(R.color.colorError) - else -> resourceProvider.getColor(R.color.colorTertiary) + DialogType.Error -> resourceProvider.getColor(R.color.colorError).asUiColor() + else -> resourceProvider.getColor(R.color.colorTertiary).asUiColor() } - val finalBackgroundColor = backgroundColor ?: resourceProvider.getColor(R.color.colorSurface) + val finalBackgroundColor = backgroundColor ?: resourceProvider.getColor(R.color.colorSurface).asUiColor() val finalCornerRadius = cornerRadius ?: 12.dp val finalTitle = title ?: when (type) { - DialogType.Success -> UiText.StringResource(R.string.label_success_title) - DialogType.Error -> UiText.StringResource(R.string.error) - DialogType.Warning -> UiText.StringResource(R.string.label_warning_title) - DialogType.Info -> UiText.StringResource(R.string.label_info_title) - DialogType.Custom -> UiText.DynamicString("") + DialogType.Success -> UiText.Resource(R.string.label_success_title) + DialogType.Error -> UiText.Resource(R.string.error) + DialogType.Warning -> UiText.Resource(R.string.label_warning_title) + DialogType.Info -> UiText.Resource(R.string.label_info_title) + DialogType.Custom -> UiText.Dynamic("") } return DialogConfig( type = type, title = finalTitle, - message = message ?: UiText.DynamicString(""), + message = message ?: UiText.Dynamic(""), icon = icon, iconColor = finalIconColor, positiveButton = _positiveButton, //?: ButtonData(defaultPositiveTextFor(type)), @@ -231,7 +229,7 @@ class DialogBuilder { // --- Compose extension: allows calling showDialog { ... } in a @Composable block. @Composable -fun DialogStateManager.showDialog(block: DialogBuilder.() -> Unit) { +fun DialogStateManager.showDialogCompose(block: DialogBuilder.() -> Unit) { val config = DialogBuilder().apply(block).build() showDialog(config) } @@ -256,10 +254,10 @@ fun DialogStateManager.showSuccessDialog( ) { showDialog { type = DialogType.Success - this.message = UiText.DynamicString(message) - if (title.isNotEmpty()) this.title = UiText.DynamicString(title) + this.message = UiText.Dynamic(message) + if (title.isNotEmpty()) this.title = UiText.Dynamic(title) positiveButton { - text = UiText.StringResource(R.string.lbl_ok) + text = UiText.Resource(R.string.lbl_ok) action = onPositive } } @@ -279,11 +277,11 @@ fun DialogStateManager.showSuccessDialog( showDialog(resourceProvider) { type = DialogType.Success if (icon != null) this.icon = icon - this.iconColor = resourceProvider.getColor(R.color.colorTertiary) - if (title != null) this.title = UiText.StringResource(title) - this.message = UiText.StringResource(message) + this.iconColor = resourceProvider.getColor(R.color.colorTertiary).asUiColor() + if (title != null) this.title = UiText.Resource(title) + this.message = UiText.Resource(message) positiveButton { - text = UiText.StringResource(positiveButtonText ?: R.string.lbl_got_it) + text = UiText.Resource(positiveButtonText ?: R.string.lbl_got_it) action = onDone } onDismissAction { @@ -294,19 +292,19 @@ fun DialogStateManager.showSuccessDialog( // View helper for an error dialog. fun DialogStateManager.showErrorDialog( - message: String, - title: String = "", + title: UiText? = null, + message: UiText, onDismiss: () -> Unit = {} ) { val resourceProvider = this.requireResourceProvider() showDialog(resourceProvider) { type = DialogType.Error - this.message = UiText.DynamicString(message) - if (title.isNotEmpty()) this.title = UiText.DynamicString(title) + this.message = message + title?.let { this.title = it } ?: run { this.title = UiText.Resource(R.string.error) } positiveButton { - text = UiText.StringResource(R.string.lbl_ok) + text = UiText.Resource(R.string.lbl_ok) action = onDismiss } } @@ -324,11 +322,11 @@ fun DialogStateManager.showInfoDialog( showDialog(resourceProvider) { type = DialogType.Info this.icon = icon - this.iconColor = resourceProvider.getColor(R.color.colorTertiary) + this.iconColor = resourceProvider.getColor(R.color.colorTertiary).asUiColor() this.title = title this.message = message positiveButton { - text = UiText.StringResource(R.string.lbl_ok) + text = UiText.Resource(R.string.lbl_ok) action = onDone } } @@ -338,7 +336,7 @@ fun DialogStateManager.showInfoDialog( fun DialogStateManager.showWarningDialog( title: UiText?, message: UiText, - icon: UiImage? = null, + icon: UiImage? = UiImage.DrawableResource(R.drawable.ic_warning), positiveButtonText: UiText? = null, onDone: () -> Unit = {}, onCancel: () -> Unit = {} @@ -349,14 +347,14 @@ fun DialogStateManager.showWarningDialog( type = DialogType.Warning this.title = title this.icon = icon - iconColor = resourceProvider.getColor(R.color.colorTertiary) + iconColor = resourceProvider.getColor(R.color.colorTertiary).asUiColor() this.message = message positiveButton { - text = positiveButtonText ?: UiText.StringResource(R.string.lbl_got_it) + text = positiveButtonText ?: UiText.Resource(R.string.lbl_got_it) action = onDone } destructiveButton { - text = UiText.StringResource(R.string.lbl_Cancel) + text = UiText.Resource(R.string.lbl_Cancel) action = onCancel } } @@ -379,11 +377,11 @@ fun DialogStateManager.showDestructiveDialog( this.icon = icon this.message = message positiveButton { - text = positiveButtonText ?: UiText.StringResource(R.string.lbl_got_it) + text = positiveButtonText ?: UiText.Resource(R.string.lbl_got_it) action = onDone } destructiveButton { - text = UiText.StringResource(R.string.lbl_Cancel) + text = UiText.Resource(R.string.lbl_Cancel) action = onCancel } } @@ -396,7 +394,6 @@ fun DialogStateManager.showDestructiveDialog( */ interface ResourceProvider { fun getColor(@ColorRes colorRes: Int): Color - fun getVector(@DrawableRes drawableRes: Int): ImageVector? } /** @@ -409,14 +406,4 @@ class DefaultResourceProvider(private val context: Context) : ResourceProvider { // ContextCompat.getColor returns an int (the ARGB value); we wrap it in Compose’s Color. return Color(ContextCompat.getColor(context, colorRes)) } - - override fun getVector(@DrawableRes drawableRes: Int): ImageVector? { - // For a real application you might have a more elaborate mapping. - // In this simple example, if the drawable resource equals R.drawable.ic_info, - // we return Icons.Filled.Info; otherwise, return null. - return when (drawableRes) { - R.drawable.ic_info -> Icons.Filled.Info - else -> null - } - } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderScreen.kt index 2490c39de..a5caad4b1 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderScreen.kt @@ -2,8 +2,6 @@ package net.opendasharchive.openarchive.features.folders import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -16,21 +14,14 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -38,27 +29,26 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.navigation.findNavController import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.features.settings.passcode.components.DefaultScaffold -@Composable -fun AddFolderScreen() { - - val navController = LocalView.current.findNavController() - SaveAppTheme { +@Composable +fun AddFolderScreen( + onCreateFolder: () -> Unit, + onBrowseFolders: () -> Unit, + onNavigateBack: () -> Unit +) { + DefaultScaffold( + title = stringResource(id = R.string.add_a_folder), + onNavigateBack = onNavigateBack + ) { AddFolderScreenContent( - onCreateFolder = { - navController.navigate(R.id.fragment_add_folder_to_fragment_create_new_folder) - }, - onBrowseFolders = { - navController.navigate(R.id.fragment_add_folder_to_fragment_browse_folders) - } + onCreateFolder = onCreateFolder, + onBrowseFolders = onBrowseFolders ) } - } @@ -158,7 +148,7 @@ private fun AddFolderScreenPreview() { DefaultScaffoldPreview { AddFolderScreenContent( onCreateFolder = {}, - onBrowseFolders = {} + onBrowseFolders = {}, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFolderScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFolderScreen.kt index 67dd1470f..dd6e69f07 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFolderScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFolderScreen.kt @@ -1,103 +1,211 @@ package net.opendasharchive.openarchive.features.folders +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.findNavController +import androidx.compose.ui.unit.sp import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import org.koin.androidx.compose.koinViewModel -import java.util.Date +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.util.DateUtils @Composable fun BrowseFolderScreen( - viewModel: BrowseFoldersViewModel = koinViewModel() + state: BrowseFoldersState, + viewModel: BrowseFoldersViewModel ) { - val navController = LocalView.current.findNavController() - - - val folders by viewModel.folders.observeAsState() - - BrowseFolderScreenContent( - folders = folders ?: emptyList() + state = state, + onAction = viewModel::onAction ) -} +} @Composable fun BrowseFolderScreenContent( - folders: List + state: BrowseFoldersState, + onAction: (BrowseFoldersAction) -> Unit ) { - - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(vertical = 24.dp, horizontal = 16.dp), - contentPadding = PaddingValues(16.dp) - ) { - - items(folders) { folder -> - BrowseFolderItem(folder) { } + Box(modifier = Modifier.fillMaxSize()) { + when { + state.isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.tertiary + ) + } + + state.folders.isEmpty() -> { + Text( + text = stringResource(R.string.no_more_folders), + modifier = Modifier.align(Alignment.Center), + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + items(state.folders, key = { it.name }) { folder -> + BrowseFolderItem( + folder = folder, + isSelected = state.selectedFolder == folder, + onClick = { onAction(BrowseFoldersAction.SelectFolder(folder)) } + ) + } + } + } } } - } @Composable fun BrowseFolderItem( folder: Folder, + isSelected: Boolean, onClick: () -> Unit ) { + val backgroundColor = if (isSelected) { + colorResource(R.color.colorTertiary) + } else { + Color.Transparent + } - Card( - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(8.dp) + .heightIn(min = 52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { + Icon( + painter = painterResource(R.drawable.ic_folder_new), + contentDescription = null, + tint = colorResource(R.color.colorOnBackground) + ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 4.dp, horizontal = 16.dp) ) { + Text( + text = folder.name, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) - Icon(painter = painterResource(R.drawable.ic_folder_new), contentDescription = null) - Text(folder.name) + // Timestamp is hidden in XML (visibility="gone"), but keeping structure for future + // Uncomment below if you want to show timestamp: + // Text( + // text = java.text.SimpleDateFormat.getDateTimeInstance( + // java.text.SimpleDateFormat.LONG, + // java.text.SimpleDateFormat.MEDIUM + // ).format(folder.modified.toJavaDate()), + // fontSize = 15.sp, + // color = MaterialTheme.colorScheme.onSurfaceVariant + // ) } + + // Always render the checkmark icon to reserve space, but control visibility + Icon( + painter = painterResource(R.drawable.ic_done), + contentDescription = if (isSelected) stringResource(R.string.lbl_select_media) else null, + tint = colorResource(R.color.colorOnBackground), + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp) + .alpha(if (isSelected) 1f else 0f) + ) } } -@Preview +@PreviewLightDark @Composable private fun BrowseFolderScreenPreview() { DefaultScaffoldPreview { BrowseFolderScreenContent( - folders = listOf( - Folder(name = "Elelan", modified = Date()), - Folder(name = "Save", modified = Date()), - Folder(name = "Downloads", modified = Date()), - Folder(name = "Trip", modified = Date()), - Folder(name = "Wedding", modified = Date()), - ) + state = BrowseFoldersState( + folders = listOf( + Folder(name = "Documents", modified = DateUtils.nowDateTime), + Folder(name = "Photos", modified = DateUtils.nowDateTime), + Folder(name = "Videos", modified = DateUtils.nowDateTime), + Folder(name = "Downloads", modified = DateUtils.nowDateTime), + Folder(name = "Projects", modified = DateUtils.nowDateTime), + ), + selectedFolder = Folder(name = "Photos", modified = DateUtils.nowDateTime) + ), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun BrowseFolderScreenEmptyPreview() { + DefaultScaffoldPreview { + BrowseFolderScreenContent( + state = BrowseFoldersState( + folders = emptyList() + ), + onAction = {} ) } -} \ No newline at end of file +} + +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun BrowseFolderScreenLoadingPreview() { + DefaultScaffoldPreview { + BrowseFolderScreenContent( + state = BrowseFoldersState( + folders = emptyList(), + isLoading = true + ), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersAdapter.kt deleted file mode 100644 index 699fb3ca9..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersAdapter.kt +++ /dev/null @@ -1,66 +0,0 @@ -package net.opendasharchive.openarchive.features.folders - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FolderRowBinding -import java.text.SimpleDateFormat - -class BrowseFoldersAdapter( - private val folders: List = emptyList(), - private val onClick: (folder: Folder) -> Unit -) : RecyclerView.Adapter() { - - companion object { - private val formatter = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.LONG, SimpleDateFormat.MEDIUM) - } - - private var mSelected: Folder? = null - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FolderViewHolder { - val binding = FolderRowBinding.inflate(LayoutInflater.from(parent.context), parent, false) - - return FolderViewHolder(binding, onClick) - } - - override fun onBindViewHolder(holder: FolderViewHolder, position: Int) { - holder.bind(folders[position]) - } - - override fun getItemCount(): Int = folders.size - - inner class FolderViewHolder(private val binding: FolderRowBinding, private val onClick: (folder: Folder) -> Unit) : RecyclerView.ViewHolder(binding.root) { - - fun bind(folder: Folder) { - - val isSelected = mSelected == folder - - itemView.isSelected = isSelected - - val icon = ContextCompat.getDrawable(binding.icon.context, R.drawable.ic_folder_new) - icon?.setTint(ContextCompat.getColor(binding.icon.context, R.color.colorOnBackground)) - binding.icon.setImageDrawable(icon) - - binding.name.text = folder.name - binding.timestamp.text = formatter.format(folder.modified) - - binding.rvTick.visibility = if (isSelected) View.VISIBLE else View.INVISIBLE - - binding.root.setOnClickListener { - if (mSelected == folder) return@setOnClickListener - - val previousSelected = mSelected - mSelected = folder - - // Notify changes for previous and current selection - notifyItemChanged(folders.indexOf(previousSelected)) - notifyItemChanged(folders.indexOf(mSelected)) - - onClick.invoke(folder) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersFragment.kt deleted file mode 100644 index 431b2c94a..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersFragment.kt +++ /dev/null @@ -1,142 +0,0 @@ -package net.opendasharchive.openarchive.features.folders - -import android.app.Activity.RESULT_OK -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.core.view.MenuProvider -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.lifecycle.Lifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentBrowseFoldersBinding -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.dialog.showSuccessDialog -import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity -import net.opendasharchive.openarchive.util.extensions.toggle -import org.koin.androidx.viewmodel.ext.android.viewModel -import java.util.Date - -class BrowseFoldersFragment : BaseFragment(), MenuProvider { - - private lateinit var binding: FragmentBrowseFoldersBinding - private val mViewModel: BrowseFoldersViewModel by viewModel() - - private var mSelected: Folder? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentBrowseFoldersBinding.inflate(layoutInflater) - - binding.rvFolderList.layoutManager = LinearLayoutManager(requireContext()) - binding.rvFolderList.clipToPadding = false - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - activity?.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - - ViewCompat.setOnApplyWindowInsetsListener(binding.rvFolderList) { view, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) - - view.updatePadding( - bottom = insets.bottom + view.paddingBottom - ) - - windowInsets - } - - val space = Space.current - if (space != null) mViewModel.getFiles(space) - - mViewModel.folders.observe(viewLifecycleOwner) { - binding.projectsEmpty.toggle(it.isEmpty()) - - binding.rvFolderList.adapter = BrowseFoldersAdapter(it) { folder -> - this.mSelected = folder - activity?.invalidateOptionsMenu() - } - } - - mViewModel.progressBarFlag.observe(viewLifecycleOwner) { - binding.progressBar.toggle(it) - } - } - - - override fun getToolbarTitle(): String = getString(R.string.browse_existing) - - private fun addFolder(folder: Folder?) { - if (folder == null) return - val space = Space.current ?: return - - // This should not happen. These should have been filtered on display. - if (space.hasProject(folder.name)) return - - val license = space.license - - - val project = Project(folder.name, Date(), space.id, licenseUrl = license) - project.save() - - showFolderCreated(project.id) - } - - private fun showFolderCreated(projectId: Long) { - - dialogManager.showSuccessDialog( - title = R.string.label_success_title, - message = R.string.create_folder_ok_message, - positiveButtonText = R.string.label_got_it, - onDone = { - navigateBackWithResult(projectId) - }, - onDismissed = { - // If the dialog is dismissed, we still want to navigate back - navigateBackWithResult(projectId) - } - ) - } - - private fun navigateBackWithResult(projectId: Long) { - requireActivity().setResult(RESULT_OK, Intent().apply { - putExtra(SpaceSetupActivity.EXTRA_FOLDER_ID, projectId) - }) - requireActivity().finish() - } - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_browse_folder, menu) - } - - override fun onPrepareMenu(menu: Menu) { - super.onPrepareMenu(menu) - val addMenuItem = menu.findItem(R.id.action_add) - addMenuItem?.isVisible = mSelected != null - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_add -> { - addFolder(mSelected) - true - } - - else -> false - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersViewModel.kt index daaecba80..81c181e7e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersViewModel.kt @@ -1,69 +1,157 @@ package net.opendasharchive.openarchive.features.folders -import android.content.Context -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.services.SaveClient -import timber.log.Timber -import java.io.IOException -import java.util.Date +import kotlinx.datetime.LocalDateTime +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.core.UiImage +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.ButtonData +import net.opendasharchive.openarchive.features.core.dialog.DialogConfig +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.services.webdav.data.WebDavRepository +import net.opendasharchive.openarchive.util.DateUtils +data class Folder(val name: String, val modified: LocalDateTime) +data class BrowseFoldersState( + val folders: List = emptyList(), + val selectedFolder: Folder? = null, + val isLoading: Boolean = false, + val error: UiText? = null +) -data class Folder(val name: String, val modified: Date) +sealed interface BrowseFoldersAction { + data class SelectFolder(val folder: Folder) : BrowseFoldersAction + data object AddFolder : BrowseFoldersAction + data object LoadFolders : BrowseFoldersAction +} -class BrowseFoldersViewModel(private val context: Context) : ViewModel() { +sealed interface BrowseFoldersEvent { + // No more UI events needed +} - private val mFolders = MutableLiveData>() +class BrowseFoldersViewModel( + private val webDavRepository: WebDavRepository, + private val spaceRepository: SpaceRepository, + private val projectRepository: ProjectRepository, + private val navigator: Navigator, + private val dialogManager: DialogStateManager +) : ViewModel() { - val folders: LiveData> - get() = mFolders + private val _uiState = MutableStateFlow(BrowseFoldersState()) + val uiState: StateFlow = _uiState.asStateFlow() - val progressBarFlag = MutableLiveData(false) + private val _events = Channel() + val events = _events.receiveAsFlow() - fun getFiles(space: Space) { + init { + loadFolders() + } + + fun onAction(action: BrowseFoldersAction) { + when (action) { + is BrowseFoldersAction.SelectFolder -> { + _uiState.update { it.copy(selectedFolder = action.folder) } + } + + is BrowseFoldersAction.AddFolder -> { + addFolder() + } + + is BrowseFoldersAction.LoadFolders -> { + loadFolders() + } + } + } + + private fun loadFolders() { viewModelScope.launch { - progressBarFlag.value = true + val space = spaceRepository.getCurrentSpace() ?: return@launch + + _uiState.update { it.copy(isLoading = true, error = null) } try { - val value = withContext(Dispatchers.IO) { - when (space.tType) { - Space.Type.WEBDAV -> getWebDavFolders(context, space) + val folderList = webDavRepository.getFolders(space) - else -> emptyList() - } + // Filter out folders that are already tracked as projects + val filteredFolders = folderList.filter { folder -> + projectRepository.getProjectByName(space.id, folder.name) == null } - mFolders.value = value.filter { !space.hasProject(it.name) } - progressBarFlag.value = false - } - // Dropbox might throw all sorts of non-IOExceptions. - catch (e: Throwable) { - progressBarFlag.value = false - mFolders.value = arrayListOf() + _uiState.update { + it.copy( + folders = filteredFolders, + isLoading = false, + error = null + ) + } + } catch (e: Throwable) { + AppLogger.e(e) + _uiState.update { + it.copy( + folders = emptyList(), + isLoading = false, + error = if (e.message != null) { + UiText.Dynamic(e.message!!) + } else { + UiText.Resource(net.opendasharchive.openarchive.R.string.error) + } + ) + } - Timber.e(e) } } } - @Throws(IOException::class) - private suspend fun getWebDavFolders(context: Context, space: Space): List { - val root = space.hostUrl?.encodedPath + private fun addFolder() { + viewModelScope.launch { + val folder = _uiState.value.selectedFolder ?: return@launch + val space = spaceRepository.getCurrentSpace() ?: return@launch + + if (projectRepository.getProjectByName(space.id, folder.name) != null) return@launch - return SaveClient.getSardine(context, space).list(space.host)?.mapNotNull { - if (it?.isDirectory == true && it.path != root) { - Folder(it.name, it.modified ?: Date()) - } - else { - null - } - } ?: emptyList() + val archive = Archive( + description = folder.name, + created = DateUtils.nowDateTime, + vaultId = space.id, + licenseUrl = space.licenseUrl, + isRemote = true + ) + + val projectId = projectRepository.addProject(archive) + AppLogger.i("New project added: $projectId") + + dialogManager.showDialog( + DialogConfig( + type = DialogType.Success, + title = UiText.Resource(net.opendasharchive.openarchive.R.string.label_success_title), + message = UiText.Resource(net.opendasharchive.openarchive.R.string.create_folder_ok_message), + icon = UiImage.DrawableResource(net.opendasharchive.openarchive.R.drawable.ic_done), + positiveButton = ButtonData( + text = UiText.Resource(net.opendasharchive.openarchive.R.string.label_got_it), + action = { navigator.navigateBack() } + ), + onDismissAction = { navigator.navigateBack() } + ) + ) + } + } + + fun onBack() { + navigator.navigateBack() } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderFragment.kt deleted file mode 100644 index 7eb62b578..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderFragment.kt +++ /dev/null @@ -1,159 +0,0 @@ -package net.opendasharchive.openarchive.features.folders - -import android.app.Activity.RESULT_CANCELED -import android.app.Activity.RESULT_OK -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.widget.Toast -import androidx.core.view.MenuProvider -import androidx.core.view.WindowInsetsCompat -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentCreateNewFolderBinding -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.dialog.showSuccessDialog -import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity -import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets -import net.opendasharchive.openarchive.util.extensions.hide -import java.util.Date - -class CreateNewFolderFragment : BaseFragment() { - - companion object { - private const val SPECIAL_CHARS = ".*[\\\\/*\\s]" - } - - private lateinit var binding: FragmentCreateNewFolderBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentCreateNewFolderBinding.inflate(layoutInflater) - - binding.buttonBar.applyEdgeToEdgeInsets( - typeMask = WindowInsetsCompat.Type.navigationBars() - ) { insets -> - bottomMargin = insets.bottom - } - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val intent = requireActivity().intent - - binding.newFolder.setText(intent.getStringExtra(SpaceSetupActivity.EXTRA_FOLDER_NAME)) - - binding.newFolder.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE) { - store() - } - - false - } - - binding.btnSubmit.setOnClickListener { - store() - } - - binding.btnCancel.setOnClickListener { - requireActivity().setResult(RESULT_CANCELED) - requireActivity().finish() - } - - setupTextWatchers() - } - - private fun setupTextWatchers() { - // Create a common TextWatcher for all three fields - val textWatcher = object : android.text.TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - updateAuthenticateButtonState() - } - - override fun afterTextChanged(s: android.text.Editable?) {} - } - - binding.newFolder.addTextChangedListener(textWatcher) - } - - private fun updateAuthenticateButtonState() { - val folderName = binding.newFolder.text?.toString()?.trim().orEmpty() - - // Enable the button only if none of the fields are empty - binding.btnSubmit.isEnabled = folderName.isNotEmpty() - } - - override fun getToolbarTitle(): String = getString(R.string.create_a_new_folder) - - private fun store() { - val name = binding.newFolder.text.toString() - - if (name.isBlank()) return - - if (name.matches(SPECIAL_CHARS.toRegex())) { - Toast.makeText( - requireContext(), - getString(R.string.please_do_not_include_special_characters_in_the_name), - Toast.LENGTH_SHORT - ).show() - - return - } - - val space = Space.current ?: return - - if (space.hasProject(name)) { - Toast.makeText( - requireContext(), getString(R.string.folder_name_already_exists), - Toast.LENGTH_LONG - ).show() - - return - } - - val license = - space.license ?: CreativeCommonsLicenseManager.getSelectedLicenseUrl(binding.cc) - - val project = Project(name, Date(), space.id, licenseUrl = license) - project.save() - - showFolderCreated(project.id) - - - } - - private fun showFolderCreated(projectId: Long) { - - dialogManager.showSuccessDialog( - title = R.string.label_success_title, - message = R.string.create_folder_ok_message, - positiveButtonText = R.string.label_got_it, - onDone = { - navigateBackWithResult(projectId) - } - ) - } - - private fun navigateBackWithResult(projectId: Long) { - val i = Intent() - i.putExtra(SpaceSetupActivity.EXTRA_FOLDER_ID, projectId) - - requireActivity().setResult(RESULT_OK, i) - requireActivity().finish() - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderScreen.kt new file mode 100644 index 000000000..c8db5262c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderScreen.kt @@ -0,0 +1,211 @@ +package net.opendasharchive.openarchive.features.folders + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomTextField + +@Composable +fun CreateNewFolderScreen( + viewModel: CreateNewFolderViewModel, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + + is CreateNewFolderEvent.ShowError -> { + Toast.makeText(context, event.message.asString(context), Toast.LENGTH_SHORT).show() + } + } + } + } + + CreateNewFolderScreenContent( + state = state, + onAction = viewModel::onAction + ) +} + +@Composable +fun CreateNewFolderScreenContent( + state: CreateNewFolderState, + onAction: (CreateNewFolderAction) -> Unit +) { + val focusManager = LocalFocusManager.current + + Box(modifier = Modifier.fillMaxSize()) { + // Scrollable content + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 64.dp, bottom = 100.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Title + Text( + text = stringResource(R.string.create_new_folder_title), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Subtitle + Text( + text = stringResource(R.string.create_new_folder_subtitle), + fontSize = 14.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Folder name input + CustomTextField( + value = state.folderName, + onValueChange = { onAction(CreateNewFolderAction.UpdateFolderName(it)) }, + placeholder = stringResource(R.string.create_new_folder_hint), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + onImeAction = { + focusManager.clearFocus() + if (state.isValid && !state.isSaving) { + onAction(CreateNewFolderAction.CreateFolder) + } + } + ) + + // CC License section is hidden in XML (visibility="gone") + // Commenting it out but keeping structure for future + // CreativeCommonsLicenseContent(...) + } + + // Button bar at bottom + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Cancel button + TextButton( + modifier = Modifier + .weight(1f) + .heightIn(ThemeDimensions.touchable), + colors = ButtonDefaults.textButtonColors( + contentColor = colorResource(R.color.colorOnBackground) + ), + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { onAction(CreateNewFolderAction.Cancel) } + ) { + Text( + stringResource(R.string.lbl_Cancel), + style = MaterialTheme.typography.titleLarge + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Create button + Button( + modifier = Modifier + .weight(1f) + .heightIn(ThemeDimensions.touchable), + enabled = state.isValid && !state.isSaving, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + disabledContainerColor = colorResource(R.color.grey_50), + disabledContentColor = colorResource(R.color.black), + contentColor = colorResource(R.color.black) + ), + onClick = { onAction(CreateNewFolderAction.CreateFolder) } + ) { + Text( + stringResource(R.string.create), + style = MaterialTheme.typography.titleLarge + ) + } + } + } +} + +@PreviewLightDark +@Composable +private fun CreateNewFolderScreenPreview() { + DefaultScaffoldPreview { + CreateNewFolderScreenContent( + state = CreateNewFolderState( + folderName = "My New Folder", + isValid = true + ), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun CreateNewFolderScreenEmptyPreview() { + DefaultScaffoldPreview { + CreateNewFolderScreenContent( + state = CreateNewFolderState(), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderViewModel.kt new file mode 100644 index 000000000..1f9679de3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderViewModel.kt @@ -0,0 +1,267 @@ +package net.opendasharchive.openarchive.features.folders + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.core.UiImage +import net.opendasharchive.openarchive.features.core.dialog.ButtonData +import net.opendasharchive.openarchive.features.core.dialog.DialogConfig +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showSuccessDialog +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.util.DateUtils + +data class CreateNewFolderState( + val folderName: String = "", + val isValid: Boolean = false, + val error: UiText? = null, + // Creative Commons License state + val ccEnabled: Boolean = false, + val allowRemix: Boolean = false, + val requireShareAlike: Boolean = false, + val allowCommercial: Boolean = false, + val cc0Enabled: Boolean = false, + val licenseUrl: String? = null, + val isSaving: Boolean = false +) + +sealed interface CreateNewFolderAction { + data class UpdateFolderName(val name: String) : CreateNewFolderAction + data object CreateFolder : CreateNewFolderAction + data object Cancel : CreateNewFolderAction + + // Creative Commons License actions + data class UpdateCcEnabled(val enabled: Boolean) : CreateNewFolderAction + data class UpdateAllowRemix(val allowed: Boolean) : CreateNewFolderAction + data class UpdateRequireShareAlike(val required: Boolean) : CreateNewFolderAction + data class UpdateAllowCommercial(val allowed: Boolean) : CreateNewFolderAction + data class UpdateCc0Enabled(val enabled: Boolean) : CreateNewFolderAction +} + +sealed interface CreateNewFolderEvent { + data class ShowError(val message: UiText) : CreateNewFolderEvent +} + +class CreateNewFolderViewModel( + private val navigator: Navigator, + private val dialogManager: DialogStateManager, + private val spaceRepository: SpaceRepository, + private val projectRepository: ProjectRepository, +) : ViewModel() { + + companion object { + private val INVALID_CHARS = Regex("[\\\\/*\\s]") + } + + private val _uiState = MutableStateFlow(CreateNewFolderState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun onAction(action: CreateNewFolderAction) { + when (action) { + is CreateNewFolderAction.UpdateFolderName -> { + _uiState.update { + it.copy( + folderName = action.name, + isValid = action.name.trim().isNotEmpty(), + error = null + ) + } + } + + is CreateNewFolderAction.CreateFolder -> { + createFolder() + } + + is CreateNewFolderAction.Cancel -> navigator.navigateBack() + + is CreateNewFolderAction.UpdateCcEnabled -> { + _uiState.update { currentState -> + if (action.enabled) { + // When CC is enabled, start fresh with no options selected + currentState.copy( + ccEnabled = true, + cc0Enabled = false, + allowRemix = false, + requireShareAlike = false, + allowCommercial = false, + licenseUrl = null + ) + } else { + // When CC is disabled, reset all other CC options + currentState.copy( + ccEnabled = false, + allowRemix = false, + requireShareAlike = false, + allowCommercial = false, + cc0Enabled = false, + licenseUrl = null + ) + } + } + generateAndUpdateLicense() + } + + is CreateNewFolderAction.UpdateAllowRemix -> { + _uiState.update { currentState -> + currentState.copy( + allowRemix = action.allowed, + cc0Enabled = if (action.allowed) false else currentState.cc0Enabled, + requireShareAlike = if (!action.allowed) false else currentState.requireShareAlike + ) + } + generateAndUpdateLicense() + } + + is CreateNewFolderAction.UpdateRequireShareAlike -> { + _uiState.update { currentState -> + currentState.copy( + requireShareAlike = action.required, + cc0Enabled = if (action.required) false else currentState.cc0Enabled + ) + } + generateAndUpdateLicense() + } + + is CreateNewFolderAction.UpdateAllowCommercial -> { + _uiState.update { currentState -> + currentState.copy( + allowCommercial = action.allowed, + cc0Enabled = if (action.allowed) false else currentState.cc0Enabled + ) + } + generateAndUpdateLicense() + } + + is CreateNewFolderAction.UpdateCc0Enabled -> { + _uiState.update { currentState -> + if (action.enabled) { + // When CC0 is enabled, disable CC and reset all other options + currentState.copy( + cc0Enabled = true, + allowRemix = false, + requireShareAlike = false, + allowCommercial = false + ) + } else { + currentState.copy(cc0Enabled = false) + } + } + generateAndUpdateLicense() + } + } + } + + private fun createFolder() { + val name = _uiState.value.folderName.trim() + if (name.isBlank() || _uiState.value.isSaving) return + if (_uiState.value.isSaving) return + + // mark saving NOW to close the race window + _uiState.update { it.copy(isSaving = true) } + + viewModelScope.launch { + + try { + + if (INVALID_CHARS.containsMatchIn(name)) { + _events.send(CreateNewFolderEvent.ShowError(UiText.Resource(R.string.please_do_not_include_special_characters_in_the_name))) + return@launch + } + + val space = spaceRepository.getCurrentSpace() ?: return@launch + + if (projectRepository.getProjectByName(space.id, name) != null) { + _events.send(CreateNewFolderEvent.ShowError(UiText.Resource(R.string.folder_name_already_exists))) + return@launch + } + + val license = _uiState.value.licenseUrl ?: space.licenseUrl + + val archive = Archive( + description = name, + created = DateUtils.nowDateTime, + vaultId = space.id, + licenseUrl = license + ) + + val projectId = projectRepository.addProject(archive) + AppLogger.i("Created new project with id $projectId") + showFolderCreatedDialog() + + } finally { + _uiState.update { it.copy(isSaving = false) } + } + } + } + + private fun showFolderCreatedDialog() { + dialogManager.showDialog( + DialogConfig( + type = DialogType.Success, + title = UiText.Resource(R.string.label_success_title), + message = UiText.Resource(R.string.create_folder_ok_message), + icon = UiImage.DrawableResource(R.drawable.ic_done), + positiveButton = ButtonData( + text = UiText.Resource(R.string.label_got_it), + action = { navigator.navigateBack() } + ), + onDismissAction = { navigator.navigateBack() } + ) + ) + } + + private fun generateAndUpdateLicense() { + val currentState = _uiState.value + val newLicense = generateLicenseUrl( + ccEnabled = currentState.ccEnabled, + allowRemix = currentState.allowRemix, + requireShareAlike = currentState.requireShareAlike, + allowCommercial = currentState.allowCommercial, + cc0Enabled = currentState.cc0Enabled + ) + + _uiState.update { it.copy(licenseUrl = newLicense) } + } + + private fun generateLicenseUrl( + ccEnabled: Boolean, + allowRemix: Boolean, + requireShareAlike: Boolean, + allowCommercial: Boolean, + cc0Enabled: Boolean + ): String? { + if (!ccEnabled) return null + + if (cc0Enabled) { + return "https://creativecommons.org/publicdomain/zero/1.0/" + } + + val parts = mutableListOf() + + if (!allowCommercial) parts.add("nc") + if (!allowRemix) { + parts.add("nd") + } else if (requireShareAlike) { + parts.add("sa") + } + + val suffix = if (parts.isEmpty()) "" else "-${parts.joinToString("-")}" + return "https://creativecommons.org/licenses/by$suffix/4.0/" + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt deleted file mode 100644 index 3b21cb7fe..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt +++ /dev/null @@ -1,32 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive - -import com.google.gson.FieldNamingPolicy -import com.google.gson.Gson -import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.InternetArchiveLoginUseCase -import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.ValidateLoginCredentialsUseCase -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveLocalSource -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveRemoteSource -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.mapping.InternetArchiveMapper -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository.InternetArchiveRepository -import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsViewModel -import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginViewModel -import org.koin.core.module.dsl.viewModel -import org.koin.dsl.module - -typealias InternetArchiveGson = Gson - -val internetArchiveModule = module { - single { - Gson().newBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .create() - } - factory { ValidateLoginCredentialsUseCase() } - factory { InternetArchiveRemoteSource(get(), get()) } - single { InternetArchiveLocalSource() } - factory { InternetArchiveMapper() } - factory { InternetArchiveRepository(get(), get(), get()) } - factory { args -> InternetArchiveLoginUseCase(get(), get(), args.get(), get()) } - viewModel { InternetArchiveDetailsViewModel(get(), get()) } - viewModel { InternetArchiveLoginViewModel(get()) } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/model/InternetArchive.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/model/InternetArchive.kt deleted file mode 100644 index 843cefd63..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/model/InternetArchive.kt +++ /dev/null @@ -1,18 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.domain.model - -data class InternetArchive( - val meta: MetaData, - val auth: Auth -) { - data class MetaData( - val userName: String, - val screenName: String, - val email: String, - ) - - - data class Auth( - val access: String, - val secret: String, - ) -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt deleted file mode 100644 index 1f92c979b..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt +++ /dev/null @@ -1,44 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.domain.usecase - -import com.google.gson.Gson -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository.InternetArchiveRepository -import net.opendasharchive.openarchive.analytics.api.AnalyticsManager - -class InternetArchiveLoginUseCase( - private val repository: InternetArchiveRepository, - private val gson: Gson, - private val space: Space, - private val analyticsManager: AnalyticsManager, -) { - - suspend operator fun invoke(email: String, password: String): Result = - repository.login(email, password).mapCatching { response -> - - response.auth.let { auth -> - repository.testConnection(auth).getOrThrow() - space.username = auth.access - space.password = auth.secret - } - - // TODO: use local data source for database - space.metaData = gson.toJson(response.meta) - - // Check if this is a new backend or existing one - val isNewBackend = space.id == null || space.id == 0L - - space.save() - - Space.current = space - - // Track backend configuration - analyticsManager.trackBackendConfigured( - backendType = Space.Type.INTERNET_ARCHIVE.friendlyName, - isNew = isNewBackend - ) - - response - } - -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/ValidateLoginCredentialsUseCase.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/ValidateLoginCredentialsUseCase.kt deleted file mode 100644 index d8054ee80..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/ValidateLoginCredentialsUseCase.kt +++ /dev/null @@ -1,18 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.domain.usecase - -class ValidateLoginCredentialsUseCase { - - operator fun invoke(identifier: String, factor: String): Boolean { - return if (identifier.contains('@')) { - validateEmail(identifier) - } else { - validateUsername(identifier) - } && validatePassword(factor) - } - - private fun validateEmail(identifier: String) = identifier.isNotBlank() - - private fun validateUsername(identifier: String) = identifier.isNotBlank() - - private fun validatePassword(factor: String) = factor.isNotBlank() -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveLocalSource.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveLocalSource.kt deleted file mode 100644 index f3f76913a..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveLocalSource.kt +++ /dev/null @@ -1,20 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.update -import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive - -class InternetArchiveLocalSource { - // TODO: just use a memory cache for demo, will need to store in DB - // the database should be SQLCipher (https://www.zetetic.net/sqlcipher/) - // as we are storing access keys. Sugar record does not support sql cipher - // so planning a migration using local data sources. - private val cache = MutableStateFlow(null) - - fun set(value: InternetArchive) = cache.update { value } - - fun get() = cache.value - - fun subscribe() = cache.filterNotNull() -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveRemoteSource.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveRemoteSource.kt deleted file mode 100644 index 3e1bb47e7..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveRemoteSource.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource - -import android.content.Context -import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult -import net.opendasharchive.openarchive.features.internetarchive.InternetArchiveGson -import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginRequest -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginResponse -import net.opendasharchive.openarchive.services.SaveClient -import net.opendasharchive.openarchive.services.internetarchive.IaConduit.Companion.ARCHIVE_API_ENDPOINT -import okhttp3.FormBody -import okhttp3.Request - -private const val LOGIN_URI = "https://archive.org/services/xauthn?op=login" - -class InternetArchiveRemoteSource( - private val context: Context, - private val gson: InternetArchiveGson -) { - suspend fun login(request: InternetArchiveLoginRequest): Result = - SaveClient.get(context).enqueueResult( - Request.Builder() - .url(LOGIN_URI) - .post( - FormBody.Builder() - .add("email", request.email) - .add("password", request.password).build() - ) - .build() - ) { response -> - val data = gson.fromJson( - response.body?.string(), - InternetArchiveLoginResponse::class.java - ) - Result.success(data) - } - - suspend fun testConnection(auth: InternetArchive.Auth): Result = - SaveClient.get(context).enqueueResult( - Request.Builder() - .url(ARCHIVE_API_ENDPOINT) - .method("GET", null) - .addHeader("Authorization", "LOW ${auth.access}:${auth.secret}") - .build() - ) { response -> - Result.success(response.isSuccessful) - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/mapping/InternetArchiveMapper.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/mapping/InternetArchiveMapper.kt deleted file mode 100644 index 665403c7f..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/mapping/InternetArchiveMapper.kt +++ /dev/null @@ -1,20 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.infrastructure.mapping - -import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginResponse - -class InternetArchiveMapper { - - private operator fun invoke(response: InternetArchiveLoginResponse.S3) = InternetArchive.Auth( - access = response.access, secret = response.secret - ) - - operator fun invoke(response: InternetArchiveLoginResponse.Values) = InternetArchive( - meta = InternetArchive.MetaData( - userName = response.itemname ?: "", - email = response.email ?: "", - screenName = response.screenname ?: "" - ), - auth = response.s3?.let { invoke(it) } ?: InternetArchive.Auth("", "") - ) -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginRequest.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginRequest.kt deleted file mode 100644 index 9e9f98898..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginRequest.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.infrastructure.model - -data class InternetArchiveLoginRequest( - val email: String, - val password: String, -) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginResponse.kt deleted file mode 100644 index 287345306..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginResponse.kt +++ /dev/null @@ -1,20 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.infrastructure.model - -data class InternetArchiveLoginResponse( - val success: Boolean, - val values: Values, - val version: Int, -) { - data class Values( - val s3: S3? = null, - val screenname: String? = null, - val email: String? = null, - val itemname: String? = null, - val reason: String? = null, - ) - - data class S3( - val access: String, - val secret: String, - ) -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/UnauthenticatedException.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/UnauthenticatedException.kt deleted file mode 100644 index a803cb4b7..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/UnauthenticatedException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.infrastructure.model - -class UnauthenticatedException : RuntimeException() diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/repository/InternetArchiveRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/repository/InternetArchiveRepository.kt deleted file mode 100644 index cec01ebc9..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/repository/InternetArchiveRepository.kt +++ /dev/null @@ -1,36 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveLocalSource -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveRemoteSource -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.mapping.InternetArchiveMapper -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginRequest -import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.UnauthenticatedException - -class InternetArchiveRepository( - private val remoteSource: InternetArchiveRemoteSource, - private val localSource: InternetArchiveLocalSource, - private val mapper: InternetArchiveMapper -) { - suspend fun login(email: String, password: String): Result = - withContext(Dispatchers.IO) { - remoteSource.login( - InternetArchiveLoginRequest(email, password) - ).mapCatching { response -> - if (response.success.not()) { - throw IllegalArgumentException(response.values.reason) - } - when (response.version) { - else -> mapper(response.values) - } - }.onSuccess { localSource.set(it) } - } - - suspend fun testConnection(auth: InternetArchive.Auth): Result = - withContext(Dispatchers.IO) { - remoteSource.testConnection(auth) - .mapCatching { if (!it) throw UnauthenticatedException() } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginScreen.kt deleted file mode 100644 index 4309c4ef4..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginScreen.kt +++ /dev/null @@ -1,604 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation.login - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.LocalTextSelectionColors -import androidx.compose.foundation.text.selection.TextSelectionColors -import androidx.compose.runtime.remember -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.PlatformImeOptions -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.findNavController -import kotlinx.coroutines.delay -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors -import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.ToolbarConfigurable -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.util.NetworkUtils -import org.koin.androidx.compose.koinViewModel - - -class InternetArchiveLoginFragment : BaseFragment(), ToolbarConfigurable { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - return ComposeView(requireContext()).apply { - setContent { - SaveAppTheme { - InternetArchiveLoginScreen( - onLoginSuccess = { spaceId -> - val action = - InternetArchiveLoginFragmentDirections.actionFragmentInternetArchiveLoginToFragmentSetupLicense( - spaceId = spaceId, - isEditing = false, - spaceType = Space.Type.INTERNET_ARCHIVE - ) - findNavController().navigate(action) - }, - onCancel = { - findNavController().popBackStack() - } - ) - } - } - } - } - - override fun getToolbarTitle() = getString(R.string.internet_archive) - override fun shouldShowBackButton() = true -} - -@Composable -private fun InternetArchiveLoginScreen( - onLoginSuccess: (Long) -> Unit, - onCancel: () -> Unit -) { - val viewModel: InternetArchiveLoginViewModel = koinViewModel() - - val state by viewModel.uiState.collectAsStateWithLifecycle() - - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = {} - ) - - LaunchedEffect(Unit) { - viewModel.events.collect { event -> - when (event) { - is InternetArchiveLoginEvent.NavigateToSignup -> { - launcher.launch( - Intent( - Intent.ACTION_VIEW, "https://archive.org/account/signup".toUri() - ) - ) - } - - is InternetArchiveLoginEvent.NavigateBack -> onCancel() - - is InternetArchiveLoginEvent.LoginSuccess -> { - onLoginSuccess(event.spaceId) - } - - is InternetArchiveLoginEvent.LoginError -> { - // Error handling can be done here if needed - } - } - } - } - - InternetArchiveLoginContent(state, viewModel::onAction) -} - -@Composable -private fun InternetArchiveLoginContent( - state: InternetArchiveLoginState, - onAction: (InternetArchiveLoginAction) -> Unit -) { - - val context = LocalContext.current - val focusManager = LocalFocusManager.current - val passwordFocusRequester = remember { FocusRequester() } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(top = 32.dp, bottom = 16.dp) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - - InternetArchiveHeader( - modifier = Modifier - .padding(vertical = 48.dp) - .padding(end = 24.dp) - ) - - - - Box { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 16.dp) - ) { - Text( - stringResource(R.string.account), - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleLarge - ) - } - } - - CustomTextField( - value = state.username, - onValueChange = { - onAction(InternetArchiveLoginAction.ErrorClear) - onAction(InternetArchiveLoginAction.UpdateUsername(it)) - }, - label = stringResource(R.string.label_username), - placeholder = stringResource(R.string.prompt_email), - isError = state.isUsernameError, - isLoading = state.isBusy, - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - onImeAction = { - passwordFocusRequester.requestFocus() - } - ) - - Spacer(Modifier.height(ThemeDimensions.spacing.large)) - - CustomSecureField( - value = state.password, - onValueChange = { - onAction(InternetArchiveLoginAction.ErrorClear) - onAction(InternetArchiveLoginAction.UpdatePassword(it)) - }, - label = stringResource(R.string.label_password), - placeholder = stringResource(R.string.prompt_password), - isError = state.isPasswordError, - isLoading = state.isBusy, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - onImeAction = { - focusManager.clearFocus() - }, - modifier = Modifier.focusRequester(passwordFocusRequester) - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) { - AnimatedVisibility( - visible = state.isLoginError, - enter = fadeIn(), - exit = fadeOut() - ) { - Text( - text = stringResource(R.string.error_incorrect_email_or_password), - color = MaterialTheme.colorScheme.error - ) - } - } - - Spacer(Modifier.height(ThemeDimensions.spacing.large)) - Row( - modifier = Modifier - .padding(top = ThemeDimensions.spacing.small), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.prompt_no_account), - style = MaterialTheme.typography.bodyLarge.copy( // reuse your themed style - color = ThemeColors.material.onBackground, - fontWeight = FontWeight.SemiBold - ) - ) - TextButton( - modifier = Modifier.heightIn(ThemeDimensions.touchable), - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.tertiary - ), - onClick = { onAction(InternetArchiveLoginAction.CreateLogin) } - ) { - Text( - text = stringResource(R.string.label_create_login), - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.SemiBold - ) - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton( - modifier = Modifier - .padding(8.dp) - .heightIn(ThemeDimensions.touchable) - .weight(1f), - colors = ButtonDefaults.textButtonColors( - contentColor = colorResource(R.color.colorOnBackground) - ), - enabled = !state.isBusy, - shape = RoundedCornerShape(ThemeDimensions.roundedCorner), - onClick = { onAction(InternetArchiveLoginAction.Cancel) }) { - Text(stringResource(R.string.back), style = MaterialTheme.typography.titleLarge) - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - modifier = Modifier - .padding(8.dp) - .heightIn(ThemeDimensions.touchable) - .weight(1f), - enabled = !state.isBusy && state.isValid, - shape = RoundedCornerShape(ThemeDimensions.roundedCorner), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - disabledContainerColor = colorResource(R.color.grey_50), - disabledContentColor = colorResource(R.color.black), - contentColor = colorResource(R.color.black) - ), - onClick = { - if (NetworkUtils.isNetworkAvailable(context)) { - onAction(InternetArchiveLoginAction.Login) - } else { - Toast.makeText(context, R.string.error_no_internet, Toast.LENGTH_LONG) - .show() - } - }, - ) { - if (state.isBusy) { - CircularProgressIndicator(color = ThemeColors.material.primary) - } else { - Text( - stringResource(R.string.next), - style = MaterialTheme.typography.titleLarge - ) - } - } - } - } -} - -@Composable -@Preview -@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) -private fun InternetArchiveLoginPreview() { - DefaultScaffoldPreview { - InternetArchiveLoginContent( - state = InternetArchiveLoginState( - username = "", - password = "", - isLoginError = true, - isPasswordError = true, - isUsernameError = true - ), - onAction = {} - ) - } -} - -@Composable -fun CustomTextField( - modifier: Modifier = Modifier, - value: String, - onValueChange: (String) -> Unit, - label: String, - enabled: Boolean = true, - placeholder: String? = null, - isError: Boolean = false, - isLoading: Boolean = false, - keyboardType: KeyboardType = KeyboardType.Text, - imeAction: ImeAction = ImeAction.Next, - onFocusChange: ((Boolean) -> Unit)? = null, - onImeAction: (() -> Unit)? = null, -) { - - val customTextSelectionColors = TextSelectionColors( - handleColor = MaterialTheme.colorScheme.tertiary, - backgroundColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.4f) - ) - CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) { - OutlinedTextField( - modifier = modifier - .fillMaxWidth() - .let { mod -> - onFocusChange?.let { callback -> - mod.onFocusChanged { callback(it.isFocused) } - } ?: mod - }, - value = value, - enabled = !isLoading && enabled, - onValueChange = onValueChange, - placeholder = { - placeholder?.let { - Text( - text = placeholder, - style = MaterialTheme.typography.bodyMedium.copy( - fontStyle = FontStyle.Italic, - fontSize = 13.sp, - fontFamily = MontserratFontFamily - ) - ) - } - }, - singleLine = true, - shape = RoundedCornerShape(ThemeDimensions.roundedCorner), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = keyboardType, - imeAction = imeAction, - platformImeOptions = PlatformImeOptions(), - showKeyboardOnFocus = true, - hintLocales = null - ), - keyboardActions = KeyboardActions( - onDone = { - onImeAction?.invoke() - }, - onNext = { - onImeAction?.invoke() - }, - onGo = { - onImeAction?.invoke() - }, - onSearch = { - onImeAction?.invoke() - }, - onSend = { - onImeAction?.invoke() - } - ), - isError = isError, - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.background, - unfocusedContainerColor = MaterialTheme.colorScheme.background, - focusedBorderColor = MaterialTheme.colorScheme.tertiary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - cursorColor = MaterialTheme.colorScheme.tertiary, - //focusedIndicatorColor = Color.Transparent, - //unfocusedIndicatorColor = Color.Transparent, - ), - ) - } -} - -@Composable -fun CustomSecureField( - modifier: Modifier = Modifier, - value: String, - onValueChange: (String) -> Unit, - label: String, - placeholder: String, - isError: Boolean = false, - isLoading: Boolean = false, - keyboardType: KeyboardType, - imeAction: ImeAction, - onImeAction: (() -> Unit)? = null, -) { - - var showPassword by rememberSaveable { mutableStateOf(false) } - - OutlinedTextField( - modifier = modifier.fillMaxWidth(), - value = value, - enabled = !isLoading, - onValueChange = onValueChange, - placeholder = { - Text( - text = placeholder, - style = MaterialTheme.typography.bodyMedium.copy( - fontStyle = FontStyle.Italic, - fontSize = 13.sp, - fontFamily = MontserratFontFamily - ) - ) - }, - singleLine = true, - shape = RoundedCornerShape(ThemeDimensions.roundedCorner), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = keyboardType, - imeAction = imeAction, - platformImeOptions = PlatformImeOptions(), - showKeyboardOnFocus = true, - hintLocales = null - ), - keyboardActions = KeyboardActions( - onDone = { - onImeAction?.invoke() - }, - onNext = { - onImeAction?.invoke() - }, - onGo = { - onImeAction?.invoke() - }, - onSearch = { - onImeAction?.invoke() - }, - onSend = { - onImeAction?.invoke() - } - ), - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - isError = isError, - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.background, - unfocusedContainerColor = MaterialTheme.colorScheme.background, - focusedBorderColor = MaterialTheme.colorScheme.tertiary, - cursorColor = MaterialTheme.colorScheme.tertiary - //focusedIndicatorColor = Color.Transparent, - //unfocusedIndicatorColor = Color.Transparent, - ), - trailingIcon = { - IconButton( - enabled = !isLoading, - modifier = Modifier.sizeIn(ThemeDimensions.touchable), - onClick = { showPassword = !showPassword }) { - - val (iconRes, cd) = - if (showPassword) { - R.drawable.ic_visibility_off to - "Hide password" // ideally a stringResource(...) - } else { - R.drawable.ic_visibility to - "Show password" - } - - Icon( - painter = painterResource(iconRes), - contentDescription = cd - ) - } - }, - ) -} - - -@Composable -fun ButtonBar( - modifier: Modifier = Modifier, - backButtonText: UiText = UiText.StringResource(R.string.back), - nextButtonText: UiText = UiText.StringResource(R.string.next), - isBackEnabled: Boolean = false, - isNextEnabled: Boolean = false, - isLoading: Boolean = false, - onBack: () -> Unit, - onNext: () -> Unit -) { - Row( - modifier = modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton( - modifier = Modifier - .padding(8.dp) - .heightIn(ThemeDimensions.touchable) - .weight(1f), - colors = ButtonDefaults.textButtonColors( - contentColor = colorResource(R.color.colorOnBackground) - ), - enabled = isBackEnabled, - shape = RoundedCornerShape(ThemeDimensions.roundedCorner), - onClick = onBack - ) { - Text(backButtonText.asString()) - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - modifier = Modifier - .padding(8.dp) - .heightIn(ThemeDimensions.touchable) - .weight(1f), - enabled = isNextEnabled, - shape = RoundedCornerShape(ThemeDimensions.roundedCorner), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - disabledContainerColor = colorResource(R.color.grey_50), - disabledContentColor = colorResource(R.color.extra_light_grey)//MaterialTheme.colorScheme.onBackground - ), - onClick = onNext, - ) { - if (isLoading) { - CircularProgressIndicator(color = ThemeColors.material.primary) - } else { - Text( - nextButtonText.asString(), - style = MaterialTheme.typography.labelLarge - ) - } - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt index 61d94621b..d9ed4328b 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt @@ -1,247 +1,173 @@ package net.opendasharchive.openarchive.features.main -import android.Manifest import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.os.Bundle -import android.view.View +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat +import androidx.activity.enableEdgeToEdge import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.fragment.app.FragmentActivity -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.features.main.ui.HomeScreen -import net.opendasharchive.openarchive.features.main.ui.HomeViewModel +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.features.core.BaseComposeActivity +import net.opendasharchive.openarchive.features.main.ui.Navigator import net.opendasharchive.openarchive.features.main.ui.SaveNavGraph -import net.opendasharchive.openarchive.features.media.AddMediaType -import net.opendasharchive.openarchive.features.media.MediaLaunchers -import net.opendasharchive.openarchive.features.media.Picker -import net.opendasharchive.openarchive.features.media.camera.CameraConfig -import net.opendasharchive.openarchive.features.settings.passcode.AppConfig +import net.opendasharchive.openarchive.core.config.AppConfig +import net.opendasharchive.openarchive.features.settings.passcode.PasscodeGate +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService +import net.opendasharchive.openarchive.util.PermissionManager import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import timber.log.Timber -import kotlin.getValue +import org.koin.android.scope.AndroidScopeComponent +import org.koin.androidx.scope.activityRetainedScope +import net.opendasharchive.openarchive.features.main.ui.SharedImportState +import net.opendasharchive.openarchive.upload.UploadJobScheduler +import net.opendasharchive.openarchive.util.C2paHelper -class HomeActivity: FragmentActivity() { +class HomeActivity : BaseComposeActivity(), AndroidScopeComponent { - private val appConfig by inject() - private val viewModel by viewModel() - - // We'll hold a reference to the media launchers registered with Picker. - private lateinit var mediaLaunchers: MediaLaunchers + override val scope by activityRetainedScope() - private val mNewFolderResultLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - //TODO: Refresh projects in MainViewModel - } - } - - private val folderResultLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - val selectedFolderId:Long? = result.data?.getLongExtra("SELECTED_FOLDER_ID", -1) - if (selectedFolderId != null && selectedFolderId > -1) { - navigateToFolder(selectedFolderId) - } - } - } + private val appConfig by inject() + private val navigator by inject() + private val uploadJobScheduler by inject() + private val passcodeGate by inject() + private val sharedImportState by inject() + private lateinit var permissionManager: PermissionManager - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (isGranted) { - Timber.d("Able to post notifications") - } else { - Timber.d("Need to explain") - } - } + /** URIs received via share sheet while the app was locked — delivered after authentication. */ + private var pendingSharedUris: List? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Register PasscodeGate on the process lifecycle so onStop only fires when the + // entire app goes to background — not during Activity-to-Activity transitions + // (e.g. PasscodeEntryActivity / PasscodeSetupActivity launching over HomeActivity). + ProcessLifecycleOwner.get().lifecycle.addObserver(passcodeGate) + installSplashScreen() - // Perform any intent processing (e.g. deep-links or shared media) - handleIntent(intent) + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + lightScrim = getColor(R.color.colorTertiary), + darkScrim = getColor(R.color.colorTertiary) + ), + navigationBarStyle = SystemBarStyle.auto( + lightScrim = getColor(R.color.colorTertiary), + darkScrim = getColor(R.color.colorTertiary) + ) + ) - // Check notification permission (for Android 13+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - checkNotificationPermissions() + setContent { + SaveAppTheme { + SaveNavGraph( + dialogManager, + navigator + ) + } } - // Get a reference to a view to serve as the root for Snackbars, etc. - val rootView: View = findViewById(android.R.id.content) - - // Register media launchers via Picker. - // The lambda for 'project' should return the currently selected project. - // For now, this stub returns null—you should wire it to your actual selection. - mediaLaunchers = Picker.register( - activity = this, - root = rootView, - project = { getCurrentProject() }, - completed = { media -> - // For example, refresh the current project UI and preview media. - refreshCurrentProject() - if (media.isNotEmpty()) { - previewMedia() - } + if (appConfig.isDwebEnabled) { + permissionManager = PermissionManager(this, dialogManager) + permissionManager.checkNotificationPermission { + AppLogger.i("Notification permission granted") } - ) + handleIntent(intent) + startForegroundService(Intent(this, SnowbirdService::class.java)) + } - // Set up your Compose UI and pass callbacks. - setContent { - SaveNavGraph( - context = this@HomeActivity, - onExit = { - finish() - }, - viewModel = viewModel, - onNewFolder = { launchNewFolder() }, - onFolderSelected = { folderId -> navigateToFolder(folderId) }, - onAddMedia = { mediaType -> addMediaClicked(mediaType) } - ) + if (savedInstanceState == null) { + importSharedMedia(intent) } } - /** - * Returns the currently selected project. - * Replace this stub with your actual project–retrieval logic. - */ - private fun getCurrentProject(): Project? { - // TODO: Return your current project from a ViewModel or other state. - return null + override fun onStart() { + super.onStart() + C2paHelper.init(this) + uploadJobScheduler.schedule() + + // Flush any share URIs that arrived while the app was locked + lifecycleScope.launch { + passcodeGate.locked + .filter { !it } + .take(1) + .collect { + pendingSharedUris?.let { uris -> + sharedImportState.setPendingUris(uris) + pendingSharedUris = null + } + } + } } - /** - * Refresh UI details for the current project. - */ - private fun refreshCurrentProject() { - // TODO: Update your UI state, refresh fragment content, etc. + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + importSharedMedia(intent) } - /** - * Launch a preview after media import. - */ - private fun previewMedia() { - // TODO: Launch your preview activity or update the UI as needed. + // ----- Permissions & Intent Handling ----- + private fun handleIntent(intent: Intent) { + if (intent.action == Intent.ACTION_VIEW) { + intent.data?.takeIf { it.scheme == "save-veilid" }?.let { processUri(it) } + } } - /** - * Launch the AddFolderActivity using your folder launcher. - */ - private fun launchNewFolder() { - // Example: startActivity(Intent(this, AddFolderActivity::class.java)) - // Or, if you have a registered launcher, use it here. + private fun processUri(uri: Uri) { + val path = uri.path + val queryParams = uri.queryParameterNames.associateWith { uri.getQueryParameter(it) } + AppLogger.d("Path: $path, QueryParams: $queryParams") } - /** - * Navigate to a folder after selection. - */ - private fun navigateToFolder(folderId: Long) { - // TODO: Update your navigation or fragment state to display the selected folder. - } + private fun importSharedMedia(intent: Intent?) { + if (intent == null) { AppLogger.d("SHARE_DEBUG: intent null, skipping"); return } + val action = intent.action + val type = intent.type + AppLogger.d("SHARE_DEBUG: importSharedMedia action=$action type=$type") + + // Defense-in-depth: reject MIME types not explicitly supported (mirrors manifest intent-filters) + val allowedMimeTypes = setOf( + "image/jpeg", "image/png", "image/gif", "image/webp", + "image/heic", "image/heif", "image/tiff", "image/bmp", + "video/mp4", "video/quicktime", "video/x-msvideo", + "video/x-matroska", "video/webm", "video/3gpp", + "audio/mpeg", "audio/aac", "audio/flac", "audio/ogg", + "audio/wav", "audio/x-wav", "audio/mp4", "audio/opus", + "application/pdf" + ) + if (type != null && type !in allowedMimeTypes) { + AppLogger.d("SHARE_DEBUG: MIME type $type not allowed, returning") + return + } - /** - * Handle "Add Media" events from the Compose UI. - */ - private fun addMediaClicked(mediaType: AddMediaType) { - if (getCurrentProject() != null) { - // If you wish to show hints or dialogs before picking media, - // insert that logic here (e.g., check Prefs.addMediaHint). - when (mediaType) { - AddMediaType.CAMERA -> { - if (appConfig.useCustomCamera) { - // Use custom camera with photo and video support - val cameraConfig = CameraConfig( - allowVideoCapture = true, - allowPhotoCapture = true, - allowMultipleCapture = false, // Single capture for main screen - enablePreview = true, - showFlashToggle = true, - showGridToggle = true, - showCameraSwitch = true - ) - Picker.launchCustomCamera( - this, - mediaLaunchers.customCameraLauncher, - cameraConfig - ) - } else { - - Picker.takePhotoModern( - activity = this@HomeActivity, - launcher = mediaLaunchers.modernCameraLauncher - ) + if ((Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) && type != null) { + val uris = mutableListOf() - } - } - AddMediaType.GALLERY -> { - // Launch the gallery/image picker. - Picker.pickMedia(mediaLaunchers.galleryLauncher) - } - AddMediaType.FILES -> { - // Launch the file picker. - Picker.pickFiles(mediaLaunchers.filePickerLauncher) - } + if (Intent.ACTION_SEND == action) { + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uris.add(it) } + } else { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let { uris.addAll(it) } } - } else { - // If no project is selected, prompt the user to create one (e.g. add a folder). - launchNewFolder() - } - } - /** - * Check for POST_NOTIFICATIONS permission on Android 13+. - */ - private fun checkNotificationPermissions() { - if (ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - ) { - Timber.d("Notification permission already granted") - } else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { - showNotificationPermissionRationale() - } else { - requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } + AppLogger.d("SHARE_DEBUG: uris.size=${uris.size} locked=${passcodeGate.locked.value}") - /** - * Show a rationale for notification permission. - */ - private fun showNotificationPermissionRationale() { - // TODO: Display a dialog or Snackbar explaining why notifications are needed. - Timber.d("Showing notification permission rationale") - } - - /** - * Handle incoming intents for deep-linking, shared media, etc. - */ - private fun handleIntent(intent: Intent?) { - intent?.let { receivedIntent -> - when (receivedIntent.action) { - Intent.ACTION_VIEW -> { - val uri = receivedIntent.data - if (uri?.scheme == "save-veilid") { - processUri(uri) - } + if (uris.isNotEmpty()) { + if (passcodeGate.locked.value) { + AppLogger.d("SHARE_DEBUG: app locked, storing pending uris") + pendingSharedUris = uris + } else { + AppLogger.d("SHARE_DEBUG: calling sharedImportState.setPendingUris") + sharedImportState.setPendingUris(uris) } - // Optionally handle other actions (like ACTION_SEND) here. } + } else { + AppLogger.d("SHARE_DEBUG: action/type mismatch, not ACTION_SEND. action=$action type=$type") } } - - private fun processUri(uri: Uri) { - // Process the URI similarly to your original logic. - Timber.d("Processing URI: $uri") - // TODO: Extract path, query parameters, etc. - } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt deleted file mode 100644 index 2fa7c01ce..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt +++ /dev/null @@ -1,162 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import android.app.Activity -import android.content.IntentSender -import android.view.View -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.IntentSenderRequest -import com.google.android.material.snackbar.Snackbar -import com.google.android.play.core.appupdate.AppUpdateInfo -import com.google.android.play.core.appupdate.AppUpdateManager -import com.google.android.play.core.appupdate.AppUpdateManagerFactory -import com.google.android.play.core.appupdate.AppUpdateOptions -import com.google.android.play.core.install.InstallStateUpdatedListener -import com.google.android.play.core.install.model.AppUpdateType -import com.google.android.play.core.install.model.InstallStatus -import com.google.android.play.core.install.model.UpdateAvailability -import net.opendasharchive.openarchive.BuildConfig -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger - -/** Handles Google Play in-app update flows and keeps MainActivity lean. */ -internal class InAppUpdateCoordinator( - private val activity: Activity, - private val rootView: View, - private val updateLauncher: ActivityResultLauncher -) { - - private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(activity) - private var installStateListener: InstallStateUpdatedListener? = null - private var flexibleUpdateSnackbar: Snackbar? = null - - fun onResume() { - checkForAppUpdates() - } - - fun onDestroy() { - installStateListener?.let(appUpdateManager::unregisterListener) - installStateListener = null - flexibleUpdateSnackbar?.dismiss() - flexibleUpdateSnackbar = null - } - - private fun checkForAppUpdates() { - appUpdateManager.appUpdateInfo - .addOnSuccessListener { info -> - when (info.installStatus()) { - InstallStatus.DOWNLOADED -> { - showFlexibleUpdateDownloadedSnackbar() - return@addOnSuccessListener - } - - InstallStatus.INSTALLED -> dismissFlexibleUpdateSnackbar() - else -> Unit - } - - when (info.updateAvailability()) { - UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> { - if (info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)) { - startUpdateFlow(info, AppUpdateType.IMMEDIATE) - } - } - - UpdateAvailability.UPDATE_AVAILABLE -> handleUpdateAvailability(info) - else -> Unit - } - } - .addOnFailureListener { throwable -> - AppLogger.w("Failed to load in-app update info", throwable) - } - } - - private fun handleUpdateAvailability(appUpdateInfo: AppUpdateInfo) { - val availableVersionCode = appUpdateInfo.availableVersionCode() - if (availableVersionCode == null) { - AppLogger.w("In-app update available but availableVersionCode is null") - return - } - - val versionGap = availableVersionCode - BuildConfig.VERSION_CODE - if (versionGap <= 0) { - AppLogger.d("No newer version detected for in-app update flow. Current gap: $versionGap") - return - } - - val immediateAllowed = versionGap >= IMMEDIATE_UPDATE_VERSION_GAP && - appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) - val flexibleAllowed = versionGap <= FLEXIBLE_UPDATE_MAX_GAP && - appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) - - when { - immediateAllowed -> { - AppLogger.i("Triggering immediate update flow. Version gap: $versionGap") - startUpdateFlow(appUpdateInfo, AppUpdateType.IMMEDIATE) - } - - flexibleAllowed -> { - AppLogger.i("Triggering flexible update flow. Version gap: $versionGap") - registerFlexibleUpdateListener() - startUpdateFlow(appUpdateInfo, AppUpdateType.FLEXIBLE) - } - - appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> { - AppLogger.i( - "Falling back to flexible update flow despite larger gap. Version gap: $versionGap" - ) - registerFlexibleUpdateListener() - startUpdateFlow(appUpdateInfo, AppUpdateType.FLEXIBLE) - } - - else -> AppLogger.w("Update available but no compatible update types allowed on this device") - } - } - - private fun registerFlexibleUpdateListener() { - if (installStateListener != null) return - - installStateListener = InstallStateUpdatedListener { state -> - when (state.installStatus()) { - InstallStatus.DOWNLOADED -> showFlexibleUpdateDownloadedSnackbar() - InstallStatus.INSTALLED -> dismissFlexibleUpdateSnackbar() - else -> Unit - } - } - - installStateListener?.let(appUpdateManager::registerListener) - } - - private fun showFlexibleUpdateDownloadedSnackbar() { - if (flexibleUpdateSnackbar?.isShown == true) return - - flexibleUpdateSnackbar = Snackbar.make( - rootView, - R.string.in_app_update_ready, - Snackbar.LENGTH_INDEFINITE - ).setAction(R.string.in_app_update_restart) { - dismissFlexibleUpdateSnackbar() - appUpdateManager.completeUpdate() - } - - flexibleUpdateSnackbar?.show() - } - - private fun dismissFlexibleUpdateSnackbar() { - flexibleUpdateSnackbar?.dismiss() - flexibleUpdateSnackbar = null - } - - private fun startUpdateFlow(appUpdateInfo: AppUpdateInfo, updateType: Int) { - val options = AppUpdateOptions.newBuilder(updateType).build() - - try { - appUpdateManager.startUpdateFlowForResult(appUpdateInfo, updateLauncher, options) - } catch (exception: IntentSender.SendIntentException) { - AppLogger.e("Failed to launch in-app update flow", exception) - } - } - - private companion object { - const val IMMEDIATE_UPDATE_VERSION_GAP = 3 - const val FLEXIBLE_UPDATE_MAX_GAP = 2 - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt deleted file mode 100644 index 626f56eb8..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt +++ /dev/null @@ -1,1338 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.graphics.Point -import android.graphics.drawable.ColorDrawable -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.view.Gravity -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.LinearLayout -import android.widget.PopupWindow -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.core.view.WindowInsetsCompat -import androidx.drawerlayout.widget.DrawerLayout -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.snackbar.Snackbar -import com.google.android.play.core.review.ReviewManager -import com.google.android.play.core.review.ReviewManagerFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.BuildConfig -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.ActivityMainBinding -import net.opendasharchive.openarchive.databinding.PopupFolderOptionsBinding -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.extensions.getMeasurments -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.UiImage -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.asUiImage -import net.opendasharchive.openarchive.features.core.asUiText -import net.opendasharchive.openarchive.features.core.dialog.ButtonData -import net.opendasharchive.openarchive.features.core.dialog.DialogConfig -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.features.core.dialog.showInfoDialog -import net.opendasharchive.openarchive.features.main.adapters.FolderDrawerAdapter -import net.opendasharchive.openarchive.features.main.adapters.FolderDrawerAdapterListener -import net.opendasharchive.openarchive.features.main.adapters.SpaceDrawerAdapter -import net.opendasharchive.openarchive.features.main.adapters.SpaceDrawerAdapterListener -import net.opendasharchive.openarchive.features.media.AddMediaDialogFragment -import net.opendasharchive.openarchive.features.media.AddMediaType -import net.opendasharchive.openarchive.features.media.ContentPickerFragment -import net.opendasharchive.openarchive.features.media.MediaLaunchers -import net.opendasharchive.openarchive.features.media.Picker -import net.opendasharchive.openarchive.features.media.PreviewActivity -import net.opendasharchive.openarchive.features.media.camera.CameraConfig -import net.opendasharchive.openarchive.features.onboarding.Onboarding23Activity -import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity -import net.opendasharchive.openarchive.features.onboarding.StartDestination -import net.opendasharchive.openarchive.features.settings.passcode.AppConfig -import net.opendasharchive.openarchive.services.snowbird.SnowbirdActivity -import net.opendasharchive.openarchive.services.snowbird.SnowbirdBridge -import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService -import net.opendasharchive.openarchive.upload.UploadManagerFragment -import net.opendasharchive.openarchive.upload.UploadService -import net.opendasharchive.openarchive.util.InAppReviewHelper -import net.opendasharchive.openarchive.util.PermissionManager -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.ProofModeHelper -import net.opendasharchive.openarchive.util.extensions.Position -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets -import net.opendasharchive.openarchive.util.extensions.cloak -import net.opendasharchive.openarchive.util.extensions.hide -import net.opendasharchive.openarchive.util.extensions.scaleAndTintDrawable -import net.opendasharchive.openarchive.util.extensions.show -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import java.text.NumberFormat - - -class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAdapterListener { - - private val appConfig by inject() - private val viewModel by viewModel() - - private var mMenuDelete: MenuItem? = null - - private var mSnackBar: Snackbar? = null - - var uploadManagerFragment: UploadManagerFragment? = null - - private lateinit var binding: ActivityMainBinding - private lateinit var mPagerAdapter: ProjectAdapter - private lateinit var mSpaceAdapter: SpaceDrawerAdapter - private lateinit var mFolderAdapter: FolderDrawerAdapter - - private lateinit var mediaLaunchers: MediaLaunchers - - private var mSelectedPageIndex: Int = 0 - private var mSelectedMediaPageIndex: Int = 0 - private var serverListOffset: Float = 0F - private var serverListCurOffset: Float = 0F - - private var selectModeToggle: Boolean = false - private var selectedMediaCount = 0 - private var pendingAddAction: AddMediaType? = null - private var pendingAddScroll = false - private var pendingAddPicker = false - private var restoredPageIndex: Int? = null // Only used for configuration changes - private var hasRestoredPage = false // Track if we've already restored the page - private var isRestoringState = false // Flag to suppress onPageSelected during restoration - - private enum class FolderBarMode { INFO, SELECTION, EDIT } - - // Hold the current mode (default to INFO) - private var folderBarMode = FolderBarMode.INFO - - // Current page getter/setter (updates bottom navbar accordingly) - private var mCurrentPagerItem - get() = binding.contentMain.pager.currentItem - set(value) { - binding.contentMain.pager.currentItem = value - updateBottomNavbar(value) - } - - // ----- Activity Result Launchers ----- - private val mNewFolderResultLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - refreshProjects(it.data?.getLongExtra(SpaceSetupActivity.EXTRA_FOLDER_ID, -1)) - } - } - - private lateinit var permissionManager: PermissionManager - - private lateinit var reviewManager: ReviewManager - private var shouldPromptReview = false - private var shouldCheckForUpdate = false - - private var inAppUpdateCoordinator: InAppUpdateCoordinator? = null - - private val inAppUpdateLauncher = - registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - if (result.resultCode != Activity.RESULT_OK) { - AppLogger.w("In-app update flow failed or cancelled: ${result.resultCode}") - } - } - - override fun onCreate(savedInstanceState: Bundle?) { -// enableEdgeToEdge() - super.onCreate(savedInstanceState) -// WindowCompat.setDecorFitsSystemWindows(window, false) - installSplashScreen() - - // Check onboarding status early and redirect if needed - if (!Prefs.didCompleteOnboarding) { - val intent = Intent(this, Onboarding23Activity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - finish() - return - } - - // Restore page index if this is a configuration change (e.g., theme change) - restoredPageIndex = savedInstanceState?.getInt(KEY_SELECTED_PAGE) - - AppLogger.i("MainActivity onCreate - restoredPageIndex: $restoredPageIndex") - - // If this is a FRESH START (not a configuration change), clear the settings flag - // This ensures fresh app starts ALWAYS go to a media page, never settings - if (savedInstanceState == null) { - AppLogger.i("MainActivity onCreate - Fresh start detected, clearing settings flag") - Prefs.putBooleanSync("is_on_settings_page_temp", false) - } - -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { -// window.insetsController?.let { -// it.hide(WindowInsets.Type.statusBars()) -// it.hide(WindowInsets.Type.systemBars()) -// it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE -// } -// } else { -// // For older versions, use the deprecated approach -// window.setFlags( -// WindowManager.LayoutParams.FLAG_FULLSCREEN, -// WindowManager.LayoutParams.FLAG_FULLSCREEN -// ) -// } -// -// window.apply { -// clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) -// addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) -// statusBarColor = ContextCompat.getColor(this@MainActivity, R.color.colorPrimary) -// // optional. if you want the icons to be light. -// decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR -// } - - - binding = ActivityMainBinding.inflate(layoutInflater) - -// binding.contentMain.imgLogo.applyEdgeToEdgeInsets { insets -> -// leftMargin = insets.left -// rightMargin = insets.right -// } - - binding.contentMain.bottomNavBar.applyEdgeToEdgeInsets(WindowInsetsCompat.Type.navigationBars()) { insets -> - bottomMargin = insets.bottom - } - - binding.btnAddFolder.applyEdgeToEdgeInsets { insets -> - bottomMargin = insets.bottom - } - - binding.drawerContent.applyEdgeToEdgeInsets { insets -> - bottomMargin = insets.bottom - } - - - setContentView(binding.root) - - // Initialize the permission manager with this activity and its dialogManager. - permissionManager = PermissionManager(this, dialogManager) - - // Initialize In App Ratings Helper - InAppReviewHelper.init(this) - - initMediaLaunchers() - setupToolbarAndPager() - setupNavigationDrawer() - setupBottomNavBar() - setupFolderBar() - setupBottomSheetObserver() - - inAppUpdateCoordinator = InAppUpdateCoordinator( - activity = this, - rootView = binding.root, - updateLauncher = inAppUpdateLauncher - ) - - - if (appConfig.isDwebEnabled) { - permissionManager.checkNotificationPermission { - AppLogger.i("Notification permission granted") - } - SnowbirdBridge.getInstance().initialize() - startForegroundService(Intent(this, SnowbirdService::class.java)) - handleIntent(intent) - } - - - if (BuildConfig.DEBUG) { - binding.contentMain.imgLogo.setOnLongClickListener { - startActivity(Intent(this, HomeActivity::class.java)) - true - } - } - - supportFragmentManager.setFragmentResultListener("uploadRetry", this) { key, bundle -> - val mediaId = bundle.getLong("mediaId") - // Now you know which media item is being retried. - // You can start the upload service or update the UI accordingly. - UploadService.startUploadService(this) - } - - supportFragmentManager.setFragmentResultListener( - ContentPickerFragment.KEY_DISMISS, - this - ) { _, _ -> - // when the sheet goes away, show your arrow - getCurrentMediaFragment()?.setArrowVisible(true) - } - - reviewManager = ReviewManagerFactory.create(this) - InAppReviewHelper.requestReviewInfo(this, analyticsManager) - shouldPromptReview = InAppReviewHelper.onAppLaunched() - - // Set flag to check for app updates on first onResume - shouldCheckForUpdate = Prefs.didCompleteOnboarding - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - // Save current page for configuration changes (like theme changes) - outState.putInt(KEY_SELECTED_PAGE, mSelectedPageIndex) - } - - override fun onResume() { - super.onResume() - AppLogger.i("MainActivity onResume is called.......") - - // Set flag to suppress onPageSelected from saving preferences during restoration - isRestoringState = true - - refreshSpace() - - // Only restore the page on the FIRST onResume after onCreate - // This prevents overriding manual navigation (like folder selection from drawer) - if (!hasRestoredPage) { - // Check if we were on the settings page before activity recreation - val wasOnSettings = Prefs.getBoolean("is_on_settings_page_temp", false) - - AppLogger.i("MainActivity onResume - wasOnSettings: $wasOnSettings, restoredPageIndex: $restoredPageIndex") - - // Determine which page to show: - if (wasOnSettings) { - // We were on settings - go back to settings (index may have changed if projects changed) - AppLogger.i("MainActivity onResume - restoring to settings page: ${mPagerAdapter.settingsIndex}") - mSelectedPageIndex = mPagerAdapter.settingsIndex - - // Also restore the last media page index for the "My Media" button - mSelectedMediaPageIndex = Prefs.currentHomePage.coerceIn(0, mPagerAdapter.settingsIndex - 1) - AppLogger.i("MainActivity onResume - also setting mSelectedMediaPageIndex to: $mSelectedMediaPageIndex") - } else if (restoredPageIndex != null) { - // Configuration change with a specific page - restore it - AppLogger.i("MainActivity onResume - restoring to saved page: $restoredPageIndex") - // Don't cap at settingsIndex - 1, allow restoring to settings page too - mSelectedPageIndex = restoredPageIndex!!.coerceIn(0, mPagerAdapter.itemCount - 1) - // Only update mSelectedMediaPageIndex if we're on a media page - if (mSelectedPageIndex < mPagerAdapter.settingsIndex) { - mSelectedMediaPageIndex = mSelectedPageIndex - } - } else { - // Fresh start - go to last media page - val page = Prefs.currentHomePage.coerceIn(0, mPagerAdapter.settingsIndex - 1) - AppLogger.i("MainActivity onResume - fresh start, going to page: $page") - mSelectedPageIndex = page - mSelectedMediaPageIndex = page - } - - AppLogger.i("MainActivity onResume - setting page to: $mSelectedPageIndex") - - // Set page WITHOUT animation to avoid ViewPager2 settling on wrong page - binding.contentMain.pager.setCurrentItem(mSelectedPageIndex, false) - updateBottomNavbar(mSelectedPageIndex) - - // Clear the restored page index after use - restoredPageIndex = null - - // Mark that we've restored the page so we don't do it again - hasRestoredPage = true - - // DON'T clear the is_on_settings_page_temp flag here! - // It should only be cleared when the user manually navigates to a media page - // This allows multiple theme toggles while staying on settings - } else { - AppLogger.i("MainActivity onResume - page already restored, using current: $mSelectedPageIndex") - } - - // Clear the restoration flag - from now on, page changes should save preferences normally - isRestoringState = false - - importSharedMedia(intent) - if (serverListOffset == 0F) { - val dims = binding.rvSpaces.getMeasurments() - serverListOffset = -dims.second.toFloat() - serverListCurOffset = serverListOffset - } - - // ───────────────────────────────────────────────────────────────────────── - // Only now, after UI is ready, do we fire the in‐app review if needed. - if (shouldPromptReview) { - lifecycleScope.launch(Dispatchers.Main) { - // Wait a small delay so we don't interrupt initial load (e.g. 2 seconds). - delay(2_000) - InAppReviewHelper.showReviewIfPossible(this@MainActivity, reviewManager, analyticsManager) - InAppReviewHelper.markReviewDone() - shouldPromptReview = false - } - } - // ───────────────────────────────────────────────────────────────────────── - - // ───────────────────────────────────────────────────────────────────────── - // Check for in-app updates after UI is fully loaded and stable. - if (shouldCheckForUpdate) { - lifecycleScope.launch(Dispatchers.Main) { - // Wait longer to ensure all UI initialization is complete (e.g. 3 seconds). - delay(3_000) - inAppUpdateCoordinator?.onResume() - shouldCheckForUpdate = false - } - } - // ───────────────────────────────────────────────────────────────────────── - } - - @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - override fun onStart() { - super.onStart() - - // Initialize ProofMode on background thread to avoid ANR during RSA key generation - lifecycleScope.launch(Dispatchers.IO) { - ProofModeHelper.init(this@MainActivity) { - // Check for any queued uploads and restart, only after ProofMode is correctly initialized. - UploadService.startUploadService(this@MainActivity) - } - } - } - - // ----- Initialization Methods ----- - private fun initMediaLaunchers() { - mediaLaunchers = Picker.register( - activity = this, - root = binding.root, - project = { getSelectedProject() }, - completed = { media -> - refreshCurrentProject() - if (media.isNotEmpty()) navigateToPreview() - } - ) - } - - private fun setupToolbarAndPager() { - setSupportActionBar(binding.contentMain.toolbar) - supportActionBar?.apply { - setDisplayHomeAsUpEnabled(false) - title = null - } - - mPagerAdapter = ProjectAdapter(supportFragmentManager, lifecycle) - binding.contentMain.pager.adapter = mPagerAdapter - - binding.contentMain.pager.registerOnPageChangeCallback(object : - ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - mSelectedPageIndex = position - - val isOnSettings = position == mPagerAdapter.settingsIndex - - // Only save preferences if we're not in the middle of state restoration - // This prevents refreshProjects() from overwriting the saved page during theme changes - if (!isRestoringState) { - if (isOnSettings) { - // On settings page - save this fact with commit() for immediate write - // This is critical for theme changes which recreate the activity immediately - Prefs.putBooleanSync("is_on_settings_page_temp", true) - } else { - // On a media page - save the page index and clear the settings flag - Prefs.currentHomePage = position - Prefs.putBooleanSync("is_on_settings_page_temp", false) - mSelectedMediaPageIndex = position - val selectedProject = getSelectedProject() - mFolderAdapter.updateSelectedProject(selectedProject) - } - } else { - // During restoration, still update the selected project but don't save preferences - if (!isOnSettings) { - mSelectedMediaPageIndex = position - val selectedProject = getSelectedProject() - mFolderAdapter.updateSelectedProject(selectedProject) - } - } - - if (!appConfig.multipleProjectSelectionMode) { - getCurrentMediaFragment()?.cancelSelection() - } - updateBottomNavbar(position) - refreshCurrentProject() - // If we navigated from settings to perform an add action, run it now. - if (pendingAddAction != null && position < mPagerAdapter.settingsIndex) { - val action = pendingAddAction - pendingAddAction = null - pendingAddScroll = false - action?.let { addClicked(it) } - } - if (pendingAddPicker && position < mPagerAdapter.settingsIndex) { - pendingAddPicker = false - openAddPickerSheet() - } - } - }) - } - - private fun setupNavigationDrawer() { - // Drawer listener resets state on close - binding.drawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener { - override fun onDrawerClosed(drawerView: View) { - collapseSpacesList() - } - - override fun onDrawerOpened(drawerView: View) { - // - } - - override fun onDrawerSlide(drawerView: View, slideOffset: Float) { - // - } - - override fun onDrawerStateChanged(newState: Int) { - // - } - }) - - binding.navigationDrawerHeader.setOnClickListener { toggleSpacesList() } - binding.dimOverlay.setOnClickListener { collapseSpacesList() } - - mSpaceAdapter = SpaceDrawerAdapter(this) - binding.rvSpaces.layoutManager = LinearLayoutManager(this) - binding.rvSpaces.adapter = mSpaceAdapter - - mFolderAdapter = FolderDrawerAdapter(this) - binding.rvFolders.layoutManager = LinearLayoutManager(this) - binding.rvFolders.adapter = mFolderAdapter - - binding.btnAddFolder.scaleAndTintDrawable(Position.Start, 0.75) - binding.btnAddFolder.setOnClickListener { - closeDrawer() - navigateToAddFolder() - } - - updateCurrentSpaceAtDrawer() - } - - private fun setupBottomNavBar() { - with(binding.contentMain.bottomNavBar) { - onMyMediaClick = { - mCurrentPagerItem = mSelectedMediaPageIndex - } - // TODO: Avoid launching multiple pickers on rapid repeated taps. - onAddClick = { - if (mSelectedPageIndex >= mPagerAdapter.settingsIndex) { - val mediaProject = getLastKnownMediaProject() - when { - Space.current == null -> navigateToAddServer() - mediaProject == null -> navigateToAddFolder() - else -> navigateToMediaPageForAdd(AddMediaType.GALLERY) - } - } else { - addClicked(AddMediaType.GALLERY) - } - } - onSettingsClick = { - // Clear settings scroll position when navigating to Settings - Prefs.putInt("settings_scroll_position", 0) - mCurrentPagerItem = mPagerAdapter.settingsIndex - } - - if (Picker.canPickFiles(this@MainActivity)) { - setAddButtonLongClickEnabled() - onAddLongClick = { - if (mSelectedPageIndex >= mPagerAdapter.settingsIndex) { - val mediaProject = getLastKnownMediaProject() - when { - Space.current == null -> navigateToAddServer() - mediaProject == null -> navigateToAddFolder() - else -> navigateToMediaPageForPicker() // Jump back to media page and then open picker. - } - } else if (Space.current == null) { - navigateToAddServer() - } else if (getSelectedProject() == null) { - navigateToAddFolder() - } else { - openAddPickerSheet() - } - } - supportFragmentManager.setFragmentResultListener( - AddMediaDialogFragment.RESP_TAKE_PHOTO, this@MainActivity - ) { _, _ -> addClicked(AddMediaType.CAMERA) } - supportFragmentManager.setFragmentResultListener( - AddMediaDialogFragment.RESP_PHOTO_GALLERY, this@MainActivity - ) { _, _ -> addClicked(AddMediaType.GALLERY) } - supportFragmentManager.setFragmentResultListener( - AddMediaDialogFragment.RESP_FILES, this@MainActivity - ) { _, _ -> addClicked(AddMediaType.FILES) } - } - } - } - - private fun setupFolderBar() { - // Tapping the edit button shows the folder options popup. - binding.contentMain.btnEdit.setOnClickListener { btnView -> - val location = IntArray(2) - binding.contentMain.btnEdit.getLocationOnScreen(location) - val point = Point(location[0], location[1]) - showFolderOptionsPopup(point) - } - // In selection mode, cancel selection reverts to INFO mode. - binding.contentMain.btnCancelSelection.setOnClickListener { - setFolderBarMode(FolderBarMode.INFO) - getCurrentMediaFragment()?.cancelSelection() - } - // In the edit (rename) container, cancel button reverts to INFO mode. - binding.contentMain.btnCancelEdit.setOnClickListener { - hideKeyboard() - setFolderBarMode(FolderBarMode.INFO) - } - // Listen for the "done" action to commit a rename. - binding.contentMain.etFolderName.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE) { - val newName = binding.contentMain.etFolderName.text.toString().trim() - if (newName.isNotEmpty()) { - renameCurrentFolder(newName) - hideKeyboard() - setFolderBarMode(FolderBarMode.INFO) - } else { - Snackbar.make( - binding.root, - getString(R.string.folder_empty_warning), - Snackbar.LENGTH_SHORT - ).show() - } - true - } else false - } - - binding.contentMain.btnRemoveSelected.setOnClickListener { - showDeleteSelectedMediaConfirmDialog() - } - } - - // Called when a new folder name is confirmed. (Adjust as needed to update your data store.) - private fun renameCurrentFolder(newName: String) { - val project = getSelectedProject() - project?.let { - it.description = newName - it.save() - refreshCurrentProject() - Snackbar.make( - binding.root, - getString(R.string.folder_rename_success), - Snackbar.LENGTH_SHORT - ).show() - } - } - - private fun showFolderOptionsPopup(p: Point) { - val layoutInflater = getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater - val popupBinding = PopupFolderOptionsBinding.inflate(layoutInflater) - val popup = PopupWindow(this).apply { - contentView = popupBinding.root - width = LinearLayout.LayoutParams.WRAP_CONTENT - height = LinearLayout.LayoutParams.WRAP_CONTENT - isFocusable = true - setBackgroundDrawable(ColorDrawable()) - animationStyle = R.style.popup_window_animation - } - - // Check if there is at least one media item in the selected project - val hasMedia = getSelectedProject()?.collections?.any { it.media.isNotEmpty() } == true - - // Disable select media if no media in current folder - popupBinding.menuFolderBarSelectMedia.isEnabled = hasMedia - popupBinding.menuFolderBarSelectMedia.alpha = if (hasMedia) 1.0f else 0.4f - - // Option to toggle selection mode - popupBinding.menuFolderBarSelectMedia.setOnClickListener { - popup.dismiss() - setFolderBarMode(FolderBarMode.SELECTION) - getCurrentMediaFragment()?.enableSelectionMode() - } - - // Rename folder - popupBinding.menuFolderBarRenameFolder.setOnClickListener { - popup.dismiss() - setFolderBarMode(FolderBarMode.EDIT) - } - - // Archive folder - popupBinding.menuFolderBarArchiveFolder.setOnClickListener { - popup.dismiss() - val selectedProject = getSelectedProject() - if (selectedProject != null) { - selectedProject.isArchived = !selectedProject.isArchived - selectedProject.save() - refreshProjects() - updateCurrentFolderVisibility() - refreshCurrentProject() - Snackbar.make( - binding.root, - getString(R.string.folder_archived), - Snackbar.LENGTH_SHORT - ).show() - } else { - Snackbar.make( - binding.root, - getString(R.string.folder_not_found), - Snackbar.LENGTH_LONG - ).show() - } - } - - // Remove folder - popupBinding.menuFolderBarRemove.setOnClickListener { - popup.dismiss() - if (getSelectedProject() != null) { - showDeleteFolderConfirmDialog() - } else { - Snackbar.make( - binding.root, - getString(R.string.folder_not_found), - Snackbar.LENGTH_LONG - ).show() - } - } - - // Adjust popup position if needed - val x = 200 - val y = 60 - popup.showAtLocation(binding.root, Gravity.NO_GRAVITY, p.x + x, p.y + y) - } - - fun setSelectionMode(isSelecting: Boolean) { - if (isSelecting) { - setFolderBarMode(FolderBarMode.SELECTION) - } else { - setFolderBarMode(FolderBarMode.INFO) - } - } - - // Update the selected count and show/hide the remove button accordingly - fun updateSelectedCount(count: Int) { - selectedMediaCount = count - updateRemoveButtonVisibility() - } - - private fun updateRemoveButtonVisibility() { - if (folderBarMode == FolderBarMode.SELECTION) { - binding.contentMain.btnRemoveSelected.visibility = - if (selectedMediaCount > 0) View.VISIBLE else View.GONE - } - } - - private fun showDeleteSelectedMediaConfirmDialog() { - dialogManager.showDialog( - config = DialogConfig( - type = DialogType.Warning, - title = R.string.menu_delete.asUiText(), - message = R.string.menu_delete_desc.asUiText(), - icon = UiImage.DrawableResource(R.drawable.ic_trash), - positiveButton = ButtonData( - text = R.string.lbl_ok.asUiText(), - action = { - getCurrentMediaFragment()?.deleteSelected() - updateSelectedCount(0) - refreshCurrentFolderCount() - } - ), - neutralButton = - ButtonData( - text = UiText.StringResource(R.string.lbl_Cancel), - action = { - - } - ) - ) - ) - } - - private fun showDeleteFolderConfirmDialog() { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Error - icon = UiImage.DrawableResource(R.drawable.ic_trash) - title = UiText.StringResource(R.string.remove_from_app) - message = UiText.StringResource(R.string.action_remove_project) - destructiveButton { - text = UiText.StringResource(R.string.lbl_remove) - action = { - getSelectedProject()?.delete() - refreshProjects() - updateCurrentFolderVisibility() - refreshCurrentProject() - Snackbar.make( - binding.root, - getString(R.string.folder_removed), - Snackbar.LENGTH_SHORT - ).show() - } - } - neutralButton { - text = UiText.StringResource(R.string.lbl_Cancel) - action = { - dialogManager.dismissDialog() - } - } - } - } - - private fun getCurrentMediaFragment(): MainMediaFragment? { - val currentItem = binding.contentMain.pager.currentItem - return supportFragmentManager.findFragmentByTag("f$currentItem") as? MainMediaFragment - } - - - // ----- Drawer Helpers ----- - private fun toggleDrawerState() { - if (binding.drawerLayout.isDrawerOpen(binding.drawerContent)) { - closeDrawer() - } else { - openDrawer() - } - } - - private fun openDrawer() { - if (binding.drawerLayout.getDrawerLockMode(binding.drawerContent) == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { - return - } - binding.drawerLayout.openDrawer(binding.drawerContent) - } - - private fun closeDrawer() { - binding.drawerLayout.closeDrawer(binding.drawerContent) - } - - private fun toggleSpacesList() { - if (serverListCurOffset == serverListOffset) { - expandSpacesList() - } else { - collapseSpacesList() - } - } - - private fun expandSpacesList() { - serverListCurOffset = 0f - binding.spaceListMore.setImageDrawable( - ContextCompat.getDrawable(this, R.drawable.ic_expand_less) - ) - binding.rvSpaces.visibility = View.VISIBLE - binding.dimOverlay.visibility = View.VISIBLE - binding.rvSpaces.bringToFront() - binding.dimOverlay.bringToFront() - binding.rvSpaces.animate() - .translationY(0f).alpha(1f).setDuration(200) - .withStartAction { - binding.spacesHeaderSeparator.alpha = 0.3f - binding.rvFolders.alpha = 0.3f - binding.btnAddFolder.alpha = 0.3f - } - binding.dimOverlay.animate().alpha(1f).setDuration(200) - binding.navigationDrawerHeader.elevation = 8f - } - - private fun collapseSpacesList() { - serverListCurOffset = serverListOffset - binding.spaceListMore.setImageDrawable( - ContextCompat.getDrawable(this, R.drawable.ic_expand_more) - ) - - binding.rvSpaces.animate() - .translationY(serverListOffset).alpha(0f).setDuration(200) - .withEndAction { - binding.rvSpaces.visibility = View.GONE - binding.dimOverlay.visibility = View.GONE - binding.spacesHeaderSeparator.alpha = 1f - binding.rvFolders.alpha = 1f - binding.btnAddFolder.alpha = 1f - } - binding.dimOverlay.animate().alpha(0f).duration = 200L - binding.navigationDrawerHeader.elevation = 0f - } - - private fun updateCurrentSpaceAtDrawer() { - Space.current?.setAvatar(binding.currentSpaceIcon) - mSpaceAdapter.notifyDataSetChanged() - - } - - // ----- Refresh & Update Methods ----- - /** - * Updates the visibility of the current folder container. - * The container is only visible if: - * 1. We are not on the settings page AND - * 2. There is a current space with at least one project. - */ - // Central function to update folder bar state - private fun setFolderBarMode(mode: FolderBarMode) { - folderBarMode = mode - when (mode) { - FolderBarMode.INFO -> { - binding.contentMain.folderInfoContainer.visibility = View.VISIBLE - binding.contentMain.folderSelectionContainer.visibility = View.GONE - binding.contentMain.folderEditContainer.visibility = View.GONE - - if (Space.current != null) { - if (Space.current?.projects?.isNotEmpty() == true) { - binding.contentMain.folderInfoContainerRight.visibility = View.VISIBLE - } else { - binding.contentMain.folderInfoContainerRight.visibility = View.INVISIBLE - } - } else { - binding.contentMain.folderInfoContainerRight.visibility = View.INVISIBLE - } - } - - FolderBarMode.SELECTION -> { - binding.contentMain.folderInfoContainer.visibility = View.GONE - binding.contentMain.folderSelectionContainer.visibility = View.VISIBLE - binding.contentMain.folderEditContainer.visibility = View.GONE - updateRemoveButtonVisibility() - } - - FolderBarMode.EDIT -> { - binding.contentMain.folderInfoContainer.visibility = View.GONE - binding.contentMain.folderSelectionContainer.visibility = View.GONE - binding.contentMain.folderEditContainer.visibility = View.VISIBLE - // Prepopulate the rename field with the current folder name - binding.contentMain.etFolderName.setText(getSelectedProject()?.description ?: "") - - // Use postDelayed to ensure view is fully ready, especially on first load - binding.contentMain.etFolderName.postDelayed({ - if (binding.contentMain.etFolderName.requestFocus()) { - binding.contentMain.etFolderName.selectAll() - - // Show the keyboard - val imm = - binding.contentMain.etFolderName.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput( - binding.contentMain.etFolderName, - InputMethodManager.SHOW_IMPLICIT - ) - } - }, 100) - } - } - } - - private fun hideKeyboard() { - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(binding.contentMain.etFolderName.windowToken, 0) - binding.contentMain.etFolderName.clearFocus() - } - - private fun updateCurrentFolderVisibility() { - val currentPagerIndex = binding.contentMain.pager.currentItem - val settingsIndex = mPagerAdapter.settingsIndex - if (currentPagerIndex == settingsIndex) { - binding.contentMain.folderBar.hide() - // Reset to default mode - setFolderBarMode(FolderBarMode.INFO) - - // Force ViewPager2 to re-measure its layout after visibility change - binding.contentMain.pager.post { - binding.contentMain.pager.requestLayout() - } - } else { - binding.contentMain.folderBar.show() - setFolderBarMode(FolderBarMode.INFO) - } - - mFolderAdapter.notifyDataSetChanged() - } - - private fun updateBottomNavbar(position: Int) { - val isSettings = position == mPagerAdapter.settingsIndex - binding.contentMain.bottomNavBar.updateSelectedItem(isSettings = isSettings) - updateCurrentFolderVisibility() - invalidateOptionsMenu() - } - - private fun refreshSpace() { - val currentSpace = Space.current - if (currentSpace != null) { - binding.spaceNameLayout.visibility = View.VISIBLE - binding.currentSpaceName.text = currentSpace.friendlyName - binding.btnAddFolder.visibility = View.VISIBLE - updateCurrentSpaceAtDrawer() - currentSpace.setAvatar(binding.contentMain.spaceIcon) - } else { - binding.contentMain.spaceIcon.visibility = View.INVISIBLE - binding.spaceNameLayout.visibility = View.INVISIBLE - binding.btnAddFolder.visibility = View.INVISIBLE - closeDrawer() - } - refreshSpaceListAtDrawer() - updateCurrentSpaceAtDrawer() - refreshProjects() - refreshCurrentProject() - updateCurrentFolderVisibility() - invalidateOptionsMenu() - } - - private fun refreshSpaceListAtDrawer() { - val spaces = Space.getAll().asSequence().toList() - val hasDwebGroups = SnowbirdGroup.getAll().isNotEmpty() - mSpaceAdapter.update(spaces, hasDwebGroups) - } - - private fun refreshProjects(setProjectId: Long? = null) { - // Save current page index before refreshing - val currentPageIndex = binding.contentMain.pager.currentItem - - val projects = Space.current?.projects ?: emptyList() - mPagerAdapter.updateData(projects) - - // Must reassign adapter for FragmentStateAdapter to properly refresh fragments - // This is necessary for upload progress UI to update - binding.contentMain.pager.adapter = mPagerAdapter - - // Determine target page index - val targetIndex = if (setProjectId != null) { - // If a specific project was requested, navigate to it - mPagerAdapter.getProjectIndexById(setProjectId, default = 0) - } else { - // Otherwise, preserve the current page (don't reset to 0) - currentPageIndex.coerceIn(0, maxOf(0, projects.size - 1)) - } - - // Set page without animation to avoid flicker - binding.contentMain.pager.setCurrentItem(targetIndex, false) - updateBottomNavbar(targetIndex) - - mFolderAdapter.update(projects) - } - - private fun refreshCurrentProject() { - val project = getSelectedProject() - - if (project != null) { - binding.contentMain.pager.post { - mPagerAdapter.notifyProjectChanged(project) - } - binding.contentMain.folderInfoContainer.visibility = View.VISIBLE - project.space?.setAvatar(binding.contentMain.spaceIcon) - binding.contentMain.folderName.text = project.description - binding.contentMain.folderNameArrow.visibility = View.VISIBLE - binding.contentMain.folderName.visibility = View.VISIBLE - } else { - binding.contentMain.folderNameArrow.visibility = View.INVISIBLE - binding.contentMain.folderName.visibility = View.INVISIBLE - } - updateCurrentFolderVisibility() - refreshCurrentFolderCount() - } - - private fun refreshCurrentFolderCount() { - val project = getSelectedProject() - - if (project != null) { - val count = project.collections.map { it.size } - .reduceOrNull { acc, count -> acc + count } ?: 0 - binding.contentMain.itemCount.text = NumberFormat.getInstance().format(count) - if (!selectModeToggle) { - binding.contentMain.itemCount.show() - } - } else { - binding.contentMain.itemCount.cloak() - } - } - - // ----- Navigation & Media Handling ----- - private fun navigateToAddServer() { - closeDrawer() - startActivity(Intent(this, SpaceSetupActivity::class.java)) - } - - private fun navigateToAddFolder() { - val intent = Intent(this, SpaceSetupActivity::class.java) - if (Space.current?.tType == Space.Type.INTERNET_ARCHIVE) { - // We cannot browse the Internet Archive. Directly forward to creating a project, - // as it doesn't make sense to show a one-option menu. - intent.putExtra( - SpaceSetupActivity.LABEL_START_DESTINATION, - StartDestination.ADD_NEW_FOLDER.name - ) - } else { - intent.putExtra( - SpaceSetupActivity.LABEL_START_DESTINATION, - StartDestination.ADD_FOLDER.name - ) - } - mNewFolderResultLauncher.launch(intent) - } - - private fun addClicked(mediaType: AddMediaType) { - - when { - getSelectedProject() != null -> { - if (Prefs.addMediaHint) { - when (mediaType) { - AddMediaType.CAMERA -> { - if (appConfig.useCustomCamera) { - // Use custom camera instead of system camera - val cameraConfig = CameraConfig( - allowVideoCapture = true, - allowPhotoCapture = true, - allowMultipleCapture = false, - enablePreview = true, - showFlashToggle = true, - showGridToggle = true, - showCameraSwitch = true - ) - Picker.launchCustomCamera( - this@MainActivity, - mediaLaunchers.customCameraLauncher, - cameraConfig - ) - } else { - permissionManager.checkCameraPermission { - Picker.takePhotoModern( - activity = this@MainActivity, - launcher = mediaLaunchers.modernCameraLauncher - ) - } - } - } - - AddMediaType.GALLERY -> { - permissionManager.checkMediaPermissions { - Picker.pickMedia(mediaLaunchers.galleryLauncher) - } - } - - AddMediaType.FILES -> Picker.pickFiles(mediaLaunchers.filePickerLauncher) - } - } else { - dialogManager.showInfoDialog( - icon = R.drawable.ic_media_new.asUiImage(), - title = R.string.press_and_hold_options_media_screen_title.asUiText(), - message = R.string.press_and_hold_options_media_screen_message.asUiText(), - onDone = { - Prefs.addMediaHint = true - addClicked(mediaType) - } - ) - } - } - - Space.current == null -> navigateToAddServer() - else -> navigateToAddFolder() - } - } - - private fun importSharedMedia(imageIntent: Intent?) { - if (imageIntent?.action != Intent.ACTION_SEND) return - val uri = - imageIntent.data ?: imageIntent.clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.uri - val path = uri?.path ?: return - if (path.contains(packageName)) return - - mSnackBar?.show() - lifecycleScope.launch(Dispatchers.IO) { - //When we are sharing a file to be uploaded to Save app we don't generate proof. - val media = Picker.import(this@MainActivity, getSelectedProject(), uri, false) - lifecycleScope.launch(Dispatchers.Main) { - mSnackBar?.dismiss() - intent = null - if (media != null) { - navigateToPreview() - } - } - } - } - - private fun navigateToPreview() { - val projectId = getSelectedProject()?.id ?: return - PreviewActivity.start(this, projectId) - } - - // ----- Permissions & Intent Handling ----- - private fun handleIntent(intent: Intent) { - if (intent.action == Intent.ACTION_VIEW) { - intent.data?.takeIf { it.scheme == "save-veilid" }?.let { processUri(it) } - } - } - - private fun processUri(uri: Uri) { - val path = uri.path - val queryParams = uri.queryParameterNames.associateWith { uri.getQueryParameter(it) } - AppLogger.d("Path: $path, QueryParams: $queryParams") - } - - // ----- Overrides ----- - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_main, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - val hasDwebGroup = SnowbirdGroup.getAll().isNotEmpty() - val shouldShowSideMenu = - ((Space.current != null || hasDwebGroup) && mCurrentPagerItem != mPagerAdapter.settingsIndex) - - menu?.findItem(R.id.menu_folders)?.apply { - isVisible = shouldShowSideMenu - } - - binding.drawerLayout.setDrawerLockMode( - if (shouldShowSideMenu) DrawerLayout.LOCK_MODE_UNLOCKED - else DrawerLayout.LOCK_MODE_LOCKED_CLOSED - ) - - if (!shouldShowSideMenu) { - closeDrawer() - } - return super.onPrepareOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.menu_folders -> { - toggleDrawerState() - true - } - - else -> super.onOptionsItemSelected(item) - } - } - - // ----- Adapter Listeners ----- - override fun onProjectSelected(project: Project) { - binding.drawerLayout.closeDrawer(binding.drawerContent) - mCurrentPagerItem = mPagerAdapter.projects.indexOf(project) - } - - override fun getSelectedProject(): Project? { - return mPagerAdapter.getProject(mCurrentPagerItem) - } - - override fun onSpaceSelected(space: Space) { - Space.current = space - refreshSpace() - updateCurrentSpaceAtDrawer() - collapseSpacesList() - binding.drawerLayout.closeDrawer(binding.drawerContent) - } - - override fun onAddNewSpace() { - collapseSpacesList() - closeDrawer() - val intent = Intent(this, SpaceSetupActivity::class.java) - startActivity(intent) - } - - override fun getSelectedSpace(): Space? { - val currentSpace = Space.current - AppLogger.i("current space requested by adapter... = $currentSpace") - return Space.current - } - - override fun onNavigateToDwebGroups() { - collapseSpacesList() - closeDrawer() - val intent = Intent(this, SnowbirdActivity::class.java) - startActivity(intent) - } - - /** - * Show the UploadManagerFragment as a Bottom Sheet. - * Ensures we only show one instance. - */ - fun showUploadManagerFragment() { - if (uploadManagerFragment == null) { - uploadManagerFragment = UploadManagerFragment() - uploadManagerFragment?.show(supportFragmentManager, UploadManagerFragment.TAG) - - // Stop the upload service when the bottom sheet is shown - UploadService.stopUploadService(this) - } - } - - /** - * Setup a listener to detect when the UploadManagerFragment is dismissed. - * If there are pending uploads, restart the UploadService. - */ - private fun setupBottomSheetObserver() { - supportFragmentManager.addFragmentOnAttachListener { _, fragment -> - if (fragment is UploadManagerFragment) { - uploadManagerFragment = fragment - - // Observe when it gets dismissed - fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - uploadManagerFragment = null // Clear reference - - // Check if there are pending uploads - if (Media.getByStatus( - listOf(Media.Status.Queued, Media.Status.Uploading), - Media.ORDER_PRIORITY - ).isNotEmpty() - ) { - UploadService.startUploadService(this@MainActivity) - } - } - }) - } - } - } - - private fun navigateToMediaPageForAdd(action: AddMediaType) { - // If already on a media page, perform immediately. - if (mSelectedPageIndex < mPagerAdapter.settingsIndex) { - addClicked(action) - return - } - - pendingAddAction = action - pendingAddScroll = true - binding.contentMain.pager.setCurrentItem(mSelectedMediaPageIndex, true) - } - - private fun navigateToMediaPageForPicker() { - if (mSelectedPageIndex < mPagerAdapter.settingsIndex) { - openAddPickerSheet() - return - } - pendingAddPicker = true - binding.contentMain.pager.setCurrentItem(mSelectedMediaPageIndex, true) - } - - private fun openAddPickerSheet() { - if (Space.current == null || getSelectedProject() == null) return - getCurrentMediaFragment()?.setArrowVisible(false) - val addMediaBottomSheet = ContentPickerFragment { actionType -> addClicked(actionType) } - addMediaBottomSheet.show(supportFragmentManager, ContentPickerFragment.TAG) - } - - // Returns the last visited media page's project (used while sitting on Settings). - private fun getLastKnownMediaProject(): Project? { - if (mPagerAdapter.projects.isEmpty()) return null - val safeIndex = mSelectedMediaPageIndex.coerceIn(0, mPagerAdapter.projects.lastIndex) - return mPagerAdapter.projects.getOrNull(safeIndex) - } - - override fun onDestroy() { - inAppUpdateCoordinator?.onDestroy() - super.onDestroy() - - // Clear pending callbacks/messages - window?.decorView?.handler?.removeCallbacksAndMessages(null) - } - - companion object { - // Define request codes - const val REQUEST_CAMERA_PERMISSION = 100 - const val REQUEST_FILE_MEDIA = 101 - - // Key for saving/restoring page index during configuration changes - private const val KEY_SELECTED_PAGE = "selected_page_index" - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainMediaFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainMediaFragment.kt deleted file mode 100644 index 0ad2ba510..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainMediaFragment.kt +++ /dev/null @@ -1,356 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.FragmentMainMediaBinding -import net.opendasharchive.openarchive.databinding.ViewSectionBinding -import net.opendasharchive.openarchive.db.Collection -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.features.main.adapters.MainMediaAdapter -import net.opendasharchive.openarchive.upload.BroadcastManager -import net.opendasharchive.openarchive.upload.UploadService -import net.opendasharchive.openarchive.util.extensions.toggle -import org.koin.androidx.viewmodel.ext.android.activityViewModel -import kotlin.collections.set - -class MainMediaFragment : BaseFragment() { - - companion object { - private const val COLUMN_COUNT = 3 - private const val ARG_PROJECT_ID = "project_id" - - fun newInstance(projectId: Long): MainMediaFragment { - val args = Bundle() - args.putLong(ARG_PROJECT_ID, projectId) - - val fragment = MainMediaFragment() - fragment.arguments = args - - return fragment - } - } - - private val viewModel by activityViewModel() - - private var mAdapters = HashMap() - private var mSection = HashMap() - private var mProjectId = -1L - private var mCollections = mutableMapOf() - - private var selectedMediaIds = mutableSetOf() - private var isSelecting = false - private var selectionHasActiveItems = false - - private lateinit var binding: FragmentMainMediaBinding - - private val mMessageReceiver: BroadcastReceiver = object : BroadcastReceiver() { - private val handler = Handler(Looper.getMainLooper()) - override fun onReceive(context: Context, intent: Intent) { - val action = BroadcastManager.getAction(intent) ?: return - - when (action) { - BroadcastManager.Action.Change -> { - handler.post { - updateProjectItem( - collectionId = action.collectionId, - mediaId = action.mediaId, - progress = action.progress, - isUploaded = action.isUploaded - ) - } - } - - BroadcastManager.Action.Delete -> { - handler.post { - refresh() - } - } - } - } - } - - override fun onStart() { - super.onStart() - BroadcastManager.register(requireContext(), mMessageReceiver) - } - - override fun onStop() { - super.onStop() - BroadcastManager.unregister(requireContext(), mMessageReceiver) - } - - override fun onPause() { - cancelSelection() - super.onPause() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mProjectId = arguments?.getLong(ARG_PROJECT_ID, -1) ?: -1 - - binding = FragmentMainMediaBinding.inflate(inflater, container, false) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.log("MainMediaFragment onCreateView called for project Id $mProjectId") - - val space = Space.current - val text: String = if (space != null) { - val projects = space.projects - if (projects.isNotEmpty()) { - getString(R.string.tap_to_add) - } else { - getString(R.string.tap_to_add_folder) - } - } else { - getString(R.string.tap_to_add_server) - } - - binding.tvWelcomeDescr.text = text - - if (space != null) { - binding.tvWelcome.visibility = View.INVISIBLE - } else { - binding.tvWelcome.visibility = View.VISIBLE - } - - - refresh() - } - - fun updateProjectItem(collectionId: Long, mediaId: Long, progress: Int, isUploaded: Boolean) { - AppLogger.i("Current progress for $collectionId: ", progress) - mAdapters[collectionId]?.apply { - viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { - updateItem(mediaId, progress, isUploaded) - if (progress == -1) { - updateHeader(collectionId, media) - } - } - } - } - - private fun updateHeader(collectionId: Long, media: ArrayList) { - lifecycleScope.launch(Dispatchers.IO) { - Collection.get(collectionId)?.let { collection -> - mCollections[collectionId] = collection - withContext(Dispatchers.Main) { - mSection[collectionId]?.setHeader(collection, media) - } - } - } - } - - fun refresh() { - mCollections = Collection.getByProject(mProjectId).associateBy { it.id }.toMutableMap() - - // Remove all sections, which' collections don't exist anymore. - val toDelete = mAdapters.keys.filter { id -> - mCollections.containsKey(id).not() - }.toMutableList() - - mCollections.forEach { (id, collection) -> - val media = collection.media - - // Also remove all empty collections. - if (media.isEmpty()) { - toDelete.add(id) - return@forEach - } - - val adapter = mAdapters[id] - val holder = mSection[id] - - if (adapter != null) { - adapter.updateData(media) - holder?.setHeader(collection, media) - } else if (media.isNotEmpty()) { - val view = createMediaList(collection, media) - - binding.mediaContainer.addView(view, 0) - } - } - - // DO NOT delete the collection here, this could lead to a race condition - // while adding images. - deleteCollections(toDelete, false) - - binding.addMediaHint.toggle(mCollections.isEmpty()) - } - - fun enableSelectionMode() { - isSelecting = true - selectionHasActiveItems = false - mAdapters.values.forEach { it.selecting = true } - updateSelectionState() - } - - fun cancelSelection() { - isSelecting = false - selectionHasActiveItems = false - selectedMediaIds.clear() - mAdapters.values.forEach { it.clearSelections() } - updateSelectionCount() - } - - fun deleteSelected() { - val toDelete = ArrayList() - - mCollections.forEach { (id, collection) -> - if (mAdapters[id]?.deleteSelected() == true) { - val media = collection.media - - if (media.isEmpty()) { - toDelete.add(collection.id) - } else { - mSection[id]?.setHeader(collection, media) - } - } - } - - deleteCollections(toDelete, true) - // If all collections are removed or empty, show add media hint. - binding.addMediaHint.toggle(mCollections.isEmpty()) - } - - private fun createMediaList(collection: Collection, media: List): View { - val holder = SectionViewHolder(ViewSectionBinding.inflate(layoutInflater)) - holder.recyclerView.layoutManager = GridLayoutManager(activity, COLUMN_COUNT) - holder.recyclerView.isNestedScrollingEnabled = false - - holder.setHeader(collection, media) - - val mediaAdapter = MainMediaAdapter( - activity = requireActivity(), - mediaList = media, - recyclerView = holder.recyclerView, - checkSelecting = { updateSelectionState() }, - onDeleteClick = { mediaItem, itemPosition -> - showDeleteConfirmationDialog( - mediaItem = mediaItem, - itemPosition = itemPosition - ) - - } - ) - - holder.recyclerView.adapter = mediaAdapter - mAdapters[collection.id] = mediaAdapter - mSection[collection.id] = holder - - return holder.root - } - - private fun showDeleteConfirmationDialog(mediaItem: Media, itemPosition: Int) { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Error - title = UiText.StringResource(R.string.upload_unsuccessful) - message = UiText.StringResource(R.string.upload_unsuccessful_description) - positiveButton { - text = UiText.StringResource(R.string.lbl_retry) - action = { - mediaItem.apply { - sStatus = Media.Status.Queued - statusMessage = "" - save() - BroadcastManager.postChange( - requireActivity(), - mediaItem.collectionId, - mediaItem.id - ) - } - UploadService.startUploadService(requireActivity()) - } - } - destructiveButton { - text = UiText.StringResource(R.string.btn_lbl_remove_media) - action = { - val adapter = mAdapters[mediaItem.collectionId] - adapter?.deleteItem(itemPosition) - } - } - } - } - - //update selection UI by summing selected counts from all adapters. - fun updateSelectionState() { - val totalSelected = mAdapters.values.sumOf { it.getSelectedCount() } - - if (isSelecting && totalSelected > 0) { - selectionHasActiveItems = true - } - // If we were in selection mode, had selections, and now none remain, exit selection. - if (isSelecting && selectionHasActiveItems && totalSelected == 0) { - isSelecting = false - selectionHasActiveItems = false - } - - val selectionActive = isSelecting || totalSelected > 0 - - // Keep all adapters in sync so a tap in any collection can toggle selection. - mAdapters.values.forEach { adapter -> - adapter.selecting = selectionActive - } - - (activity as? MainActivity)?.setSelectionMode(selectionActive) - (activity as? MainActivity)?.updateSelectedCount(totalSelected) - } - - - private fun updateSelectionCount() { - (activity as? MainActivity)?.updateSelectedCount(selectedMediaIds.size) - } - - private fun deleteCollections(collectionIds: List, cleanup: Boolean) { - collectionIds.forEach { collectionId -> - mAdapters.remove(collectionId) - - val holder = mSection.remove(collectionId) - (holder?.root?.parent as? ViewGroup)?.removeView(holder.root) - - mCollections[collectionId]?.let { - mCollections.remove(collectionId) - if (cleanup) { - it.delete() - } - } - } - } - - fun showUploadManager() { - (activity as? MainActivity)?.showUploadManagerFragment() - } - - fun setArrowVisible(visible: Boolean) { - binding.imgWelcomeArrowLayout.visibility = - if (visible) View.VISIBLE else View.INVISIBLE - } - - - override fun getToolbarTitle(): String = "" -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainViewModel.kt deleted file mode 100644 index fba870c12..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainViewModel.kt +++ /dev/null @@ -1,38 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import net.opendasharchive.openarchive.core.logger.AppLogger - -class MainViewModel : ViewModel() { - - private val _uiState = MutableStateFlow( - MainUiState( - currentPagerItem = 0 - ) - ) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - - AppLogger.i("MainViewModel initialized....") - } - - - fun log(msg: String) { - AppLogger.i("MainViewModel: $msg") - } - - fun updateCurrentPagerItem(page: Int) { - _uiState.update { it.copy(currentPagerItem = page) } - } - - fun getCurrentPagerItem(): Int = _uiState.value.currentPagerItem -} - -data class MainUiState( - val currentPagerItem: Int -) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ProjectAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ProjectAdapter.kt deleted file mode 100644 index 8906074f2..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ProjectAdapter.kt +++ /dev/null @@ -1,61 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.Lifecycle -import androidx.viewpager2.adapter.FragmentStateAdapter -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.features.settings.SettingsFragment -import kotlin.math.max - -class ProjectAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : - FragmentStateAdapter(fragmentManager, lifecycle) { - - var projects = listOf() - private set - - fun getProject(i: Int): Project? { - return if (i > -1 && i < projects.size) projects[i] else null - } - - val settingsIndex: Int - get() = max(1, projects.size) - - fun updateData(projects: List) { - this.projects = projects - notifyItemRangeChanged(0, projects.size) - } - - fun getPageTitle(position: Int): CharSequence? { - return getProject(position)?.description - } - - override fun getItemCount(): Int { - return max(1, projects.size) + 1 - } - - fun getProjectIndexById(id: Long, default: Int = 0): Int { - val index = projects.indexOfFirst { it.id == id } - return when (index) { - -1 -> default - else -> index - } - } - - fun notifyProjectChanged(project: Project) { - val index = projects.indexOf(project) - if (index != -1) { - notifyItemChanged(index) - } - } - - override fun createFragment(position: Int): Fragment { - return when (position) { - settingsIndex -> SettingsFragment() - else -> { - val project = getProject(position) - return MainMediaFragment.newInstance(project?.id ?: -1) - } - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/QRScannerActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/QRScannerActivity.kt deleted file mode 100644 index 59ae35226..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/QRScannerActivity.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import android.os.Bundle -import com.journeyapps.barcodescanner.CaptureActivity -import timber.log.Timber - -class QRScannerActivity : CaptureActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - Timber.d("Starting QR scanner") - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/RestEndpointTask.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/RestEndpointTask.kt deleted file mode 100644 index 8f5dc16f7..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/RestEndpointTask.kt +++ /dev/null @@ -1,42 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import android.os.Handler -import android.os.Looper -import android.widget.Toast -import okhttp3.OkHttpClient -import okhttp3.Request -import java.lang.Exception -import java.net.InetSocketAddress -import java.net.Proxy - -class RestEndpointTask(private val callback: (String?) -> Unit) : Runnable { - private val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("localhost", 9050)) - - override fun run() { - val client = OkHttpClient.Builder() - .proxy(proxy) - .build() - - val request = Request.Builder() - .url("https://jsonplaceholder.typicode.com/todos/1") - .build() - - try { - val response = client.newCall(request).execute() - - val result = if (response.isSuccessful) { - response.body?.string() - } else { - null - } - - Handler(Looper.getMainLooper()).post { - callback(result) - } - } catch (e: Exception) { - Handler(Looper.getMainLooper()).post { - callback(null) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/SectionViewHolder.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/SectionViewHolder.kt deleted file mode 100644 index 60f34bfff..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/SectionViewHolder.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ViewSectionBinding -import net.opendasharchive.openarchive.db.Collection -import net.opendasharchive.openarchive.db.Media -import java.text.DateFormat -import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -data class SectionViewHolder( - private val binding: ViewSectionBinding -) { - - companion object { - - private val mNf = NumberFormat.getIntegerInstance() - - private val mDf = DateFormat.getDateTimeInstance() - - private val dateFormat = SimpleDateFormat("MMM dd, yyyy | h:mma", Locale.ENGLISH) - - fun formatWithLowercaseAmPm(date: Date): String { - val formatted = dateFormat.format(date) - return formatted.replace("AM", "am").replace("PM", "pm") - } - - } - - val root - get() = binding.root - - val timestamp - get() = binding.timestamp - - val count - get() = binding.count - - val recyclerView - get() = binding.recyclerView - - fun setHeader(collection: Collection, media: List) { - if (media.any { it.isUploading }) { - timestamp.setText(R.string.uploading) - val uploaded = media.filter { it.sStatus == Media.Status.Uploaded }.size - count.text = count.context.getString(R.string.counter, uploaded, media.size) - return - } - count.text = mNf.format(media.size) - val uploadDate = collection.uploadDate - timestamp.text = if (uploadDate != null) formatWithLowercaseAmPm(uploadDate) else "Ready to upload" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClient.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClient.kt deleted file mode 100644 index 733aa1869..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClient.kt +++ /dev/null @@ -1,198 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import android.content.Context -import android.net.LocalSocket -import android.net.LocalSocketAddress -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import net.opendasharchive.openarchive.db.SerializableMarker -import net.opendasharchive.openarchive.services.snowbird.service.ErrorResponse -import net.opendasharchive.openarchive.services.snowbird.service.HttpLikeException -import timber.log.Timber -import java.io.BufferedReader -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.io.InputStreamReader -import java.net.SocketTimeoutException - -enum class HttpMethod(val value: String) { - GET("GET"), POST("POST"), PUT("PUT"), DELETE("DELETE"), PATCH("PATCH"), - HEAD("HEAD"), OPTIONS("OPTIONS"), TRACE("TRACE"); - - override fun toString() = value - - companion object { - fun fromString(method: String) = entries.find { it.value.equals(method, ignoreCase = true) } - } -} - -//sealed class ClientResponse { -// data class SuccessResponse(val data: T) : ClientResponse() -// data class ErrorResponse(val error: ApiError) : ClientResponse() -//} - -class UnixSocketClient(context: Context) { - private val context = context - val socketPath: String = File(context.filesDir, "rust_server.sock").absolutePath - val json = Json { ignoreUnknownKeys = true } - - init { - // Log the socket path for debugging - Timber.d("Unix socket path: $socketPath") - logFileDirectoryStatus() - } - - private fun logFileDirectoryStatus() { - try { - val filesDir = context.filesDir - Timber.d("App files directory: ${filesDir.absolutePath}") - Timber.d("Files directory exists: ${filesDir.exists()}") - Timber.d("Files directory readable: ${filesDir.canRead()}") - Timber.d("Files directory writable: ${filesDir.canWrite()}") - - val socketFile = File(socketPath) - Timber.d("Socket file exists: ${socketFile.exists()}") - if (socketFile.exists()) { - Timber.d("Socket file readable: ${socketFile.canRead()}") - Timber.d("Socket file writable: ${socketFile.canWrite()}") - Timber.d("Socket file size: ${socketFile.length()} bytes") - Timber.d("Socket file last modified: ${socketFile.lastModified()}") - } - - // List all files in the directory for debugging - val files = filesDir.listFiles() - if (files != null) { - Timber.d("Files in app directory (${files.size} total):") - files.forEach { file -> - Timber.d(" - ${file.name} (${if (file.isDirectory()) "dir" else "file"}, ${file.length()} bytes)") - } - } else { - Timber.w("Unable to list files in directory: ${filesDir.absolutePath}") - } - } catch (e: Exception) { - Timber.e(e, "Error checking file directory status") - } - } - - private fun logConnectionDiagnostics() { - Timber.w("Connection failed - running diagnostics:") - logFileDirectoryStatus() - } - - suspend inline fun sendRequest( - endpoint: String, - method: HttpMethod, - body: REQUEST? = null - ): RESPONSE = withContext(Dispatchers.IO) { - Timber.d("$method $endpoint") - sendRequestInternal(endpoint, method, body, { json.encodeToString(it) }, { json.decodeFromString(it) }) - } - - fun sendRequestInternal( - endpoint: String, - method: HttpMethod, - body: REQUEST?, - serialize: (REQUEST) -> String, - deserialize: (String) -> RESPONSE - ): RESPONSE { - return try { - LocalSocket().use { socket -> - socket.connect(LocalSocketAddress(socketPath, LocalSocketAddress.Namespace.FILESYSTEM)) - - val (responseCode, _, responseBody) = sendJsonRequestAndGetResponse(socket, endpoint, method, body, serialize) - - Timber.d("response body = $responseBody") - - when (responseCode) { - in 200..299 -> parseSuccessResponse(responseBody, deserialize) - else -> { - Timber.e("Error response body = $responseBody") - // try to decode our {"error":"…","status":"error"} payload - val message = try { - json.decodeFromString(responseBody).error - } catch (_: Exception) { - // fallback to raw body if it wasn’t JSON - responseBody - } - throw HttpLikeException(responseCode, message) - } - } - } - } catch (e: SocketTimeoutException) { - Timber.e(e, "Socket timeout when connecting to Unix socket") - logConnectionDiagnostics() - throw IOException("Connection timeout to Unix socket at $socketPath. Check if the server is running.") - } catch (e: IOException) { - Timber.e(e, "IO error when connecting to Unix socket") - logConnectionDiagnostics() - - val errorMessage = when { - e.message?.contains("Connection refused") == true -> - "Connection refused to Unix socket at $socketPath. Server may not be running or socket file may not exist." - e.message?.contains("No such file") == true -> - "Socket file not found at $socketPath. Server may not be started yet." - else -> - "Failed to connect to Unix socket at $socketPath: ${e.message}" - } - throw IOException(errorMessage) - } catch (e: Exception) { - Timber.e(e, "Unexpected error during Unix socket communication") - logConnectionDiagnostics() - throw IOException("Unexpected error during Unix socket communication: ${e.message}") - } - } - - private fun sendJsonRequestAndGetResponse( - socket: LocalSocket, - endpoint: String, - method: HttpMethod, - body: REQUEST?, - serialize: (REQUEST) -> String - ): Triple, String> { - val output = socket.outputStream - val jsonBody = body?.let { serialize(it) } ?: "" - - val requestHeaders = buildString { - append("$method $endpoint HTTP/1.1\r\n") - append("Content-Type: application/json\r\n") - append("Content-Length: ${jsonBody.length}\r\n") - //append("Connection: close\r\n") - append("\r\n") - } - - output.write(requestHeaders.toByteArray()) - output.write(jsonBody.toByteArray()) - output.flush() - - return readResponse(socket.inputStream) - } - - fun readResponse(inputStream: InputStream): Triple, String> { - val reader = BufferedReader(InputStreamReader(inputStream)) - val statusLine = reader.readLine() - val (_, statusCode, _) = statusLine.split(" ", limit = 3) - - val headers = mutableMapOf() - var line: String? - while (reader.readLine().also { line = it } != null) { - if (line.isNullOrBlank()) break - val (key, value) = line!!.split(": ", limit = 2) - headers[key] = value - } - - val responseBody = reader.readText() - - return Triple(statusCode.toInt(), headers, responseBody) - } - - fun parseSuccessResponse(responseBody: String, deserialize: (String) -> T): T { - return try { - deserialize(responseBody) - } catch (e: Exception) { - Timber.e("error = $e") - throw e - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClientFileExtensions.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClientFileExtensions.kt deleted file mode 100644 index 3adf239aa..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClientFileExtensions.kt +++ /dev/null @@ -1,135 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import android.net.LocalSocket -import android.net.LocalSocketAddress -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.db.SerializableMarker -import net.opendasharchive.openarchive.services.snowbird.service.HttpLikeException -import timber.log.Timber -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.io.InputStream -import java.net.SocketTimeoutException - - -suspend fun UnixSocketClient.downloadFile(endpoint: String): ByteArray = withContext(Dispatchers.IO) { - LocalSocket().use { socket -> - socket.connect(LocalSocketAddress(socketPath, LocalSocketAddress.Namespace.FILESYSTEM)) - - // 1) build & send a perfectly raw HTTP/1.1 GET - val req = buildString { - append("GET $endpoint HTTP/1.1\r\n") - append("Accept: image/*\r\n") - append("Connection: close\r\n") - append("\r\n") - }.toByteArray() - socket.outputStream.write(req) - socket.outputStream.flush() - - // 2) now read response entirely over the InputStream - val input = socket.inputStream - val (statusCode, headers) = input.readHttpResponseHeaders() - if (statusCode !in 200..299) throw HttpLikeException(statusCode, "Failed to download file: $statusCode") - - // 3) decide how to read the body - return@withContext when { - headers["Transfer-Encoding"]?.equals("chunked", true) == true -> - input.readChunkedBody() - headers["Content-Length"] != null -> - input.readFixedLengthBody(headers["Content-Length"]!!.toInt()) - else -> - input.readAllCompat() // maybe not ideal for huge files, but safe - } - } -} - -private fun InputStream.readAllCompat(): ByteArray { - val buffer = ByteArrayOutputStream() - this.use { input -> - input.copyTo(buffer) - } - return buffer.toByteArray() -} - -suspend fun UnixSocketClient.downloadFileOld(endpoint: String): ByteArray = withContext(Dispatchers.IO) { - LocalSocket().use { socket -> - socket.connect(LocalSocketAddress(socketPath, LocalSocketAddress.Namespace.FILESYSTEM)) - - val requestHeaders = buildString { - append("${HttpMethod.GET} $endpoint HTTP/1.1\r\n") - append("Accept: image/*\r\n") - append("\r\n") - } - - socket.outputStream.apply { - write(requestHeaders.toByteArray()) - flush() - } - - try { - val (responseCode, _, bytes) = readBinaryResponseWithCancellation(socket.inputStream) - - Timber.d("File download response code: $responseCode") - - when (responseCode) { - in 200..299 -> bytes - else -> throw HttpLikeException(responseCode, "Failed to download file: $responseCode") - } - } catch (e: SocketTimeoutException) { - throw e - } catch (e: IOException) { - throw e - } catch (e: Exception) { - throw IOException("Unexpected error during Unix socket communication: ${e.message}") - } - } -} - -suspend inline fun UnixSocketClient.uploadFile( - endpoint: String, - imageData: ByteArray -): RESPONSE = withContext(Dispatchers.IO) { - try { - LocalSocket().use { socket -> - socket.connect(LocalSocketAddress(socketPath, LocalSocketAddress.Namespace.FILESYSTEM)) - - val requestHeaders = buildString { - append("${HttpMethod.POST} $endpoint HTTP/1.1\r\n") - append("Content-Type: application/octet-stream\r\n") - append("Content-Length: ${imageData.size}\r\n") - append("\r\n") - } - - socket.outputStream.apply { - write(requestHeaders.toByteArray()) - write(imageData) - flush() - } - - val (responseCode, _, responseBody) = readResponse(socket.inputStream) - - Timber.d("Image upload response code: $responseCode") - - when (responseCode) { - in 200..299 -> parseSuccessResponse(responseBody) { json.decodeFromString(it) } - else -> throw HttpLikeException(responseCode, "Failed to upload file: $responseCode") - } - } - } catch (e: SocketTimeoutException) { - throw e - } catch (e: IOException) { - throw e - } catch (e: Exception) { - throw IOException("Unexpected error during Unix socket communication: ${e.message}") - } -} - -suspend inline fun UnixSocketClient.uploadFile( - endpoint: String, - inputStream: InputStream, -): RESPONSE { - inputStream.use { stream -> - return uploadFile(endpoint, stream.readBytes()) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClientUtilityExtensions.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClientUtilityExtensions.kt deleted file mode 100644 index 6ee04c589..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClientUtilityExtensions.kt +++ /dev/null @@ -1,166 +0,0 @@ -package net.opendasharchive.openarchive.features.main - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext -import java.io.BufferedReader -import java.io.ByteArrayOutputStream -import java.io.DataInputStream -import java.io.IOException -import java.io.InputStream -import java.io.InputStreamReader -import kotlin.coroutines.cancellation.CancellationException - -/** - * Reads status line + headers from raw InputStream. - * Returns pair(statusCode, headersMap). - */ -@Throws(IOException::class) -internal fun InputStream.readHttpResponseHeaders(): Pair> { - fun readLine(): String { - val sb = StringBuilder() - while (true) { - val b = read().takeIf { it >= 0 } ?: throw IOException("Unexpected EOF") - when (b.toChar()) { - '\r' -> if (read().toChar() == '\n') break - '\n' -> break - else -> sb.append(b.toChar()) - } - } - return sb.toString() - } - - // Status line, e.g. "HTTP/1.1 200 OK" - val statusLine = readLine() - val parts = statusLine.split(' ', limit = 3) - val code = parts.getOrNull(1)?.toIntOrNull() - ?: throw IOException("Invalid status line: $statusLine") - - // Headers until blank line - val headers = mutableMapOf() - while (true) { - val line = readLine() - if (line.isEmpty()) break - val (k, v) = line.split(":", limit = 2) - headers[k.trim()] = v.trim() - } - return code to headers -} - -/** Read exactly `length` bytes (uses DataInputStream.readFully). */ -@Throws(IOException::class) -internal fun InputStream.readFixedLengthBody(length: Int): ByteArray { - val data = ByteArray(length) - DataInputStream(this).readFully(data) - return data -} - -/** Read a chunked‐encoded body. */ -@Throws(IOException::class) -internal fun InputStream.readChunkedBody(): ByteArray { - val out = ByteArrayOutputStream() - while (true) { - // read next chunk-size line - val sizeLine = buildString { - while (true) { - val b = read().takeIf { it >= 0 } ?: throw IOException("EOF in chunked size") - if (b.toChar() == '\r') { - if (read().toChar() == '\n') break - } else if (b.toChar() == '\n') break - else append(b.toChar()) - } - } - val chunkSize = sizeLine.trim().toInt(16) - if (chunkSize == 0) { - // consume the final CRLF after the 0‐length chunk - if (read() != '\r'.code || read() != '\n'.code) { /*ignore*/ } - break - } - // read exactly chunkSize bytes - val buf = ByteArray(chunkSize) - DataInputStream(this).readFully(buf) - out.write(buf) - // consume trailing CRLF - if (read() != '\r'.code || read() != '\n'.code) { /*ignore*/ } - } - return out.toByteArray() -} - -suspend fun UnixSocketClient.readBinaryResponseWithCancellation( - inputStream: InputStream, - onProgress: ((Long) -> Unit)? = null -): Triple, ByteArray> = withContext(Dispatchers.IO) { - val reader = BufferedReader(InputStreamReader(inputStream)) - - // Read status line - val statusLine = reader.readLine() ?: throw IOException("Empty response") - val (_, statusCode, _) = statusLine.split(" ", limit = 3) - - // Read headers - val headers = mutableMapOf() - var line: String? - while (reader.readLine().also { line = it } != null) { - if (line.isNullOrBlank()) break - val (key, value) = line!!.split(": ", limit = 2) - headers[key] = value - } - - val outputStream = ByteArrayOutputStream() - var totalBytesRead = 0L - - val isChunked = headers["Transfer-Encoding"]?.equals("chunked", ignoreCase = true) ?: false - val contentLength = headers["Content-Length"]?.toLongOrNull() - - try { - if (isChunked) { - // Handle chunked transfer encoding - while (isActive) { - ensureActive() - val chunkSizeLine = reader.readLine() ?: break - val chunkSize = chunkSizeLine.trim().toInt(16) - if (chunkSize == 0) break - - val buffer = ByteArray(chunkSize) - var bytesRead = 0 - while (bytesRead < chunkSize) { - ensureActive() - val count = inputStream.read(buffer, bytesRead, chunkSize - bytesRead) - if (count == -1) break - bytesRead += count - } - outputStream.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - onProgress?.invoke(totalBytesRead) - - reader.readLine() // Read the CRLF after the chunk - } - } else if (contentLength != null) { - // Handle Content-Length specified - val buffer = ByteArray(8192) // 8KB buffer - var bytesRead = 0 - while (totalBytesRead < contentLength && inputStream.read(buffer).also { bytesRead = it } != -1) { - ensureActive() - outputStream.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - onProgress?.invoke(totalBytesRead) - } - } else { - // Handle case where neither chunked nor Content-Length is specified - val buffer = ByteArray(8192) // 8KB buffer - var bytesRead: Int - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - ensureActive() - outputStream.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - onProgress?.invoke(totalBytesRead) - } - } - } catch (e: CancellationException) { - throw e - } finally { - inputStream.close() - } - - Triple(statusCode.toInt(), headers, outputStream.toByteArray()) -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/FolderDrawerAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/FolderDrawerAdapter.kt deleted file mode 100644 index 6e0686765..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/FolderDrawerAdapter.kt +++ /dev/null @@ -1,104 +0,0 @@ -package net.opendasharchive.openarchive.features.main.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.RvDrawerRowBinding -import net.opendasharchive.openarchive.db.Project - - -interface FolderDrawerAdapterListener { - fun onProjectSelected(project: Project) - fun getSelectedProject(): Project? -} - -class FolderDrawerAdapter( - private val listener: FolderDrawerAdapterListener -) : ListAdapter(DIFF_CALLBACK) { - - private var selectedProject: Project? = listener.getSelectedProject() - - inner class FolderViewHolder( - private val binding: RvDrawerRowBinding, - private val listener: FolderDrawerAdapterListener - ) : RecyclerView.ViewHolder(binding.root) { - - fun bind(project: Project) { - - binding.rvTitle.text = project.description - - val isSelected = project.id == selectedProject?.id - val iconRes = if (isSelected) R.drawable.baseline_folder_white_24 else R.drawable.outline_folder_white_24 - val iconColor = if (isSelected) R.color.colorTertiary else R.color.colorOnBackground - val textColor = if (isSelected) R.color.colorOnBackground else R.color.colorText - - val icon = ContextCompat.getDrawable(binding.rvIcon.context, iconRes) - icon?.setTint(ContextCompat.getColor(binding.rvIcon.context, iconColor)) - binding.rvIcon.setImageDrawable(icon) - - binding.rvTitle.setTextColor(ContextCompat.getColor(binding.rvTitle.context, textColor)) - - binding.root.setOnClickListener { - onItemSelected(project) - } - } - - private fun onItemSelected(project: Project) { - val previousIndex = currentList.indexOf(selectedProject) - val newIndex = currentList.indexOf(project) - - selectedProject = project - - if (previousIndex != -1) notifyItemChanged(previousIndex) - if (newIndex != -1) notifyItemChanged(newIndex) - - listener.onProjectSelected(project) - } - } - - companion object { - private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Project, newItem: Project): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: Project, newItem: Project): Boolean { - return oldItem.description == newItem.description - } - } - } - - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FolderViewHolder { - val binding = RvDrawerRowBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return FolderViewHolder(binding, listener = listener) - } - - override fun onBindViewHolder(holder: FolderViewHolder, position: Int) { - val project = getItem(position) - - holder.bind(project) - } - - fun update(projects: List) { - // Preserve selection if the selected project is still present - val previouslySelectedId = selectedProject?.id - selectedProject = projects.find { it.id == previouslySelectedId } - - submitList(projects) - } - - fun updateSelectedProject(project: Project?) { - val previousIndex = currentList.indexOf(selectedProject) - val newIndex = currentList.indexOf(project) - - selectedProject = project - - if (previousIndex != -1) notifyItemChanged(previousIndex) - if (newIndex != -1) notifyItemChanged(newIndex) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaAdapter.kt deleted file mode 100644 index 2300c6576..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaAdapter.kt +++ /dev/null @@ -1,324 +0,0 @@ -package net.opendasharchive.openarchive.features.main.adapters - -import android.app.Activity -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.snackbar.Snackbar -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.RvMediaBoxBinding -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.features.main.MainActivity -import net.opendasharchive.openarchive.features.media.PreviewActivity -import net.opendasharchive.openarchive.upload.BroadcastManager -import java.lang.ref.WeakReference - -class MainMediaAdapter( - private val activity: Activity?, - private val mediaList: List, - private val recyclerView: RecyclerView, - private val checkSelecting: () -> Unit, - private val allowMultiProjectSelection: Boolean = false, - private val onDeleteClick: (Media, Int) -> Unit, -) : RecyclerView.Adapter() { - - companion object { - private const val PAYLOAD_SELECTION = "selection" - private const val PAYLOAD_PROGRESS = "progress" - - private val supportedStatuses: List = listOf( - Media.Status.Local, Media.Status.Uploading, Media.Status.Error - ) - } - - var media: ArrayList = ArrayList(mediaList) - private set - - var doImageFade = true - - var isEditMode = false - - var selecting = false - - private var mActivity = WeakReference(activity) - - private val selectedItems = mutableSetOf() - - init { - setHasStableIds(true) - } - - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainMediaViewHolder { - val binding = RvMediaBoxBinding.inflate(LayoutInflater.from(parent.context), parent, false) - val mvh = MainMediaViewHolder(binding) - - // Normal click: either toggle selection if already in selection mode or perform normal action. - mvh.itemView.setOnClickListener { v -> - val pos = recyclerView.getChildLayoutPosition(v) - if (pos == RecyclerView.NO_POSITION) return@setOnClickListener - if (selecting) { - toggleSelection(pos) - } else { - handleNormalClick(pos) - } - } - - // Long-click: enable selection mode (if not already enabled) and toggle selection. - mvh.itemView.setOnLongClickListener { v -> - val pos = recyclerView.getChildLayoutPosition(v) - if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener true - if (!selecting) { - selecting = true - // If multi-project selection is allowed, the parent fragment may already have enabled selection - // on other adapters. Otherwise, we are only enabling it here. - checkSelecting.invoke() - } - toggleSelection(pos) - true - } - - return mvh - } - - override fun getItemCount(): Int = media.size - - override fun getItemId(position: Int): Long = media[position].id - - override fun onBindViewHolder(holder: MainMediaViewHolder, position: Int) { - AppLogger.i("onBindViewHolder called for position $position") - holder.bind(media[position], selecting, doImageFade) - } - - override fun onBindViewHolder( - holder: MainMediaViewHolder, position: Int, payloads: MutableList - ) { - if (payloads.isNotEmpty()) { - val payload = payloads[0] - when (payload) { - "progress" -> { - holder.updateProgress(media[position].uploadPercentage ?: 0) - } - - "full" -> { - holder.bind(media[position], selecting, doImageFade) - } - } - } else { - holder.bind(media[position], selecting, doImageFade) - } - } - - // --- Helper functions for selection handling --- - private fun toggleSelection(position: Int) { - val item = media[position] - item.selected = !item.selected - item.save() - notifyItemChanged(position) - // Update the adapter’s overall selecting flag. - selecting = media.any { it.selected } - checkSelecting.invoke() - } - - private fun handleNormalClick(position: Int) { - val item = media[position] - val mediaStatus = item.sStatus - // Default behavior if needed. - if (mediaStatus == Media.Status.Local) { - if (supportedStatuses.contains(Media.Status.Local)) { - mActivity.get()?.let { - PreviewActivity.start(it, item.projectId) - } - } - } else if (mediaStatus == Media.Status.Queued || mediaStatus == Media.Status.Uploading) { - if (supportedStatuses.contains(Media.Status.Uploading)) { - (mActivity.get() as? MainActivity)?.showUploadManagerFragment() - } - } else if (mediaStatus == Media.Status.Error) { - if (supportedStatuses.contains(Media.Status.Error)) { - onDeleteClick.invoke(item, position) - } - } - } - - fun updateItem(mediaId: Long, progress: Int, isUploaded: Boolean = false): Boolean { - val mediaIndex = media.indexOfFirst { it.id == mediaId } - AppLogger.i("updateItem: mediaId=$mediaId idx=$mediaIndex") - if (mediaIndex < 0) return false - - val item = media[mediaIndex] - - if (isUploaded) { - item.status = Media.Status.Uploaded.id - AppLogger.i("Media item $mediaId uploaded, notifying item changed at position $mediaIndex") - notifyItemChanged(mediaIndex, "full") - } else if (progress >= 0) { - item.uploadPercentage = progress - item.status = Media.Status.Uploading.id - notifyItemChanged(mediaIndex, "progress") - } else { - item.status = Media.Status.Queued.id - notifyItemChanged(mediaIndex, "full") - } - - return true - } - - fun removeItem(mediaId: Long): Boolean { - val idx = media.indexOfFirst { it.id == mediaId } - if (idx < 0) return false - media.removeAt(idx) - notifyItemRemoved(idx) - checkSelecting.invoke() - return true - } - - fun updateData(newMediaList: List) { - val diffCallback = MediaDiffCallback(this.media, newMediaList) - val diffResult = DiffUtil.calculateDiff(diffCallback) - media.clear() - media.addAll(newMediaList) - diffResult.dispatchUpdatesTo(this) - } - - fun clearSelections() { - selectedItems.clear() - media.forEach { - if (it.selected) { - it.selected = false - it.save() - } - } - selecting = false - notifyDataSetChanged() - checkSelecting.invoke() - } - - private fun selectView(view: View) { - if (!selecting) return - - val mediaId = view.tag as? Long ?: return - val wasSelected = selectedItems.contains(mediaId) - - if (wasSelected) { - selectedItems.remove(mediaId) - } else { - if (!allowMultiProjectSelection) { - selectedItems.clear() - media.forEach { it.selected = false } - } - selectedItems.add(mediaId) - } - - media.firstOrNull { it.id == mediaId }?.selected = !wasSelected - checkSelecting.invoke() - notifyItemChanged(media.indexOfFirst { it.id == mediaId }) - } - - fun onItemMove(oldPos: Int, newPos: Int) { - if (!isEditMode) return - - val mediaToMov = media.removeAt(oldPos) - media.add(newPos, mediaToMov) - - var priority = media.size - - for (item in media) { - item.priority = priority-- - item.save() - } - - notifyItemMoved(oldPos, newPos) - } - - fun deleteItem(pos: Int) { - if (pos < 0 || pos >= media.size) return - - val item = media[pos] -// var undone = false - -// val snackbar = -// Snackbar.make(recyclerView, R.string.confirm_remove_media, Snackbar.LENGTH_INDEFINITE) -// snackbar.setAction(R.string.undo) { _ -> -// undone = true -// media.add(pos, item) -// -// notifyItemInserted(pos) -// } -// -// snackbar.addCallback(object : Snackbar.Callback() { -// override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { -// if (!undone) { - val collection = item.collection - - // Delete collection along with the item, if the collection - // would become empty. - if ((collection?.size ?: 0) < 2) { - collection?.delete() - } else { - item.delete() - } - - -// } -// -// super.onDismissed(transientBottomBar, event) -// } -// }) - - //snackbar.show() - - removeItem(item.id) - - BroadcastManager.postDelete(recyclerView.context, item.id) - } - - fun getSelectedCount(): Int = media.count { it.selected } - - fun deleteSelected(): Boolean { - var hasDeleted = false - // Copy list to avoid concurrent modification. - val selectedItems = media.filter { it.selected } - selectedItems.forEach { item -> - val idx = media.indexOf(item) - if (idx != -1) { - media.removeAt(idx) - notifyItemRemoved(idx) - item.delete() - hasDeleted = true - } - } - selecting = false - checkSelecting.invoke() - return hasDeleted - } -} - -private class MediaDiffCallback( - private val oldList: List, private val newList: List -) : DiffUtil.Callback() { - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldList[oldItemPosition].id == newList[newItemPosition].id - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - // Compare only the fields that affect the UI - - val oldItem = oldList[oldItemPosition] - val newItem = newList[newItemPosition] - - return oldItem.status == newItem.status && oldItem.uploadPercentage == newItem.uploadPercentage && oldItem.selected == newItem.selected && oldItem.title == newItem.title - } - - override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { - return super.getChangePayload(oldItemPosition, newItemPosition) - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaViewHolder.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaViewHolder.kt deleted file mode 100644 index 35d177347..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaViewHolder.kt +++ /dev/null @@ -1,351 +0,0 @@ -package net.opendasharchive.openarchive.features.main.adapters - -import android.content.res.ColorStateList -import android.widget.ImageView -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.CircularProgressDrawable -import coil3.ImageLoader -import coil3.load -import coil3.request.crossfade -import coil3.request.error -import coil3.request.placeholder -import coil3.request.Disposable -import coil3.video.VideoFrameDecoder -import coil3.video.videoFrameMillis -import java.io.File -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.RvMediaBoxBinding -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.util.PdfThumbnailLoader -import net.opendasharchive.openarchive.util.extensions.hide -import net.opendasharchive.openarchive.util.extensions.show -import timber.log.Timber - -class MainMediaViewHolder(val binding: RvMediaBoxBinding) : RecyclerView.ViewHolder(binding.root) { - - private val mContext = itemView.context - private val pdfScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private var pdfThumbnailJob: Job? = null - private var imageRequest: Disposable? = null - - private val imageLoader = ImageLoader.Builder(mContext) - .components { - add(VideoFrameDecoder.Factory()) - } - .build() - - - fun bind( - media: Media? = null, - isInSelectionMode: Boolean = false, - doImageFade: Boolean = true - ) { - - itemView.tag = media?.id - binding.image.tag = media?.id - - resetImage() - - val isSelected = isInSelectionMode && media?.selected == true - - // Update selection visuals. - if (isSelected) { - //itemView.setBackgroundResource(R.color.colorTertiary) - binding.selectedIndicator.show() - } else { - //itemView.setBackgroundResource(R.color.transparent) - binding.selectedIndicator.hide() - } - - binding.image.alpha = - if (media?.sStatus == Media.Status.Uploaded || !doImageFade) 1f else 0.5f - - if (media?.mimeType?.startsWith("image") == true) { - // Check if file exists before attempting to load - val fileExists = try { - media.fileUri.path?.let { path -> - File(path).exists() - } ?: false - } catch (e: Exception) { - AppLogger.e(e) - false - } - - if (fileExists) { - val progress = CircularProgressDrawable(mContext) - progress.strokeWidth = 5f - progress.centerRadius = 30f - progress.start() - - binding.image.scaleType = ImageView.ScaleType.CENTER_CROP - binding.image.setBackgroundColor( - ContextCompat.getColor( - mContext, - android.R.color.transparent - ) - ) - binding.image.setPadding(0, 0, 0, 0) - binding.image.clearColorFilter() - imageRequest = binding.image.load(media.fileUri, imageLoader) { - placeholder(progress) - error(R.drawable.ic_image) - crossfade(true) - crossfade(300) - listener(onError = { _, res -> - AppLogger.e(res.throwable) - }) - } - } else { - AppLogger.w("Image file not found: ${media.fileUri.path}") - val padding = (28 * mContext.resources.displayMetrics.density).toInt() - binding.image.scaleType = ImageView.ScaleType.FIT_CENTER - binding.image.setPadding(padding, padding, padding, padding) - imageRequest = binding.image.load(R.drawable.ic_image, imageLoader) { - crossfade(false) - } - applyPlaceholderTint(isSelected) - binding.mediaTitle.text = media.title - binding.mediaTitle.show() - } - - binding.image.show() - binding.videoIndicator.hide() - } else if (media?.mimeType?.startsWith("video") == true) { - // For videos, try both paths to find the file - val fileExists = try { - // First try originalFilePath - val originalExists = media.originalFilePath?.let { File(it).exists() } ?: false - // If not found, try fileUri path - val uriExists = if (!originalExists) { - media.fileUri.path?.let { File(it).exists() } ?: false - } else false - - originalExists || uriExists - } catch (e: Exception) { - AppLogger.e(e) - false - } - - if (fileExists) { - val progress = CircularProgressDrawable(mContext) - progress.strokeWidth = 5f - progress.centerRadius = 30f - progress.start() - - binding.image.scaleType = ImageView.ScaleType.CENTER_CROP - //binding.image.setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - binding.image.setPadding(0, 0, 0, 0) - binding.image.clearColorFilter() - imageRequest = binding.image.load(media.originalFilePath, imageLoader) { - videoFrameMillis(1000) // Extracts the frame at 1 second (1000ms) - placeholder(progress) - error(R.drawable.ic_video) - crossfade(true) - crossfade(300) - listener(onError = { _, res -> - AppLogger.e(res.throwable) - }) - } - } else { - AppLogger.w("Video file not found: ${media.originalFilePath}") - val padding = (28 * mContext.resources.displayMetrics.density).toInt() - binding.image.scaleType = ImageView.ScaleType.FIT_CENTER - binding.image.setPadding(padding, padding, padding, padding) - imageRequest = binding.image.load(R.drawable.ic_video, imageLoader) { - crossfade(false) - } - applyPlaceholderTint(isSelected) - - binding.mediaTitle.text = media.title - binding.mediaTitle.show() - } - - binding.image.show() - binding.videoIndicator.show() - } else if (media?.mimeType?.startsWith("audio") == true) { - binding.videoIndicator.hide() - placeholderIcon(R.drawable.ic_music, media?.title, isSelected) - } else if (media?.mimeType == "application/pdf") { - loadPdfThumbnail(media, isSelected) - binding.videoIndicator.hide() - } else if (media?.mimeType?.startsWith("application") == true) { - placeholderIcon(R.drawable.ic_unknown_file, media?.title, isSelected) - binding.videoIndicator.hide() - - } else { - placeholderIcon(R.drawable.ic_unknown_file, media?.title, isSelected) - binding.videoIndicator.hide() - } - - // Update overlay based on media status. - when (media?.sStatus) { - Media.Status.Error -> { - AppLogger.i("Media Item ${media.id} is error") - - binding.overlayContainer.show() - binding.progress.hide() - binding.progressText.hide() - binding.error.show() - - } - - Media.Status.Queued -> { - AppLogger.i("Media Item ${media.id} is queued") - binding.overlayContainer.show() - binding.progress.isIndeterminate = true - binding.progress.show() - binding.progressText.hide() - binding.error.hide() - } - - Media.Status.Uploading -> { - binding.progress.isIndeterminate = false - val progressValue = media.uploadPercentage ?: 0 - AppLogger.i("Media Item ${media.id} is uploading") - - binding.overlayContainer.show() - binding.progress.show() - //binding.progressText.show() - - // Make sure to keep spinning until the upload has made some noteworthy progress. - if (progressValue > 2) { - binding.progress.setProgressCompat(progressValue, true) - } - //binding.progressText.text = "${progressValue}%" - binding.error.hide() - } - - else -> { - binding.overlayContainer.hide() - binding.progress.hide() - binding.progressText.hide() - binding.error.hide() - } - } - - } - - fun updateProgress(progressValue: Int) { - if (progressValue > 2) { - binding.progress.isIndeterminate = false - binding.progress.setProgressCompat(progressValue, true) - } else { - binding.progress.isIndeterminate = true - } - - //AppLogger.i("Updating progressText to $progressValue%") - //binding.progressText.show(animate = true) - //binding.progressText.text = "$progressValue%" - } - - private fun resetImage() { - pdfThumbnailJob?.cancel() - pdfThumbnailJob = null - imageRequest?.dispose() - imageRequest = null - binding.mediaTitle.text = "" - binding.mediaTitle.hide() - binding.image.setImageDrawable(null) - binding.image.setBackgroundColor( - ContextCompat.getColor(mContext, android.R.color.transparent) - ) - binding.image.setPadding(0, 0, 0, 0) - binding.image.scaleType = ImageView.ScaleType.CENTER_CROP - binding.image.clearColorFilter() - binding.image.imageTintList = null - } - - private fun placeholderIcon(drawableRes: Int, title: String?, isSelected: Boolean) { - val padding = (28 * mContext.resources.displayMetrics.density).toInt() - binding.image.apply { - scaleType = ImageView.ScaleType.FIT_CENTER - setBackgroundColor( - ContextCompat.getColor( - mContext, - android.R.color.transparent - ) - ) - setPadding(padding, padding, padding, padding) - imageRequest = load(drawableRes, imageLoader) { - crossfade(false) - } - clearColorFilter() - applyPlaceholderTint(isSelected) - show() - } - if (title.isNullOrBlank()) { - binding.mediaTitle.hide() - } else { - binding.mediaTitle.text = title - binding.mediaTitle.show() - } - } - - private fun applyPlaceholderTint(isSelected: Boolean) { - val tint = if (isSelected) { - ContextCompat.getColor(mContext, R.color.colorOnPrimaryContainer) - } else { - ContextCompat.getColor(mContext, R.color.colorOnSurfaceVariant) - } - binding.image.imageTintList = ColorStateList.valueOf(tint) - } - - private fun loadPdfThumbnail(media: Media?, isSelected: Boolean) { - if (media == null) { - showPdfPlaceholder(null, isSelected) - return - } - - val uri = media.fileUri - val file = media.file - if (uri.scheme == "file" && !file.exists()) { - showPdfPlaceholder(media.title, isSelected) - return - } - - pdfThumbnailJob = PdfThumbnailLoader.loadThumbnail( - imageView = binding.image, - uri = uri, - placeholderRes = R.drawable.ic_pdf, - scope = pdfScope, - maxDimensionPx = 512, - context = mContext, - requestKey = media.id, - onPlaceholder = { showPdfPlaceholder(null, isSelected) } - ) { success -> - if (success) { - binding.mediaTitle.hide() - } else { - if (!media.title.isNullOrBlank()) { - binding.mediaTitle.text = media.title - binding.mediaTitle.show() - } - } - } - } - - private fun showPdfPlaceholder(title: String?, isSelected: Boolean) { - val padding = (28 * mContext.resources.displayMetrics.density).toInt() - binding.image.apply { - scaleType = ImageView.ScaleType.FIT_CENTER - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - setPadding(padding, padding, padding, padding) - setImageResource(R.drawable.ic_pdf) - clearColorFilter() - applyPlaceholderTint(isSelected) - show() - } - if (title.isNullOrBlank()) { - binding.mediaTitle.hide() - } else { - binding.mediaTitle.text = title - binding.mediaTitle.show() - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/SpaceDrawerAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/SpaceDrawerAdapter.kt deleted file mode 100644 index 0c85b6dd9..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/SpaceDrawerAdapter.kt +++ /dev/null @@ -1,186 +0,0 @@ -package net.opendasharchive.openarchive.features.main.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.RvDrawerRowBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.util.extensions.scaled - -interface SpaceDrawerAdapterListener { - fun onSpaceSelected(space: Space) - fun onAddNewSpace() - fun getSelectedSpace(): Space? - fun onNavigateToDwebGroups() -} - -class SpaceDrawerAdapter(private val listener: SpaceDrawerAdapterListener) : - ListAdapter(DIFF_CALLBACK) { - - private var selectedSpace: Space? = listener.getSelectedSpace() - - sealed class SpaceItem { - data class SpaceItemData(val space: Space) : SpaceItem() - data object AddSpaceItem : SpaceItem() - data object DwebGroupItem : SpaceItem() - } - - companion object { - - private const val VIEW_TYPE_SPACE = 0 - private const val VIEW_TYPE_ADD = 1 - private const val VIEW_TYPE_DWEB = 2 - - private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SpaceItem, newItem: SpaceItem): Boolean { - return when { - oldItem is SpaceItem.SpaceItemData && newItem is SpaceItem.SpaceItemData -> oldItem.space.id == newItem.space.id - oldItem is SpaceItem.AddSpaceItem && newItem is SpaceItem.AddSpaceItem -> true - else -> false - } - } - - override fun areContentsTheSame(oldItem: SpaceItem, newItem: SpaceItem): Boolean { - return when { - oldItem is SpaceItem.SpaceItemData && newItem is SpaceItem.SpaceItemData -> oldItem.space.friendlyName == newItem.space.friendlyName - oldItem is SpaceItem.AddSpaceItem && newItem is SpaceItem.AddSpaceItem -> true - else -> false - } - } - } - } - - abstract class ItemTypeViewHolder(binding: RvDrawerRowBinding) : - RecyclerView.ViewHolder(binding.root) { - abstract fun bind(item: SpaceItem) - } - - inner class SpaceViewHolder(private val binding: RvDrawerRowBinding) : - ItemTypeViewHolder(binding) { - override fun bind(item: SpaceItem) { - - val space = (item as SpaceItem.SpaceItemData).space - - val isSelected = listener.getSelectedSpace()?.id == space.id - val backgroundColor = - if (isSelected) R.color.colorTertiary else R.color.colorDrawerSpaceListBackground - val textColor = if (isSelected) R.color.colorOnBackground else R.color.colorText - - binding.root.setBackgroundColor(binding.root.context.getColor(backgroundColor)) - - val icon = space.getAvatar(binding.rvIcon.context)?.scaled(21, binding.rvIcon.context) - icon?.setTint(binding.rvIcon.context.getColor(R.color.colorOnBackground)) - binding.rvIcon.setImageDrawable(icon) - - binding.rvTitle.text = space.friendlyName - binding.rvTitle.setTextColor(binding.rvTitle.context.getColor(textColor)) - - binding.root.setOnClickListener { - onItemSelected(space) - } - } - - private fun onItemSelected(space: Space) { - val previousIndex = - currentList.indexOfFirst { it is SpaceItem.SpaceItemData && it.space.id == selectedSpace?.id } - val newIndex = - currentList.indexOfFirst { it is SpaceItem.SpaceItemData && it.space.id == space.id } - - selectedSpace = space - - if (previousIndex != -1) notifyItemChanged(previousIndex) - if (newIndex != -1) notifyItemChanged(newIndex) - - listener.onSpaceSelected(space) - } - } - - inner class AddSpaceViewHolder(private val binding: RvDrawerRowBinding) : - ItemTypeViewHolder(binding) { - override fun bind(item: SpaceItem) { - val context = binding.rvTitle.context - val backgroundColor = R.color.colorDrawerSpaceListBackground - binding.root.setBackgroundColor(binding.root.context.getColor(backgroundColor)) - binding.rvTitle.text = context.getString(R.string.add_another_account) - binding.rvTitle.setTextColor(ContextCompat.getColor(context, R.color.colorTertiary)) - - val icon = ContextCompat.getDrawable(context, R.drawable.ic_add) - icon?.setTint(ContextCompat.getColor(binding.rvIcon.context, R.color.colorTertiary)) - binding.rvIcon.setImageDrawable(icon) - - binding.root.setOnClickListener { - listener.onAddNewSpace() - } - } - } - - inner class DwebGroupViewHolder(private val binding: RvDrawerRowBinding) : - ItemTypeViewHolder(binding) { - override fun bind(item: SpaceItem) { - val context = binding.rvTitle.context - val backgroundColor = R.color.colorDrawerSpaceListBackground - binding.root.setBackgroundColor(binding.root.context.getColor(backgroundColor)) - binding.rvTitle.text = "Dweb"//context.getString(R.string.dweb_join_group_group_name) - binding.rvTitle.setTextColor(ContextCompat.getColor(context, R.color.colorTertiary)) - - val icon = ContextCompat.getDrawable(context, R.drawable.ic_space_dweb) - icon?.setTint(ContextCompat.getColor(binding.rvIcon.context, R.color.colorTertiary)) - binding.rvIcon.setImageDrawable(icon) - - binding.root.setOnClickListener { - // Handle Dweb group click - listener.onNavigateToDwebGroups() - } - } - } - - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is SpaceItem.SpaceItemData -> VIEW_TYPE_SPACE - is SpaceItem.AddSpaceItem -> VIEW_TYPE_ADD - is SpaceItem.DwebGroupItem -> VIEW_TYPE_DWEB - else -> throw IllegalArgumentException("Invalid view type") - } - } - - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemTypeViewHolder { - val binding = RvDrawerRowBinding.inflate(LayoutInflater.from(parent.context), parent, false) - - return when (viewType) { - VIEW_TYPE_SPACE -> SpaceViewHolder(binding) - VIEW_TYPE_ADD -> AddSpaceViewHolder(binding) - VIEW_TYPE_DWEB -> DwebGroupViewHolder(binding) - else -> throw IllegalArgumentException("Invalid view type") - } - } - - override fun onBindViewHolder(holder: ItemTypeViewHolder, position: Int) { - holder.bind(getItem(position)) - } - - fun update(spaces: List, hasDwebGroups: Boolean) { - val items = mutableListOf() - items.addAll(spaces.map { SpaceItem.SpaceItemData(it) }) - if (hasDwebGroups) items.add(SpaceItem.DwebGroupItem) - items.add(SpaceItem.AddSpaceItem) - - submitList(items) - } - - fun updateSelectedSpace(space: Space?) { - val previousIndex = - currentList.indexOfFirst { it is SpaceItem.SpaceItemData && it.space.id == selectedSpace?.id } - val newIndex = - currentList.indexOfFirst { it is SpaceItem.SpaceItemData && it.space.id == space?.id } - - selectedSpace = space - - if (previousIndex != -1) notifyItemChanged(previousIndex) - if (newIndex != -1) notifyItemChanged(newIndex) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/AppRoute.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/AppRoute.kt new file mode 100644 index 000000000..6d82e2785 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/AppRoute.kt @@ -0,0 +1,147 @@ +package net.opendasharchive.openarchive.features.main.ui + +import android.net.Uri +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.navigation.NavigationResultKeys +import net.opendasharchive.openarchive.features.media.camera.CameraConfig + +@Serializable +sealed class AppRoute(open val deeplink: String) : NavKey { + + @Serializable + data object WelcomeRoute : AppRoute("welcome") + + @Serializable + data object InstructionsRoute : AppRoute("instructions") + + @Serializable + data object HomeRoute : AppRoute("home") + + @Serializable + data object SpaceSetupRoute : AppRoute("space_setup") + + @Serializable + data object WebDavLoginRoute : AppRoute("webdav_login") + + @Serializable + data class WebDavDetailRoute( + val spaceId: Long, + ) : AppRoute("webdav_detail") + + @Serializable + data object IALoginRoute : AppRoute("ia_login") + + @Serializable + data class SetupLicenseRoute( + val spaceId: Long, + val spaceType: VaultType, + ) : AppRoute("setup_license") + + @Serializable + data class SpaceSetupSuccessRoute( + val spaceType: VaultType + ) : AppRoute("space_setup_success") + + @Serializable + data object SpaceListRoute : AppRoute("space_list") + + @Serializable + data class IADetailRoute(val spaceId: Long) : AppRoute("ia_detail") + + @Serializable + data class AddFolderRoute( + val spaceId: Long, + ) : AppRoute("add_folder") + + @Serializable + data object CreateNewFolderRoute : AppRoute("create_new_folder") + + @Serializable + data object BrowseExistingFoldersRoute : AppRoute("browse_existing_folders") + + @Serializable + data class FolderListRoute( + val showArchived: Boolean, + val spaceId: Long?, + ) : AppRoute("folder_list") + + @Serializable + data class FolderDetailRoute(val currentProjectId: Long) : AppRoute("folder_detail") + + @Serializable + data object C2paSettings : AppRoute("c2pa_settings") + + @Serializable + data class PreviewMediaRoute(val projectId: Long) : AppRoute("preview_media") + + @Serializable + data class ReviewMediaRoute( + val mediaIds: LongArray, + val selectedIdx: Int = 0, + val batchMode: Boolean = false + ) : AppRoute("review_media") + + @Serializable + data object PasscodeSetupRoute : AppRoute("passcode_setup") + + @Serializable + data object PasscodeEntryRoute : AppRoute("passcode_entry") + + @Serializable + data object MediaCacheRoute : AppRoute("media_cache") + + @Serializable + data class CameraRoute( + val projectId: Long, + val config: CameraConfig, + val resultKey: String = NavigationResultKeys.CAMERA_CAPTURE_RESULT + ) : AppRoute("camera") + + // ── Snowbird Routes ── + + @Serializable + data object SnowbirdDashboardRoute : AppRoute("snowbird_dashboard") + + @Serializable + data object SnowbirdCreateGroupRoute : AppRoute("snowbird_create_group") + + @Serializable + data class SnowbirdJoinGroupRoute( + val groupKey: String + ) : AppRoute("snowbird_join_group") + + @Serializable + data object SnowbirdGroupListRoute : AppRoute("snowbird_group_list") + + @Serializable + data class SnowbirdShareRoute( + val groupKey: String, + ) : AppRoute("snowbird_share") + + @Serializable + data class SnowbirdRepoListRoute( + val vaultId: Long, + val groupKey: String + ) : AppRoute("snowbird_repo_list") + + @Serializable + data class SnowbirdFileListRoute( + val archiveId: Long, + val groupKey: String, + val repoKey: String, + val canWrite: Boolean = true + ) : AppRoute("snowbird_file_list") + + @Serializable + data object SnowbirdQRScannerRoute : AppRoute("snowbird_qr_scanner") +} + +/** + * Result data class for camera capture results passed via ResultEventBus. + */ +data class CameraCaptureResult( + val projectId: Long, + val capturedUris: List +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/CustomButton.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/CustomButton.kt deleted file mode 100644 index 7ab952413..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/CustomButton.kt +++ /dev/null @@ -1,63 +0,0 @@ -package net.opendasharchive.openarchive.features.main.ui - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.TextView -import net.opendasharchive.openarchive.R - -class CustomButton @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr) { - - private val leftIcon: ImageView - private val rightIcon: ImageView - private val titleText: TextView - private val subTitleText: TextView - - init { - LayoutInflater.from(context).inflate(R.layout.custom_button, this, true) - leftIcon = findViewById(R.id.leftIcon) - rightIcon = findViewById(R.id.rightIcon) - titleText = findViewById(R.id.title) - subTitleText = findViewById(R.id.subTitle) - - isClickable = true - isFocusable = true - - subTitleText.visibility = GONE - } - - fun setLeftIcon(drawable: Drawable?) { - leftIcon.setImageDrawable(drawable) - } - - fun setLeftResource(iconResId: Int) { - leftIcon.setImageResource(iconResId) - } - - fun setRightResource(iconResId: Int) { - rightIcon.setImageResource(iconResId) - } - - fun setRightIcon(drawable: Drawable?) { - rightIcon.setImageDrawable(drawable) - } - - fun setTitle(text: String?) { - titleText.text = text ?: "" - } - - fun setSubTitle(text: String?) { - text?.let { - subTitleText.text = text - subTitleText.visibility = VISIBLE - } - } -} \ No newline at end of file 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 new file mode 100644 index 000000000..f745cff41 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/FolderBar.kt @@ -0,0 +1,386 @@ +package net.opendasharchive.openarchive.features.main.ui + +import android.content.Context +import android.view.inputmethod.InputMethodManager +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import net.opendasharchive.openarchive.features.main.ui.components.SpaceIcon +import java.text.NumberFormat + +// Folder Bar Composable +@Composable +internal fun FolderBar( + state: FolderBarState, + menu: ImmutableList = defaultFolderMenu(state.projectName != null), + onIntent: (FolderBarIntent) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + when (state.mode) { + FolderBarMode.INFO -> FolderBarInfoMode( + state = state, + menu = menu, + onIntent = onIntent, + ) + + FolderBarMode.SELECTION -> FolderBarSelectionMode( + selectedCount = state.selectedCount, + onCancel = { onIntent(FolderBarIntent.CancelSelection) }, + onDelete = { onIntent(FolderBarIntent.DeleteSelectedMediaRequest) }, + ) + + FolderBarMode.EDIT -> FolderBarEditMode( + initialName = state.projectName ?: "", + onCancel = { onIntent(FolderBarIntent.CancelEdit) }, + onSave = { onIntent(FolderBarIntent.SaveName(it)) } + ) + } + } +} + +@Immutable +data class FolderBarState( + val mode: FolderBarMode, + val spaceType: VaultType? = null, + val projectName: String? = null, + val totalMediaCount: Int = 0, + val selectedCount: Int = 0, + val showOptionsPopup: Boolean = false, + val canShowOptions: Boolean = true, // optional +) + +sealed interface FolderBarIntent { + data object OptionsOpened : FolderBarIntent + data object OptionsDismissed : FolderBarIntent + + // menu actions + data object SelectMedia : FolderBarIntent + data object RenameFolder : FolderBarIntent + data object ToggleArchive : FolderBarIntent + data object RemoveFolder : FolderBarIntent + + // selection mode actions + data object CancelSelection : FolderBarIntent + data object DeleteSelectedMediaRequest : FolderBarIntent + + // edit mode actions + data object CancelEdit : FolderBarIntent + data class SaveName(val name: String) : FolderBarIntent +} + +@Immutable +sealed class FolderMenuItem( + @param:StringRes val titleRes: Int, + val intent: FolderBarIntent, +) { + data object SelectMedia : FolderMenuItem( + titleRes = R.string.lbl_select_media, + intent = FolderBarIntent.SelectMedia + ) + + data object Rename : FolderMenuItem( + titleRes = R.string.lbl_rename_folder, + intent = FolderBarIntent.RenameFolder + ) + + data object Archive : FolderMenuItem( + titleRes = R.string.popup_archive_folder, + intent = FolderBarIntent.ToggleArchive + ) + + data object Remove : FolderMenuItem( + titleRes = R.string.popup_remove_folder, + intent = FolderBarIntent.RemoveFolder + ) +} + +fun defaultFolderMenu(hasProject: Boolean): ImmutableList = + if (!hasProject) persistentListOf() + else persistentListOf( + FolderMenuItem.SelectMedia, + FolderMenuItem.Rename, + FolderMenuItem.Archive, + FolderMenuItem.Remove + ) + +@Composable +private fun RowScope.FolderBarInfoMode( + state: FolderBarState, + menu: ImmutableList, + onIntent: (FolderBarIntent) -> Unit, +) { + val hasProject = state.projectName != null + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + // Space Icon + state.spaceType?.let { + + SpaceIcon( + type = it, + modifier = Modifier.size(28.dp) + ) + + // Arrow + Icon( + painter = painterResource(R.drawable.keyboard_arrow_right), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = colorResource(R.color.colorOnBackground) + ) + + // Folder Name + Text( + text = state.projectName ?: "", + style = MaterialTheme.typography.titleMedium.copy(fontFamily = MontserratFontFamily), + modifier = Modifier.weight(1f, fill = false) + ) + } + + + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (hasProject) { + Box { + // Edit Button + IconButton( + onClick = { onIntent(FolderBarIntent.OptionsOpened) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_edit_folder), + contentDescription = stringResource(R.string.edit), + tint = colorResource(R.color.colorTertiary), + modifier = Modifier.size(24.dp) + ) + } + + DropdownMenu( + expanded = state.showOptionsPopup, + onDismissRequest = { onIntent(FolderBarIntent.OptionsDismissed) } + ) { + menu.forEach { item -> + val enabled = item !is FolderMenuItem.SelectMedia || state.totalMediaCount > 0 + DropdownMenuItem( + text = { Text(stringResource(id = item.titleRes)) }, + enabled = enabled, + onClick = { + onIntent(FolderBarIntent.OptionsDismissed) + onIntent(item.intent) + } + ) + } + } + } + // Count Pill + Box( + modifier = Modifier + .background( + colorResource(R.color.colorPillTransparent), + RoundedCornerShape(12.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = NumberFormat.getInstance().format(state.totalMediaCount), + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Composable +private fun RowScope.FolderBarSelectionMode( + selectedCount: Int, + onDelete: () -> Unit, + onCancel: () -> Unit, +) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + // Close Button + IconButton( + onClick = onCancel, + modifier = Modifier.size(28.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.action_cancel), + tint = colorResource(R.color.colorOnBackground) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // "Select Media" Text + Text( + text = stringResource(R.string.lbl_select_media), + style = MaterialTheme.typography.titleMedium.copy(fontFamily = MontserratFontFamily) + ) + } + + // Remove Button (only show if items selected) + if (selectedCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onDelete() } + ) { + Icon( + painter = painterResource(R.drawable.ic_trash), + contentDescription = null, + tint = colorResource(R.color.colorError), + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.lbl_remove), + color = colorResource(R.color.colorError), + style = MaterialTheme.typography.titleMedium + ) + } + } +} + +@Composable +private fun FolderBarEditMode( + initialName: String, + onSave: (String) -> Unit, + onCancel: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + val view = LocalView.current + var folderName by remember { + mutableStateOf( + TextFieldValue( + text = initialName, + selection = TextRange(0, initialName.length) + ) + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + // Show keyboard + keyboard?.show() + } + + fun closeImeAndClearFocus() { + focusManager.clearFocus() + keyboard?.hide() + + // fallback (optional but reliable) using a real token + val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Close Button + IconButton( + onClick = { + closeImeAndClearFocus() + onCancel() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.action_cancel), + tint = colorResource(R.color.colorOnBackground) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Text Input + OutlinedTextField( + value = folderName, + onValueChange = { folderName = it }, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + textStyle = MaterialTheme.typography.titleMedium.copy(fontFamily = MontserratFontFamily), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + closeImeAndClearFocus() + onSave(folderName.text) + } + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeAction.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeAction.kt new file mode 100644 index 000000000..e2b137be0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeAction.kt @@ -0,0 +1,34 @@ +package net.opendasharchive.openarchive.features.main.ui + +import android.net.Uri +import net.opendasharchive.openarchive.features.main.ui.components.HomeBottomTab +import net.opendasharchive.openarchive.features.media.AddMediaType + +sealed class HomeAction { + data object Load : HomeAction() + data class SelectSpace(val spaceId: Long) : HomeAction() + data class SelectProject(val projectId: Long?) : HomeAction() + data class UpdatePager(val page: Int) : HomeAction() + data object AddClick : HomeAction() + data object AddLongClick : HomeAction() + data class TabSelected(val tab: HomeBottomTab) : HomeAction() + data object ContentPickerDismissed : HomeAction() + data class ContentPickerPicked(val type: AddMediaType) : HomeAction() + data object ShowUploadManager : HomeAction() + data object HideUploadManager : HomeAction() + data class Navigate(val route: AppRoute) : HomeAction() + data object NavigateToAddNewFolder : HomeAction() + data object NavigateToArchivedFolders : HomeAction() + data object NavigateToPreviewMedia : HomeAction() + data object NavigateToCamera: HomeAction() + data class MediaImported(val projectId: Long) : HomeAction() + + // Share-sheet import flow + data class StartSharedImport(val uris: List) : HomeAction() + data object DismissSharedImportPicker : HomeAction() + + // NEW: Project-level actions that modify the projects list + data class RenameProject(val projectId: Long, val newName: String) : HomeAction() + data class ArchiveProject(val projectId: Long) : HomeAction() + data class DeleteProject(val projectId: Long) : HomeAction() +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeEvent.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeEvent.kt new file mode 100644 index 000000000..1fbcde35c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeEvent.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.features.main.ui + +import net.opendasharchive.openarchive.features.media.AddMediaType + +sealed class HomeEvent { + data class NavigateToProject(val projectId: Long) : HomeEvent() + data class LaunchPicker(val type: AddMediaType) : HomeEvent() // Launch native picker + data class ShowMessage(val message: String) : HomeEvent() +} \ No newline at end of file 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 d327b7569..2ce6913ed 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 @@ -1,221 +1,520 @@ package net.opendasharchive.openarchive.features.main.ui -import android.content.Context -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import net.opendasharchive.openarchive.R +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextButton import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue -import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.db.Space +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.navigation.NavigationResultKeys +import net.opendasharchive.openarchive.core.navigation.ResultEffect +import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.features.main.CheckForInAppReview +import net.opendasharchive.openarchive.features.main.CheckForInAppUpdates import net.opendasharchive.openarchive.features.main.ui.components.HomeAppBar +import net.opendasharchive.openarchive.features.main.ui.components.HomeBottomTab import net.opendasharchive.openarchive.features.main.ui.components.MainBottomBar import net.opendasharchive.openarchive.features.main.ui.components.MainDrawerContent -import net.opendasharchive.openarchive.features.main.ui.components.SpaceIcon import net.opendasharchive.openarchive.features.media.AddMediaType +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.features.media.ContentPickerSheet +import net.opendasharchive.openarchive.features.media.MediaPicker +import net.opendasharchive.openarchive.features.media.rememberContentPickerLaunchers import net.opendasharchive.openarchive.features.settings.SettingsScreen -import net.opendasharchive.openarchive.util.Prefs +import net.opendasharchive.openarchive.upload.UploadManagerScreen +import net.opendasharchive.openarchive.upload.UploadManagerViewModel +import net.opendasharchive.openarchive.util.rememberComposePermissionManager import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf import kotlin.math.max - -@Serializable -data object HomeRoute - -@Serializable -data object MediaCacheRoute - +/** + * IMPROVED HomeScreen: + * - Single HomeViewModel as source of truth + * - Bridges MainMediaViewModel events → HomeViewModel actions + * - No unnecessary HomeState copying + * - Proper reactive data flow + */ @Composable -fun SaveNavGraph( - context: Context, +fun HomeScreen( viewModel: HomeViewModel = koinViewModel(), - onExit: () -> Unit, - onNewFolder: () -> Unit, - onFolderSelected: (Long) -> Unit, - onAddMedia: (AddMediaType) -> Unit ) { - val navController = rememberNavController() - SaveAppTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } - NavHost( - navController = navController, - startDestination = HomeRoute - ) { + LaunchedEffect(uiState.showProjectPickerForImport) { + net.opendasharchive.openarchive.core.logger.AppLogger.d("SHARE_DEBUG: HomeScreen showProjectPickerForImport=${uiState.showProjectPickerForImport}") + } - composable { - HomeScreen( - viewModel = viewModel, - onExit = onExit, - onNewFolder = onNewFolder, - onFolderSelected = onFolderSelected, - onAddMedia = onAddMedia, - onNavigateToCache = { - navController.navigate(MediaCacheRoute) - } - ) + // Handle In-App Updates + CheckForInAppUpdates(snackbarHostState) + + // Handle In-App Review + CheckForInAppReview() + + val context = LocalContext.current + val noFolderMessage = stringResource(R.string.tap_to_add_folder) + val scope = rememberCoroutineScope() + var manualImportInProgress by remember { mutableStateOf(false) } + var importSnackbarJob by remember { mutableStateOf(null) } + var pendingImportedCount by remember { mutableIntStateOf(-1) } + var pendingImportedProjectId by remember { mutableStateOf(null) } + + val projectRepository: ProjectRepository = koinInject() + val mediaRepository: MediaRepository = koinInject() + + // Content Picker Launchers for Gallery/Files + // Camera is handled via navigation + val pickerLaunchers = rememberContentPickerLaunchers( + projectProvider = { uiState.projects.firstOrNull { it.id == uiState.selectedProjectId } }, + onError = { message -> + scope.launch { + snackbarHostState.showSnackbar(message) + } + }, + onMediaImported = { evidenceList -> + pendingImportedCount = evidenceList.size + pendingImportedProjectId = uiState.selectedProjectId + } + ) + val isImporting = pickerLaunchers.isProcessing || manualImportInProgress + + LaunchedEffect(isImporting) { + if (isImporting) { + if (importSnackbarJob == null) { + importSnackbarJob = scope.launch { + snackbarHostState.showSnackbar( + message = "Importing media...", + duration = SnackbarDuration.Indefinite + ) + } } + } else { + importSnackbarJob?.cancel() + importSnackbarJob = null + snackbarHostState.currentSnackbarData?.dismiss() + } + } - composable { - MediaCacheScreen(context) { - navController.popBackStack() + LaunchedEffect(pickerLaunchers.isProcessing, pendingImportedCount, pendingImportedProjectId) { + if (!pickerLaunchers.isProcessing && pendingImportedCount >= 0) { + if (pendingImportedCount > 0 && pendingImportedProjectId != null) { + scope.launch { + snackbarHostState.showSnackbar("Media imported") + } + viewModel.onAction(HomeAction.MediaImported(pendingImportedProjectId!!)) + } else { + scope.launch { + snackbarHostState.showSnackbar("Import failed") + } + } + pendingImportedCount = -1 + pendingImportedProjectId = null + } + } + val permissionManager = rememberComposePermissionManager() + + // Receive camera capture results from CameraScreen via ResultEventBus + ResultEffect(resultKey = NavigationResultKeys.CAMERA_CAPTURE_RESULT) { result -> + manualImportInProgress = true + scope.launch(Dispatchers.IO) { + try { + val archive = projectRepository.getProject(result.projectId) + if (archive != null && result.capturedUris.isNotEmpty()) { + val submission = projectRepository.getActiveSubmission(archive.id) + val evidenceList = MediaPicker.import( + context, + archive, + submission.id, + result.capturedUris, + ) + evidenceList.forEach { evidence -> + mediaRepository.addEvidence(evidence) + } + withContext(Dispatchers.Main) { + if (evidenceList.isNotEmpty()) { + scope.launch { + snackbarHostState.showSnackbar("Media imported") + } + viewModel.onAction(HomeAction.MediaImported(result.projectId)) + } else { + scope.launch { + snackbarHostState.showSnackbar("Import failed") + } + } + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + snackbarHostState.showSnackbar("Failed to import: ${e.localizedMessage}") + } + } finally { + withContext(Dispatchers.Main) { + manualImportInProgress = false } } + } + } + // Receive space added result to refresh space list after coming from space setup complete screen + ResultEffect(resultKey = NavigationResultKeys.REFRESH_SPACES) { success -> + if (success) { + viewModel.onAction(HomeAction.Load) } } -} -@Composable -fun HomeScreen( - viewModel: HomeViewModel = koinViewModel(), - onExit: () -> Unit, - onNewFolder: () -> Unit, - onFolderSelected: (Long) -> Unit, - onAddMedia: (AddMediaType) -> Unit, - onNavigateToCache: () -> Unit -) { + LaunchedEffect(Unit) { + viewModel.uiEvent.collectLatest { event -> + when (event) { + is HomeEvent.LaunchPicker -> { + val hasFolder = uiState.selectedProjectId != null && + uiState.projects.any { it.id == uiState.selectedProjectId } + if (!hasFolder) { + snackbarHostState.showSnackbar(noFolderMessage) + } else { + when (event.type) { + AddMediaType.CAMERA -> { + viewModel.onAction(HomeAction.NavigateToCamera) + } + + AddMediaType.GALLERY -> { + permissionManager.checkMediaPermissions { + pickerLaunchers.launch(AddMediaType.GALLERY) + } + } - val state by viewModel.uiState.collectAsStateWithLifecycle() + AddMediaType.FILES -> { + pickerLaunchers.launch(AddMediaType.FILES) + } + } + } + } + + is HomeEvent.NavigateToProject -> Unit + is HomeEvent.ShowMessage -> { + snackbarHostState.showSnackbar(event.message) + } + } + } + } HomeScreenContent( - onExit = onExit, - state = state, + state = uiState, onAction = viewModel::onAction, - onNavigateToCache = onNavigateToCache + snackbarHostState = snackbarHostState, + showImportLoading = isImporting ) - -} - -class HomeViewModel : ViewModel() { - private val _uiState = MutableStateFlow(HomeScreenState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - loadSpacesAndFolders() - } - - fun onAction(action: HomeScreenAction) { - when (action) { - is HomeScreenAction.UpdateSelectedProject -> { - _uiState.update { it.copy(selectedProject = action.project) } + // Two-step project picker bottom sheet for share-sheet imports + // Step 1: pick a space; Step 2: pick a folder within that space + if (uiState.showProjectPickerForImport) { + var selectedSpace by remember { mutableStateOf(null) } + var spaceFolders by remember { mutableStateOf>(emptyList()) } + var foldersLoading by remember { mutableStateOf(false) } + + LaunchedEffect(selectedSpace) { + val space = selectedSpace ?: return@LaunchedEffect + foldersLoading = true + spaceFolders = try { + projectRepository.getProjects(space.id) + } catch (e: Exception) { + emptyList() } - - is HomeScreenAction.AddMediaClicked -> TODO() + foldersLoading = false } - } - private fun loadSpacesAndFolders() { - viewModelScope.launch { - val allSpaces = Space.getAll().asSequence().toList() - val selectedSpace = Space.current - val projectsForSelectedSpace = selectedSpace?.projects ?: emptyList() - - _uiState.update { - it.copy( - allSpaces = allSpaces, - projectsForSelectedSpace = projectsForSelectedSpace, - selectedSpace = selectedSpace, - selectedProject = projectsForSelectedSpace.firstOrNull() - ) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { + selectedSpace = null + viewModel.onAction(HomeAction.DismissSharedImportPicker) + }, + sheetState = sheetState + ) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + if (selectedSpace == null) { + // ── Step 1: space list ── + Text( + text = "Select server", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + HorizontalDivider() + if (uiState.spaces.isEmpty()) { + Text( + text = "No servers configured. Add a server first.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp) + ) + } else { + LazyColumn { + items(uiState.spaces) { space -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedSpace = space } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_folder), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = space.friendlyName, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = space.type.friendlyName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } else { + // ── Step 2: folder list for selected space ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 16.dp, top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { selectedSpace = null }) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + Text( + text = selectedSpace!!.friendlyName, + style = MaterialTheme.typography.titleMedium + ) + } + HorizontalDivider() + when { + foldersLoading -> { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text("Loading folders…", style = MaterialTheme.typography.bodyMedium) + } + } + spaceFolders.isEmpty() -> { + Text( + text = "No folders found in this server.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp) + ) + } + else -> { + LazyColumn { + items(spaceFolders) { project -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val uris = uiState.pendingSharedUris ?: return@clickable + selectedSpace = null + viewModel.onAction(HomeAction.DismissSharedImportPicker) + manualImportInProgress = true + scope.launch(Dispatchers.IO) { + try { + val submission = projectRepository.getActiveSubmission(project.id) + val evidenceList = MediaPicker.import( + context, + project, + submission.id, + uris, + ) + evidenceList.forEach { mediaRepository.addEvidence(it) } + withContext(Dispatchers.Main) { + if (evidenceList.isNotEmpty()) { + viewModel.onAction(HomeAction.MediaImported(project.id)) + } else { + snackbarHostState.showSnackbar("Import failed") + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + snackbarHostState.showSnackbar("Failed to import: ${e.localizedMessage}") + } + } finally { + withContext(Dispatchers.Main) { + manualImportInProgress = false + } + } + } + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_folder), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = project.description ?: "Unnamed folder", + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } + } + HorizontalDivider() + TextButton( + onClick = { + selectedSpace = null + viewModel.onAction(HomeAction.DismissSharedImportPicker) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Text("Cancel") + } } } } - } -sealed class HomeScreenAction { - data class UpdateSelectedProject(val project: Project? = null) : HomeScreenAction() - data class AddMediaClicked(val mediaType: AddMediaType) : HomeScreenAction() -} - -data class HomeScreenState( - val selectedSpace: Space? = null, - val selectedProject: Project? = null, - val allSpaces: List = emptyList(), - val projectsForSelectedSpace: List = emptyList() -) +/** + * IMPROVED HomeScreenContent: + * - Takes getProject function instead of copying state + * - Properly bridges MainMediaViewModel events to HomeViewModel + * - Cleaner data flow + */ @Composable fun HomeScreenContent( - onExit: () -> Unit, - state: HomeScreenState, - onAction: (HomeScreenAction) -> Unit, - onNavigateToCache: () -> Unit = {} + state: HomeState, + onAction: (HomeAction) -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + showImportLoading: Boolean = false ) { - val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() - val projects = state.projectsForSelectedSpace - val totalPages = max(1, projects.size) + 1 + // Calculate pager configuration + val totalPages = max(1, state.projects.size) + 1 + val settingsIndex = totalPages - 1 - // Always start at last media page (never settings) for fresh starts - // For configuration changes, the activity handles restoration - val initialPage = Prefs.currentHomePage.coerceIn(0, (totalPages - 2).coerceAtLeast(0)) - val pagerState = rememberPagerState(initialPage = initialPage) { totalPages } + val pagerState = rememberPagerState( + initialPage = state.pagerIndex.coerceIn(0, totalPages - 1) + ) { totalPages } - val currentProjectIndex = state.selectedProject?.let { selected -> - projects.indexOfFirst { it.id == selected.id }.takeIf { it >= 0 } ?: 0 - } ?: 0 + val selectedTab: HomeBottomTab = + if (state.pagerIndex == settingsIndex) HomeBottomTab.SETTINGS else HomeBottomTab.MEDIA + val isSettings = selectedTab == HomeBottomTab.SETTINGS - // Save current page ONLY if it's a media page (not settings) - LaunchedEffect(pagerState.currentPage) { - if (pagerState.currentPage < totalPages - 1) { - Prefs.currentHomePage = pagerState.currentPage + val showDrawer = isSettings.not() && (state.spaces.isNotEmpty() || state.hasDwebEntry) + + // Back on settings tab → go to media tab (lower priority, defined first) + BackHandler(enabled = isSettings) { + onAction(HomeAction.TabSelected(HomeBottomTab.MEDIA)) + } + + // Back when drawer is open → close drawer (higher priority, defined last) + BackHandler(enabled = drawerState.isOpen) { + scope.launch { drawerState.close() } + } + + // Sync pager → HomeViewModel ONLY when settled + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect { settledPage -> + onAction(HomeAction.UpdatePager(settledPage)) + } + } + + // HomeViewModel → pager (when state changes) + LaunchedEffect(state.pagerIndex) { + if (!pagerState.isScrollInProgress && pagerState.currentPage != state.pagerIndex) { + val distance = kotlin.math.abs(pagerState.currentPage - state.pagerIndex) + if (distance > 2) { + pagerState.scrollToPage(state.pagerIndex) + } else { + pagerState.animateScrollToPage(state.pagerIndex) + } } } - // Whenever the pager's current page changes and it represents a project page, - // update the view model's selected project. - LaunchedEffect(pagerState.currentPage, projects) { - if (projects.isNotEmpty() && pagerState.currentPage < projects.size) { - val newlySelectedProject = projects[pagerState.currentPage] - onAction(HomeScreenAction.UpdateSelectedProject(newlySelectedProject)) + // React to project list changes - update pager page count + LaunchedEffect(state.projects.size) { + // Pager will automatically rebuild with new page count via rememberPagerState + // If current page is out of bounds, coerce it + if (pagerState.currentPage >= totalPages) { + pagerState.scrollToPage(settingsIndex) } } @@ -223,13 +522,46 @@ fun HomeScreenContent( ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = true, + gesturesEnabled = showDrawer, drawerContent = { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + MainDrawerContent( - selectedSpace = state.selectedSpace, - spaceList = state.allSpaces + selectedSpace = state.currentSpace, + spaceList = state.spaces, + projects = state.projects, + selectedProject = state.projects.firstOrNull { it.id == state.selectedProjectId }, + onProjectSelected = { project -> + // Update selected project and close drawer + onAction(HomeAction.SelectProject(project.id)) + scope.launch { + drawerState.close() + // Navigate to the project's page + val projectIndex = state.projects.indexOf(project) + if (projectIndex >= 0) { + pagerState.scrollToPage(projectIndex) + } + } + }, + onSpaceSelected = { spaceId -> + scope.launch { drawerState.close() } + onAction(HomeAction.SelectSpace(spaceId)) + }, + onAddNewSpaceClicked = { + scope.launch { drawerState.close() } + onAction(HomeAction.Navigate(route = AppRoute.SpaceSetupRoute)) + }, + showDwebEntry = state.hasDwebEntry, + onDwebSelected = { + scope.launch { drawerState.close() } + onAction(HomeAction.Navigate(route = AppRoute.SnowbirdDashboardRoute)) + }, + onAddNewFolderClicked = { + scope.launch { drawerState.close() } + onAction(HomeAction.NavigateToAddNewFolder) + }, ) + } } ) { @@ -238,7 +570,7 @@ fun HomeScreenContent( Scaffold( topBar = { HomeAppBar( - onExit = onExit, + showDrawer = showDrawer, openDrawer = { scope.launch { drawerState.open() @@ -249,148 +581,178 @@ fun HomeScreenContent( bottomBar = { MainBottomBar( - isSettings = pagerState.currentPage == (totalPages - 1), - onAddMediaClick = {}, - onMyMediaClick = { - // When "My Media" is tapped, scroll to the page of the currently selected project. - // If no project is selected, default to the first page. - val targetPage = if (projects.isEmpty()) 0 else currentProjectIndex - if (pagerState.currentPage != targetPage) { - scope.launch { pagerState.scrollToPage(targetPage) } - } + selectedTab = selectedTab, + onTabSelected = { tab -> + onAction(HomeAction.TabSelected(tab)) }, - onSettingsClick = { - // Scroll to the last page if not already there. - if (pagerState.currentPage != totalPages - 1) { - scope.launch { pagerState.scrollToPage(totalPages - 1) } - } + onAddClick = { + onAction(HomeAction.AddClick) + }, + + onAddLongClick = { + onAction(HomeAction.AddLongClick) } ) - } - - ) { paddingValues -> - - Column( - modifier = Modifier.padding(paddingValues) - ) { - AnimatedVisibility( - visible = pagerState.currentPage < totalPages - 1, - enter = slideInHorizontally( - animationSpec = tween() - ), - exit = slideOutHorizontally( - animationSpec = tween() - ) - ) { - val selectedProject = state.selectedProject - val selectedSpace = state.selectedSpace - - val folderName = selectedProject?.description - ?: selectedProject?.created.toString() + }, - selectedSpace?.let { space -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensionResource(R.dimen.activity_horizontal_margin)), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { snackbarData -> + if (showImportLoading) { + Snackbar { Row { - SpaceIcon( - type = space.tType, - modifier = Modifier.size(24.dp) - ) - Icon( - painter = painterResource(R.drawable.keyboard_arrow_right), - contentDescription = null - ) - Text(folderName) - } - - - TextButton( - onClick = {} - ) { - Icon( - painter = painterResource(R.drawable.ic_edit_folder), - contentDescription = null + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp ) - Text("Edit") + Spacer(modifier = Modifier.width(8.dp)) + Text(snackbarData.visuals.message) } } + } else { + Snackbar(snackbarData = snackbarData) } - } + } + ) { paddingValues -> + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + beyondViewportPageCount = 0, + // IMPORTANT: Set a key so pager items are properly keyed by content + key = { page -> + if (page == settingsIndex) { + "settings" + } else { + state.projects.getOrNull(page)?.id ?: "empty_$page" + } + } + ) { page -> + + val isSettingsPage = page == settingsIndex + val projectForPage = state.projects.getOrNull(page) + + when { + isSettingsPage -> { + SettingsScreen( + onNavigateToSpaceList = { + onAction(HomeAction.Navigate(AppRoute.SpaceListRoute)) + }, + onNavigateToArchivedFolders = { + onAction(HomeAction.NavigateToArchivedFolders) + }, + onNavigateToCache = { + onAction(HomeAction.Navigate(AppRoute.MediaCacheRoute)) + }, + onNavigateToC2pa = { + onAction(HomeAction.Navigate(AppRoute.C2paSettings)) + }, + ) + } - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { page -> - - when (page) { - 0 -> { - // First page: If no projects, show -1, else show first project's ID - MainMediaScreen(projectId = if (projects.isEmpty()) -1 else projects[0].id) - } - - in 1 until projects.size -> { - // Next project IDs (page - 1) - MainMediaScreen(projects[page].id) - } - - totalPages - 1 -> { - // Always settings screen as the last page - SettingsScreen( - onNavigateToCache = onNavigateToCache - ) + projectForPage != null -> { + val projectId = projectForPage.id + val viewModel = koinViewModel( + key = "media_$projectId", + parameters = { parametersOf(projectId) } + ) + + // Bridge MainMediaViewModel events → HomeViewModel actions + LaunchedEffect(projectId) { + viewModel.projectEvent.collectLatest { event -> + when (event) { + is MainMediaProjectEvent.RequestProjectRename -> { + onAction(HomeAction.RenameProject(event.projectId, event.newName)) + } + + is MainMediaProjectEvent.RequestProjectArchive -> { + onAction(HomeAction.ArchiveProject(event.projectId)) + } + + is MainMediaProjectEvent.RequestProjectDelete -> { + onAction(HomeAction.DeleteProject(event.projectId)) + } + } + } } - else -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text("Unexpected page index") + MainMediaScreen( + viewModel = viewModel, + refreshProjectId = state.mediaRefreshProjectId, + refreshToken = state.mediaRefreshToken, + onNavigateToPreview = { + onAction(HomeAction.NavigateToPreviewMedia) + }, + onShowUploadManager = { + onAction(HomeAction.ShowUploadManager) } - } // This should never be reached + ) + } + + else -> { + // No projects yet: show empty media state with current space from HomeViewModel + MainMediaContent( + state = MainMediaState(currentSpace = state.currentSpace, isLoading = false), + onAction = {} + ) } } } } + } } } + + // Content Picker Bottom Sheet + if (state.showContentPicker) { + ContentPickerSheet( + onDismiss = { + onAction(HomeAction.ContentPickerDismissed) + }, + onMediaTypeSelected = { type -> + onAction(HomeAction.ContentPickerPicked(type)) + } + ) + } + + // Hoist UploadManagerViewModel outside the if-block so its viewModelScope and + // reactive observers (InvalidationBus, UploadEventBus) are bound to HomeScreenContent's + // stable ViewModelStoreOwner rather than the ModalBottomSheet's dialog window scope, + // which would create a fresh ViewModel (and cancel its flows) on every open/close. + val uploadManagerViewModel: UploadManagerViewModel = koinViewModel() + + // Upload Manager Bottom Sheet + if (state.showUploadManager) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { + onAction(HomeAction.HideUploadManager) + }, + sheetState = sheetState + ) { + UploadManagerScreen( + viewModel = uploadManagerViewModel, + onClose = { + onAction(HomeAction.HideUploadManager) + } + ) + } + } } @Preview @Composable private fun MainContentPreview() { - SaveAppTheme { + DefaultBoxPreview { HomeScreenContent( - onExit = {}, - state = HomeScreenState(), - onAction = {} + state = HomeState(), + onAction = {}, ) } } - - -//@Composable -//fun MainMediaScreen(projectId: Long) { -// -// val fragmentState = rememberFragmentState() -// -// AndroidFragment( -// modifier = Modifier.fillMaxSize(), -// fragmentState = fragmentState, -// arguments = bundleOf("project_id" to projectId), -// onUpdate = { -// // -// } -// ) -//} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeState.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeState.kt new file mode 100644 index 000000000..78d624c1d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeState.kt @@ -0,0 +1,32 @@ +package net.opendasharchive.openarchive.features.main.ui + +import android.net.Uri +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Vault + +/** + * Activity-scoped state for the Home shell. + * This is the SINGLE SOURCE OF TRUTH for: + * - All spaces + * - Current selected space + * - All projects in the current space + * - Currently selected project ID + * - Pager state + * + * MainMediaViewModel should NOT duplicate this data. + */ +data class HomeState( + val spaces: List = emptyList(), + val hasDwebEntry: Boolean = false, + val currentSpace: Vault? = null, + val projects: List = emptyList(), + val selectedProjectId: Long? = null, + val pagerIndex: Int = 0, + val lastMediaIndex: Int = 0, + val showContentPicker: Boolean = false, + val showUploadManager: Boolean = false, + val mediaRefreshProjectId: Long? = null, + val mediaRefreshToken: Long = 0L, + val pendingSharedUris: List? = null, + val showProjectPickerForImport: Boolean = false, +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeViewModel.kt new file mode 100644 index 000000000..99dbca395 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeViewModel.kt @@ -0,0 +1,393 @@ +package net.opendasharchive.openarchive.features.main.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.main.ui.HomeEvent.LaunchPicker +import net.opendasharchive.openarchive.features.main.ui.components.HomeBottomTab +import net.opendasharchive.openarchive.features.media.AddMediaType +import net.opendasharchive.openarchive.features.media.MediaPicker +import net.opendasharchive.openarchive.features.media.camera.CameraConfig +import net.opendasharchive.openarchive.upload.UploadJobScheduler +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.util.Prefs + +/** + * HomeViewModel handles logic for the home screen including spaces and projects. + */ +class HomeViewModel( + private val route: AppRoute.HomeRoute, + private val navigator: Navigator, + private val spaceRepository: SpaceRepository, + private val projectRepository: ProjectRepository, + private val uploadJobScheduler: UploadJobScheduler, + private val sharedImportState: SharedImportState +) : ViewModel() { + + private val _uiState = MutableStateFlow(HomeState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEvent = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() + + init { + observeData() + observeSharedImport() + } + + private fun observeData() { + + combine( + spaceRepository.observeSpaces(), + spaceRepository.observeCurrentSpace(), + spaceRepository.observeHasDwebSpace(), + ) { spaces, current, hasDwebEntry -> + Triple(spaces, current, hasDwebEntry) + }.flatMapLatest { (spaces, current, hasDwebEntry) -> + val actualCurrent = current ?: spaces.firstOrNull() + if (current == null && actualCurrent != null) { + viewModelScope.launch { + spaceRepository.setCurrentSpace(actualCurrent.id) + } + } + + val projectsFlow = actualCurrent?.let { projectRepository.observeProjects(it.id) } + ?: flowOf(emptyList()) + + projectsFlow.map { projects -> + ObservedData(spaces, actualCurrent, projects, hasDwebEntry) + } + }.onEach { data -> + _uiState.update { state -> + val selectedProjectId = + state.selectedProjectId?.takeIf { id -> data.projects.any { it.id == id } } + ?: data.projects.firstOrNull()?.id + + val currentSettingsIndex = settingsIndex(state.projects.size) + val wasOnSettings = state.pagerIndex == currentSettingsIndex + + val newPagerIndex = if (wasOnSettings) { + settingsIndex(data.projects.size) + } else { + resolvePagerIndexForProject(selectedProjectId, data.projects) + } + + state.copy( + spaces = data.spaces, + hasDwebEntry = data.hasDwebEntry, + currentSpace = data.currentSpace, + projects = data.projects, + selectedProjectId = selectedProjectId, + pagerIndex = newPagerIndex, + lastMediaIndex = if (newPagerIndex < settingsIndex(data.projects.size)) newPagerIndex else state.lastMediaIndex + ) + } + }.launchIn(viewModelScope) + } + + private fun observeSharedImport() { + sharedImportState.pendingUris + .onEach { uris -> + AppLogger.d("SHARE_DEBUG: HomeViewModel observeSharedImport uris=$uris") + if (uris != null) { + AppLogger.d("SHARE_DEBUG: setting showProjectPickerForImport=true") + _uiState.update { it.copy(pendingSharedUris = uris, showProjectPickerForImport = true) } + sharedImportState.clear() + } + } + .launchIn(viewModelScope) + } + + fun onAction(action: HomeAction) { + when (action) { + HomeAction.Load -> Unit // Already observing + is HomeAction.SelectSpace -> selectSpace(action.spaceId) + is HomeAction.SelectProject -> selectProject(action.projectId) + is HomeAction.UpdatePager -> updatePager(action.page) + HomeAction.AddClick -> handleAddClick() + HomeAction.AddLongClick -> handleAddLongClick() + is HomeAction.TabSelected -> handleTabSelected(action.tab) + HomeAction.ContentPickerDismissed -> { + _uiState.update { it.copy(showContentPicker = false) } + } + + is HomeAction.ContentPickerPicked -> { + _uiState.update { it.copy(showContentPicker = false) } + emitEvent(LaunchPicker(action.type)) + } + + is HomeAction.Navigate -> navigator.navigateTo(action.route) + + HomeAction.ShowUploadManager -> { + _uiState.update { it.copy(showUploadManager = true) } + uploadJobScheduler.cancel() + } + HomeAction.HideUploadManager -> { + _uiState.update { it.copy(showUploadManager = false) } + // In legacy, it resumes if there are pending uploads. + // UploadJobScheduler.schedule() usually checks internally, but we can also check here if needed. + uploadJobScheduler.schedule() + } + + HomeAction.NavigateToAddNewFolder -> { + val spaceId = uiState.value.currentSpace?.id ?: return + navigateToAddFolder(spaceId) + } + + HomeAction.NavigateToArchivedFolders -> { + val spaceId = uiState.value.currentSpace?.id + navigator.navigateTo(AppRoute.FolderListRoute(spaceId = spaceId, showArchived = true)) + } + + HomeAction.NavigateToPreviewMedia -> { + val projectId = uiState.value.selectedProjectId ?: return + navigator.navigateTo(AppRoute.PreviewMediaRoute(projectId = projectId)) + } + + is HomeAction.MediaImported -> viewModelScope.launch { + val spaceId = uiState.value.currentSpace?.id ?: return@launch + val projectId = uiState.value.selectedProjectId ?: return@launch + + _uiState.update { state -> + state.copy( + mediaRefreshProjectId = action.projectId, + mediaRefreshToken = state.mediaRefreshToken + 1L + ) + } + + navigator.navigateTo(AppRoute.PreviewMediaRoute(projectId)) + } + + HomeAction.NavigateToCamera -> viewModelScope.launch { + val projectId = uiState.value.selectedProjectId ?: return@launch + val config = CameraConfig( + allowVideoCapture = true, + allowPhotoCapture = true, + allowMultipleCapture = false, + enablePreview = true, + showFlashToggle = true, + showGridToggle = true, + showCameraSwitch = true + ) + val route = AppRoute.CameraRoute(projectId, config) + navigator.navigateTo(route) + } + + is HomeAction.StartSharedImport -> { + _uiState.update { it.copy( + pendingSharedUris = action.uris, + showProjectPickerForImport = true + )} + } + + HomeAction.DismissSharedImportPicker -> { + _uiState.update { it.copy( + pendingSharedUris = null, + showProjectPickerForImport = false + )} + } + + // NEW: Handle project mutations + is HomeAction.RenameProject -> renameProject(action.projectId, action.newName) + is HomeAction.ArchiveProject -> archiveProject(action.projectId) + is HomeAction.DeleteProject -> deleteProject(action.projectId) + } + } + + private fun selectSpace(spaceId: Long) { + viewModelScope.launch { + spaceRepository.setCurrentSpace(spaceId) + // Projects will be reloaded automatically via observeData + _uiState.update { + it.copy( + pagerIndex = 0, + lastMediaIndex = 0 + ) + } + } + } + + private fun selectProject(projectId: Long?) { + _uiState.update { + it.copy( + selectedProjectId = projectId, + pagerIndex = resolvePagerIndexForProject(projectId, it.projects), + lastMediaIndex = resolvePagerIndexForProject(projectId, it.projects) + ) + } + } + + private fun resolvePagerIndexForProject(projectId: Long?, projects: List): Int { + val idx = projects.indexOfFirst { it.id == projectId } + return if (idx >= 0) idx else 0 + } + + private fun updatePager(page: Int) { + _uiState.update { state -> + val settingsIndex = settingsIndex(state.projects.size) + val isMediaPage = page < settingsIndex + val newSelectedProjectId = + if (isMediaPage) state.projects.getOrNull(page)?.id else state.selectedProjectId + val lastMediaIndex = if (isMediaPage) page else state.lastMediaIndex + if (isMediaPage) Prefs.currentHomePage = page + + val updated = state.copy( + pagerIndex = page, + selectedProjectId = newSelectedProjectId, + lastMediaIndex = lastMediaIndex + ) + updated + } + + } + + private fun handleAddClick() { + val state = _uiState.value + val settingsIndex = settingsIndex(state.projects.size) + val isSettings = state.pagerIndex == settingsIndex + + when { + state.currentSpace == null -> navigator.navigateTo(AppRoute.SpaceSetupRoute) + state.projects.isEmpty() || state.selectedProjectId == null -> { + state.currentSpace.id.let { + navigateToAddFolder(it) + } + } + + isSettings -> { + // When on settings, navigate back to media page and show picker + _uiState.update { + it.copy( + showContentPicker = true, + pagerIndex = state.lastMediaIndex.coerceAtMost(settingsIndex - 1) + ) + } + } + + else -> { + // launch gallery + viewModelScope.launch { + _uiEvent.emit(LaunchPicker(AddMediaType.GALLERY)) + } + } + } + } + + private fun handleAddLongClick() { + val state = _uiState.value + val settingsIndex = settingsIndex(state.projects.size) + val isSettings = state.pagerIndex == settingsIndex + + when { + state.currentSpace == null -> navigator.navigateTo(AppRoute.SpaceSetupRoute) + state.projects.isEmpty() || state.selectedProjectId == null -> { + state.currentSpace.id.let { + navigateToAddFolder(it) + } + } + + isSettings -> { + // When on settings, navigate back to media page and show picker + _uiState.update { + it.copy( + showContentPicker = true, + pagerIndex = state.lastMediaIndex.coerceAtMost(settingsIndex - 1) + ) + } + } + + else -> { + // Show content picker sheet + _uiState.update { it.copy(showContentPicker = true) } + } + } + } + + private fun handleTabSelected(tab: HomeBottomTab) { + val state = _uiState.value + val settingsIndex = settingsIndex(state.projects.size) + when (tab) { + HomeBottomTab.MEDIA -> _uiState.update { + it.copy( + pagerIndex = state.lastMediaIndex.coerceAtMost( + settingsIndex - 1 + ) + ) + } + + HomeBottomTab.SETTINGS -> _uiState.update { it.copy(pagerIndex = settingsIndex) } + } + } + + // NEW: Project mutation methods + + private fun renameProject(projectId: Long, newName: String) { + viewModelScope.launch { + projectRepository.renameProject(projectId, newName) + emitEvent(HomeEvent.ShowMessage("Folder renamed")) + } + } + + private fun archiveProject(projectId: Long) { + viewModelScope.launch { + projectRepository.getProject(projectId)?.let { project -> + projectRepository.archiveProject(projectId, !project.isArchived) + } + emitEvent(HomeEvent.ShowMessage("Folder archived")) + } + } + + private fun deleteProject(projectId: Long) { + viewModelScope.launch { + projectRepository.deleteProject(projectId) + emitEvent(HomeEvent.ShowMessage("Folder removed")) + } + } + + + private fun emitEvent(event: HomeEvent) { + viewModelScope.launch { _uiEvent.emit(event) } + } + + private fun settingsIndex(projectCount: Int): Int = maxOf(1, projectCount) + + private fun navigateToAddFolder(spaceId: Long) { + if (uiState.value.currentSpace?.type == VaultType.INTERNET_ARCHIVE) { + navigator.navigateTo(AppRoute.CreateNewFolderRoute) + } else { + navigator.navigateTo(AppRoute.AddFolderRoute(spaceId)) + } + } +} + +private data class Quadruple( + val first: A, + val second: B, + val third: C, + val fourth: D +) + +private data class ObservedData( + val spaces: List, + val currentSpace: Vault?, + val projects: List, + val hasDwebEntry: Boolean, +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt index a68559606..3c545bdf9 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt @@ -1,13 +1,10 @@ package net.opendasharchive.openarchive.features.main.ui -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.Handler -import android.os.Looper +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,390 +15,477 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Error -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.db.Collection -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.upload.BroadcastManager +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.collectLatest +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.presentation.media.MediaStatusOverlay +import net.opendasharchive.openarchive.core.presentation.media.MediaThumbnail +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import java.text.NumberFormat +import kotlinx.datetime.LocalDateTime +import net.opendasharchive.openarchive.util.format +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight /** - * A data class representing one “section” (i.e. one Collection and its list of Media). - * (Here we wrap the list of media in a mutableStateListOf so that updates trigger recomposition.) + * IMPROVED MainMediaScreen: + * - Receives space and project from parent (HomeScreen) + * - No longer needs homeState + * - Cleaner data flow */ -data class CollectionSection( - val collection: Collection, - val media: SnapshotStateList = mutableStateListOf().apply { addAll(collection.media) } -) - @Composable fun MainMediaScreen( - projectId: Long, + viewModel: MainMediaViewModel, + refreshProjectId: Long?, + refreshToken: Long, + onNavigateToPreview: () -> Unit, + onShowUploadManager: () -> Unit, ) { - val context = LocalContext.current - - // State holding our list of sections (each collection with its media) - val sections = remember { mutableStateListOf() } - // Flag to track if any media is “selected” (for deletion) - var isSelecting by remember { mutableStateOf(false) } - // State to control showing the “delete confirmation” dialog. - var showDeleteDialog by remember { mutableStateOf(false) } - // State to control showing an error/retry dialog for a media item. - var errorDialogData by remember { mutableStateOf(null) } - - - // Handle broadcast messages - DisposableEffect(Unit) { - val handler = Handler(Looper.getMainLooper()) - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val action = BroadcastManager.getAction(intent) ?: return - when (action) { - BroadcastManager.Action.Change -> { - // Extract extras from the intent (assuming these keys are provided) - val collectionId = intent.getLongExtra("collectionId", -1) - val mediaId = intent.getLongExtra("mediaId", -1) - val progress = intent.getIntExtra("progress", 0) - val isUploaded = intent.getBooleanExtra("isUploaded", false) - if (collectionId != -1L && mediaId != -1L) { - handler.post { - updateMediaItem( - sections = sections, - collectionId = collectionId, - mediaId = mediaId, - progress = progress, - isUploaded = isUploaded - ) - } - } - } - BroadcastManager.Action.Delete -> { - handler.post { refreshSections(projectId, sections) } - } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val lazyListState = rememberLazyListState() + + LaunchedEffect(refreshToken, uiState.projectId, refreshProjectId) { + val projectId = uiState.projectId ?: return@LaunchedEffect + if (refreshProjectId == projectId) { + viewModel.onAction(MainMediaAction.Refresh(projectId)) + lazyListState.animateScrollToItem(0) + } + } + + LaunchedEffect(Unit) { + viewModel.uiEvent.collectLatest { event -> + when (event) { + is MainMediaEvent.NavigateToPreview -> { + onNavigateToPreview() + } + + is MainMediaEvent.ShowUploadManager -> { + onShowUploadManager() } + is MainMediaEvent.SelectionModeChanged -> Unit + MainMediaEvent.FocusFolderNameInput -> Unit } } + } + + MainMediaContent( + state = uiState, + lazyListState = lazyListState, + onAction = viewModel::onAction, + ) +} + +/** + * IMPROVED MainMediaContent: + * - Reads space and project from state + * - Archive/delete handled via ViewModel methods that emit events + * - No direct Sugar ORM calls + */ +@Composable +fun MainMediaContent( + state: MainMediaState, + lazyListState: LazyListState = rememberLazyListState(), + onAction: (MainMediaAction) -> Unit, +) { + + + LaunchedEffect(state.folderBarMode) { + if (state.folderBarMode != FolderBarMode.INFO) { + onAction(MainMediaAction.ShowHideFolderOptionsPopup(false)) + } + } + - BroadcastManager.register(context, receiver) - onDispose { BroadcastManager.unregister(context, receiver) } + val folderBarState = remember( + state.folderBarMode, + state.totalMediaCount, + state.selectedMediaIds, + state.showFolderOptionsPopup, + state.currentProject, + state.currentSpace + ) { + FolderBarState( + mode = state.folderBarMode, + spaceType = state.currentSpace?.type, + projectName = state.currentProject?.description, + totalMediaCount = state.totalMediaCount, + selectedCount = state.selectedMediaIds.size, + showOptionsPopup = state.showFolderOptionsPopup, + canShowOptions = state.currentProject != null + ) } - LaunchedEffect(projectId) { - refreshSections(projectId, sections) + fun handleFolderIntent(intent: FolderBarIntent) { + when (intent) { + OptionsOpened -> onAction(MainMediaAction.ShowHideFolderOptionsPopup(true)) + OptionsDismissed -> onAction(MainMediaAction.ShowHideFolderOptionsPopup(false)) + + SelectMedia -> onAction(MainMediaAction.EnterSelectionMode) + RenameFolder -> onAction(MainMediaAction.EditFolderClicked) + ToggleArchive -> onAction(MainMediaAction.OnArchiveProject) + + RemoveFolder -> onAction(MainMediaAction.ShowRemoveProjectDialog) + + CancelSelection -> onAction(MainMediaAction.CancelSelection) + DeleteSelectedMediaRequest -> onAction(MainMediaAction.ShowDeleteSelectedMediaDialog) + + CancelEdit -> onAction(MainMediaAction.CancelEditMode) + is SaveName -> onAction(MainMediaAction.SaveFolderName(intent.name)) + } } - Box(modifier = Modifier.fillMaxSize()) { - if (sections.isEmpty()) { - WelcomeMessage() - } else { - // Use a LazyColumn to list each collection section vertically. - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(sections, key = { it.collection.id }) { section -> - CollectionSectionView( - section = section, - onMediaClick = { media -> - handleMediaClick(context, media) { errorMedia -> - errorDialogData = errorMedia - } - }, - onMediaLongPress = { media -> - // For selection (if needed) - toggleMediaSelection(media) + Column(modifier = Modifier.fillMaxSize()) { + // Folder Bar + FolderBar( + state = folderBarState, + onIntent = ::handleFolderIntent, + ) + + // Main Content + Box(modifier = Modifier.fillMaxSize()) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (state.sections.isEmpty()) { + EmptyStateView( + title = stringResource(R.string.title_welcome), + showWelcome = state.currentSpace == null, + message = when { + state.currentSpace == null -> stringResource(R.string.tap_to_add_server) + state.currentProject == null -> stringResource(R.string.tap_to_add_folder) + else -> stringResource(R.string.tap_to_add) + }, + ) + } else { + LazyColumn(state = lazyListState, modifier = Modifier.fillMaxSize()) { + state.sections.forEach { section -> + item(key = "header_${section.collection.id}") { + CollectionHeaderView(section) } - ) + + item(key = "grid_${section.collection.id}") { + CollectionSectionView( + section = section, + isInSelectionMode = state.isInSelectionMode, + selectedMediaIds = state.selectedMediaIds, + onMediaClick = { media -> + onAction(MainMediaAction.MediaClicked(media)) + }, + onMediaLongPress = { media -> + onAction(MainMediaAction.MediaLongPressed(media)) + } + ) + } + } } } } - - // Add floating action button or other UI elements if needed } } -/** Shows a header with the collection’s upload date and media count */ @Composable -fun CollectionHeaderView(section: CollectionSection) { - // For example, showing date and item count side by side: +private fun CollectionHeaderView(section: CollectionSection) { + val uploadingCount = section.media.count { it.isUploading } + val uploadedCount = section.media.count { it.status == EvidenceStatus.UPLOADED } + val totalCount = section.media.size + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween ) { - val dateText = section.collection.uploadDate?.toGMTString() ?: "Unknown Date" - Text(text = dateText, style = MaterialTheme.typography.titleMedium) + // Left: Upload date or "Uploading" Text( - text = "${section.media.size} items", - style = MaterialTheme.typography.bodyMedium, - color = Color.Gray + text = if (uploadingCount > 0) { + stringResource(R.string.uploading) + } else { + section.collection.uploadDate?.let { formatUploadDate(it) } + ?: "Ready to upload" + }, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = MontserratFontFamily) ) + + // Right: Count in pill shape + Box( + modifier = Modifier + .background(colorResource(R.color.colorPillTransparent), RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = if (uploadingCount > 0) "$uploadedCount/$totalCount" + else NumberFormat.getInstance().format(totalCount), + style = MaterialTheme.typography.bodySmall + ) + } } } -/** Renders one collection section: header and grid of media items. */ @Composable -fun CollectionSectionView( +private fun CollectionSectionView( section: CollectionSection, - onMediaClick: (Media) -> Unit, - onMediaLongPress: (Media) -> Unit + isInSelectionMode: Boolean, + selectedMediaIds: Set, + onMediaClick: (Evidence) -> Unit, + onMediaLongPress: (Evidence) -> Unit ) { Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp) + .padding(vertical = 4.dp) ) { - CollectionHeaderView(section) - // Render the media items as a grid of 4 columns. - // We use a simple approach: chunk the media list into rows of 4. - val rows = section.media.chunked(4) + // 3-column grid (not 4!) + val rows = section.media.chunked(3) rows.forEach { rowItems -> Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - rowItems.forEach { media -> - MediaItemView( - media = media, - isSelected = media.selected, - onClick = { onMediaClick(media) }, - onLongClick = { onMediaLongPress(media) }, + rowItems.forEach { evidence -> + MediaGridItem( + evidence = evidence, + isInSelectionMode = isInSelectionMode, + isSelected = selectedMediaIds.contains(evidence.id), + onClick = { onMediaClick(evidence) }, + onLongClick = { onMediaLongPress(evidence) }, modifier = Modifier .weight(1f) .aspectRatio(1f) ) } - // Fill out the remaining cells (if any) in this row - if (rowItems.size < 4) { - repeat(4 - rowItems.size) { + if (rowItems.size < 3) { + repeat(3 - rowItems.size) { Spacer(modifier = Modifier.weight(1f)) } } } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(3.dp)) } } } -/** Renders one media item as an image filling its box. */ +@OptIn(ExperimentalFoundationApi::class) @Composable -fun MediaItemView( - media: Media, +private fun MediaGridItem( + evidence: Evidence, + isInSelectionMode: Boolean, isSelected: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier ) { + val thumbnailAlpha = if (evidence.status == EvidenceStatus.UPLOADED) 1f else 0.5f + var showTitle by remember { mutableStateOf(false) } + Box( modifier = modifier - .border( - width = if (isSelected) 4.dp else 0.dp, - color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent - ) - .pointerInput(Unit) { - detectTapGestures( - onTap = { onClick() }, - onLongPress = { onLongClick() } - ) - } + .background(MaterialTheme.colorScheme.background) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) ) { - AsyncImage( - model = media.fileUri, - contentDescription = media.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + // Use shared MediaThumbnail component + MediaThumbnail( + evidence = evidence, + isSelected = isInSelectionMode && isSelected, + alpha = thumbnailAlpha, + placeholderPadding = 28.dp, + pdfMaxDimensionPx = 512, + showStatusOverlay = false, + onTitleVisibilityChanged = { showTitle = it } ) - when (media.sStatus) { - Media.Status.Uploading -> UploadProgress(media.uploadPercentage ?: 0) - Media.Status.Error -> ErrorIndicator() - else -> Unit + + if (showTitle && evidence.title.isNotBlank()) { + Box( + modifier = Modifier + .fillMaxSize() + .align(Alignment.BottomCenter), + contentAlignment = Alignment.BottomCenter + ) { + Text( + text = evidence.title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) + } } - } -} + // Selection border and background overlay (goes on top of thumbnail) + if (isInSelectionMode && isSelected) { + Box( + modifier = Modifier + .fillMaxSize() + .border( + width = 2.dp, + color = colorResource(R.color.c23_teal), + shape = RoundedCornerShape(4.dp) + ) + .background( + color = Color(0x4D00B4A6), + shape = RoundedCornerShape(4.dp) + ) + ) + } -@Composable -fun UploadProgress(progress: Int) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.6f)), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - progress = progress / 100f, - modifier = Modifier.size(48.dp), - color = Color.White - ) - Text( - text = "$progress%", - color = Color.White, - modifier = Modifier.padding(top = 56.dp) + // Use shared MediaStatusOverlay component (goes on top of everything) + MediaStatusOverlay( + evidence = evidence, + modifier = Modifier.fillMaxSize(), + showProgressText = false, + backgroundColor = colorResource(R.color.transparent_black), + progressIndicatorSize = 32, + showQueuedState = true, + showUploadingState = true ) } } @Composable -fun ErrorIndicator() { +private fun EmptyStateView( + showWelcome: Boolean, + title: String, + message: String, +) { + Box( modifier = Modifier - .fillMaxSize() - .background(Color.Red.copy(alpha = 0.6f)), + .fillMaxSize(), contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Default.Error, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(48.dp) - ) - } -} - -@Composable -fun WelcomeMessage() { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Welcome", - style = MaterialTheme.typography.displayMedium - ) - Text( - text = "Tap the button below to add media", - style = MaterialTheme.typography.titleMedium - ) - } -} - -/** Refreshes the list of collections (with nonempty media) for the given project. - * This runs on IO and updates the [sections] state on the main thread. - */ -private fun refreshSections(projectId: Long, sections: MutableList) { - kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { - val collections = Collection.getByProject(projectId) - val newSections = collections.filter { it.media.isNotEmpty() } - .map { CollectionSection(it) } - withContext(Dispatchers.Main) { - sections.clear() - sections.addAll(newSections) - } - } -} + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Welcome title (only for no space state) + if (showWelcome) { + Spacer(modifier = Modifier.height(120.dp)) + Text( + text = title, + style = MaterialTheme.typography.displayLarge.copy(fontFamily = MontserratFontFamily), + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = colorResource(R.color.colorOnSurface), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + } else { + Spacer(modifier = Modifier.height(136.dp)) + } + // Description text + Text( + text = message, + style = MaterialTheme.typography.headlineSmall.copy(fontFamily = MontserratFontFamily), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 50.dp) + ) -/** Updates one media item in one section (called when a broadcast “change” is received). */ -private fun updateMediaItem( - sections: List, - collectionId: Long, - mediaId: Long, - progress: Int, - isUploaded: Boolean -) { - sections.find { it.collection.id == collectionId }?.let { section -> - val idx = section.media.indexOfFirst { it.id == mediaId } - if (idx != -1) { - val media = section.media[idx] - if (isUploaded) { - media.status = Media.Status.Uploaded.id - } else { - media.uploadPercentage = progress - media.status = Media.Status.Uploading.id + // Arrow pointing to add button + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(R.drawable.welcome_arrow), + contentDescription = stringResource(R.string.title_welcome), + modifier = Modifier + .fillMaxSize() + .weight(2f) + .padding(horizontal = 48.dp, vertical = 8.dp), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant) + ) } - // Replace to trigger recomposition - section.media[idx] = media } } } -/** Toggles the selected state of the media item and saves it. */ -private fun toggleMediaSelection(media: Media) { - media.selected = !media.selected - media.save() +private fun formatUploadDate(dateTime: LocalDateTime): String { + val formatted = dateTime.format("MMM dd, yyyy | h:mma") + return formatted.replace("AM", "am").replace("PM", "pm") } -/** Deletes any media items that are selected from all sections. - * Also deletes the media from the database and posts a delete broadcast. - */ -private fun deleteSelected(sections: MutableList, context: Context) { - sections.forEach { section -> - // Work on a copy so we can remove items safely - section.media.filter { it.selected }.toList().forEach { media -> - section.media.remove(media) - media.delete() // delete from database - BroadcastManager.postDelete(context, media.id) - } +@PreviewLight +@Composable +private fun MainMediaScreenPreview() { + DefaultScaffoldPreview { + MainMediaContent( + state = MainMediaState( + currentSpace = Vault(id = 1, name = "My Vault", type = VaultType.PRIVATE_SERVER), + currentProject = Archive(id = 1, description = "My Project", vaultId = 1), + folderBarMode = FolderBarMode.INFO, + totalMediaCount = 24 + ), + onAction = {}, + ) } - // Remove sections that are now empty (do not delete the collection from DB here) - sections.removeAll { it.media.isEmpty() } } -/** Deletes a single media item (used when “remove” is chosen from the error dialog). */ -private fun deleteMediaItem(sections: MutableList, media: Media) { - sections.find { it.collection.id == media.collectionId }?.let { section -> - section.media.remove(media) - media.delete() - // In a real app, you might also post a broadcast here +@PreviewLight +@Composable +private fun MainMediaScreenNoFolderPreview() { + DefaultScaffoldPreview { + MainMediaContent( + state = MainMediaState( + currentSpace = Vault(id = 1, name = "My Vault", type = VaultType.PRIVATE_SERVER), + folderBarMode = FolderBarMode.INFO, + totalMediaCount = 24 + ), + onAction = {}, + ) } } -/** Handles what happens when a media item is clicked (when not in selection mode). - * Depending on its status and mime type, this either launches a preview, an upload manager, - * or shows an error dialog. - * - * The onError lambda is called if the media is in an error state. - */ -private fun handleMediaClick(context: Context, media: Media, onError: (Media) -> Unit) { - when (media.sStatus) { - Media.Status.Local -> { - // For images, start a preview - if (media.mimeType.startsWith("image")) { - //PreviewActivity.start(context, media.projectId) - } - } - - Media.Status.Queued, Media.Status.Uploading -> { - // Start the upload manager activity - //context.startActivity(Intent(context, UploadManagerActivity::class.java)) - TODO("Integrate the UploadFragment BottomSheet here using compose") - } - - Media.Status.Error -> { - // Show error dialog (retry/remove) - onError(media) - } - - else -> { /* no op */ - } +@PreviewLight +@Composable +private fun MainMediaScreenNoServerPreview() { + DefaultScaffoldPreview { + MainMediaContent( + state = MainMediaState(), + onAction = {}, + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaViewModel.kt index 30e0cb08e..6731aa5e9 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaViewModel.kt @@ -1,8 +1,613 @@ package net.opendasharchive.openarchive.features.main.ui import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.domain.Submission +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.repositories.CollectionRepository +import net.opendasharchive.openarchive.core.repositories.InvalidationBus +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.core.UiImage +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.upload.UploadEvent +import net.opendasharchive.openarchive.upload.UploadEventBus -class MainMediaViewModel : ViewModel() { +/** + * Represents one collection section with its media items. + */ +data class CollectionSection( + val collection: Submission, + val media: List +) +/** + * Folder bar modes matching MainActivity. + */ +enum class FolderBarMode { + INFO, + SELECTION, + EDIT } +/** + * UI State for a single project's media screen. + * This ONLY contains media-specific state. + * Project info comes from HomeViewModel. + */ +data class MainMediaState( + val projectId: Long? = null, + val currentSpace: Vault? = null, + val currentProject: Archive? = null, + val sections: List = emptyList(), + val isInSelectionMode: Boolean = false, + val selectedMediaIds: Set = emptySet(), + val isLoading: Boolean = true, + + val showDeleteSelectedMediaDialog: Boolean = false, + + + // Project State - Folder Bar + val folderBarMode: FolderBarMode = FolderBarMode.INFO, + val totalMediaCount: Int = 0, + val showFolderOptionsPopup: Boolean = false, + val showRemoveProjectDialog: Boolean = false, + val transientProgress: Map = emptyMap(), +) + +/** + * User actions scoped to MainMediaScreen. + */ +sealed class MainMediaAction { + data class LoadProject( + val projectId: Long, + val project: Archive? = null, + val space: Vault? = null + ) : + MainMediaAction() + + data class Refresh(val projectId: Long) : MainMediaAction() + data class MediaClicked(val media: Evidence) : MainMediaAction() + data class MediaLongPressed(val media: Evidence) : MainMediaAction() + data class UpdateMediaItem( + val collectionId: Long, + val mediaId: Long, + val progress: Int, + val isUploaded: Boolean + ) : MainMediaAction() + + data object ToggleSelectAll : MainMediaAction() + data object DeleteSelected : MainMediaAction() + data object CancelSelection : MainMediaAction() + data object EditFolderClicked : MainMediaAction() + data object CancelEditMode : MainMediaAction() + data class SaveFolderName(val newName: String) : MainMediaAction() + data object EnterSelectionMode : MainMediaAction() + + + data object ShowRemoveProjectDialog : MainMediaAction() + data object ShowDeleteSelectedMediaDialog : MainMediaAction() + + data class ShowHideFolderOptionsPopup(val showPopup: Boolean) : MainMediaAction() + data object OnArchiveProject : MainMediaAction() + data class ShowErrorRecovery(val media: Evidence) : MainMediaAction() +} + +/** + * Events emitted from ViewModel to UI. + * IMPROVED: Project-level mutations are emitted as events, + * not handled directly. HomeViewModel will handle them. + */ +sealed class MainMediaEvent { + data class NavigateToPreview(val projectId: Long) : MainMediaEvent() + data object ShowUploadManager : MainMediaEvent() + data class SelectionModeChanged(val isSelecting: Boolean, val count: Int) : MainMediaEvent() + data object FocusFolderNameInput : MainMediaEvent() +} + +// NEW: Project-level mutation requests (handled by HomeViewModel) +sealed class MainMediaProjectEvent { + data class RequestProjectRename(val projectId: Long, val newName: String) : + MainMediaProjectEvent() + + data class RequestProjectArchive(val projectId: Long) : MainMediaProjectEvent() + data class RequestProjectDelete(val projectId: Long) : MainMediaProjectEvent() +} + +/** + * IMPROVED MainMediaViewModel: + * - Only manages media/collection state for its project + * - Does NOT load project info (gets it from HomeViewModel) + * - Emits events for project-level mutations instead of handling them directly + * - Smaller, more focused responsibility + */ +class MainMediaViewModel( + private val projectId: Long, + private val dialogManager: DialogStateManager, + private val spaceRepository: SpaceRepository, + private val projectRepository: ProjectRepository, + private val collectionRepository: CollectionRepository, + private val mediaRepository: MediaRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(MainMediaState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEvent = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() + + private val _projectEvent = MutableSharedFlow() + val projectEvent = _projectEvent.asSharedFlow() + + init { + AppLogger.i("MainMediaViewModel initialized for project $projectId") + if (projectId >= 0) { + _uiState.update { it.copy(projectId = projectId) } + } + observeData() + observeUploadEvents() + } + + private fun observeData() { + val pid = projectId + if (pid < 0) { + _uiState.update { it.copy(isLoading = false) } + return + } + + val projectAndSpace = combine( + projectRepository.observeProject(pid), + spaceRepository.observeCurrentSpace() + ) { project, space -> project to space } + + combine( + projectAndSpace, + collectionRepository.observeCollections(pid), + mediaRepository.observeMediaForProject(pid), + uiState.map { it.selectedMediaIds }.distinctUntilChanged(), + uiState.map { it.transientProgress }.distinctUntilChanged() + ) { (project, space), collections, media, selectedIds, progressMap -> + val sections = collections.map { collection -> + CollectionSection( + collection = collection, + media = media.filter { it.submissionId == collection.id } + .map { item -> + val progress = progressMap[item.id] + val effectiveStatus = when { + item.status == EvidenceStatus.UPLOADED -> EvidenceStatus.UPLOADED + progress != null -> EvidenceStatus.UPLOADING + else -> item.status + } + item.copy( + isSelected = selectedIds.contains(item.id), + uploadPercentage = progress ?: item.uploadPercentage, + status = effectiveStatus + ) + } + ) + }.filter { it.media.isNotEmpty() } + + MainDataUpdate(project, space, sections) + } + .onEach { update -> + val totalCount = update.sections.sumOf { it.media.size } + _uiState.update { + it.copy( + currentProject = update.project, + currentSpace = update.space, + sections = update.sections, + totalMediaCount = totalCount, + isLoading = false + ) + } + } + .launchIn(viewModelScope) + } + + private data class MainDataUpdate( + val project: Archive?, + val space: Vault?, + val sections: List + ) + + fun onAction(action: MainMediaAction) { + when (action) { + is LoadProject -> setProject(action.projectId) + is Refresh -> refreshSections() + is MediaClicked -> handleMediaClick(action.media) + is MediaLongPressed -> handleMediaLongPress(action.media) + is UpdateMediaItem -> updateMediaItem( + action.collectionId, + action.mediaId, + action.progress, + action.isUploaded + ) + + ToggleSelectAll -> toggleSelectAll() + DeleteSelected -> deleteSelectedMedia() + CancelSelection -> cancelSelection() + EnterSelectionMode -> enableSelectionMode() + EditFolderClicked -> enterEditMode() + CancelEditMode -> exitEditMode() + is SaveFolderName -> requestSaveFolderName(action.newName) + ShowDeleteSelectedMediaDialog -> showConfirmDeleteSelectedDialog() + ShowRemoveProjectDialog -> showConfirmRemoveProjectDialog() + OnArchiveProject -> requestArchiveProject() + is ShowHideFolderOptionsPopup -> _uiState.update { it.copy(showFolderOptionsPopup = action.showPopup) } + is MainMediaAction.ShowErrorRecovery -> showErrorRecoveryDialog(action.media) + } + } + + private fun setProject(projectId: Long) { + _uiState.update { + it.copy( + projectId = projectId, + isInSelectionMode = false, + selectedMediaIds = emptySet() + ) + } + } + + private fun observeUploadEvents() { + viewModelScope.launch { + UploadEventBus.events.collect { event -> + when (event) { + is UploadEvent.Changed -> { + val currentProjectId = _uiState.value.projectId ?: return@collect + if (event.projectId == currentProjectId) { + updateMediaItem( + collectionId = event.collectionId, + mediaId = event.mediaId, + progress = event.progress, + isUploaded = event.isUploaded + ) + if (event.progress < 0 || event.isUploaded) { + // refreshSections() is no longer needed as observeData handles it + } + } + } + + is UploadEvent.Deleted -> { + val currentProjectId = _uiState.value.projectId ?: return@collect + if (event.projectId == currentProjectId) { + // refreshSections() is no longer needed + } + } + } + } + } + } + + fun refreshSections() { + // Ping the invalidation bus to wake up the reactive observeData() flow. + // This is safe to call even if the flow is already running as it's distinctUntilChanged. + InvalidationBus.invalidateAll() + } + + private fun handleMediaClick(media: Evidence) { + viewModelScope.launch { + if (_uiState.value.isInSelectionMode) { + toggleMediaSelection(media) + } else { + when (media.status) { + EvidenceStatus.NEW, + EvidenceStatus.LOCAL -> { + _uiEvent.emit(MainMediaEvent.NavigateToPreview(media.archiveId)) + } + + EvidenceStatus.QUEUED, + EvidenceStatus.UPLOADING -> _uiEvent.emit(MainMediaEvent.ShowUploadManager) + + EvidenceStatus.ERROR -> { + showErrorRecoveryDialog(media) + } + + else -> Unit + } + } + } + } + + private fun handleMediaLongPress(media: Evidence) { + viewModelScope.launch { + if (!_uiState.value.isInSelectionMode) { + _uiState.update { + it.copy( + isInSelectionMode = true, + folderBarMode = FolderBarMode.SELECTION + ) + } + } + toggleMediaSelection(media) + } + } + + private fun toggleMediaSelection(media: Evidence) { + val currentSelected = _uiState.value.selectedMediaIds + val newSelected = if (currentSelected.contains(media.id)) { + currentSelected - media.id + } else { + currentSelected + media.id + } + + viewModelScope.launch(Dispatchers.IO) { + // mediaRepository.setSelected(media.id, newSelected.contains(media.id)) + + withContext(Dispatchers.Main) { + _uiState.update { it.copy(selectedMediaIds = newSelected) } + + if (newSelected.isEmpty() && _uiState.value.isInSelectionMode) { + _uiState.update { + it.copy( + isInSelectionMode = false, + folderBarMode = FolderBarMode.INFO + ) + } + } + + _uiEvent.emit( + MainMediaEvent.SelectionModeChanged( + _uiState.value.isInSelectionMode, + newSelected.size + ) + ) + } + } + } + + private fun toggleSelectAll() { + viewModelScope.launch(Dispatchers.IO) { + val allMediaIds = _uiState.value.sections.flatMap { it.media }.map { it.id }.toSet() + val currentSelected = _uiState.value.selectedMediaIds + + val newSelected = + if (currentSelected.size == allMediaIds.size) emptySet() else allMediaIds + + /* + _uiState.value.sections.flatMap { it.media }.forEach { media -> + mediaRepository.setSelected(media.id, newSelected.contains(media.id)) + } + */ + + withContext(Dispatchers.Main) { + _uiState.update { it.copy(selectedMediaIds = newSelected) } + _uiEvent.emit( + MainMediaEvent.SelectionModeChanged( + _uiState.value.isInSelectionMode, + newSelected.size + ) + ) + } + } + } + + private fun deleteSelectedMedia() { + viewModelScope.launch(Dispatchers.IO) { + val selectedIds = _uiState.value.selectedMediaIds + selectedIds.forEach { mediaId -> mediaRepository.deleteMedia(mediaId) } + withContext(Dispatchers.Main) { + _uiState.update { + it.copy( + selectedMediaIds = emptySet(), + isInSelectionMode = false, + folderBarMode = FolderBarMode.INFO + ) + } + } + _uiEvent.emit(MainMediaEvent.SelectionModeChanged(false, 0)) + } + } + + fun cancelSelection() { + viewModelScope.launch(Dispatchers.IO) { + /* + _uiState.value.sections.flatMap { it.media }.forEach { media -> + if (media.isSelected) { + mediaRepository.setSelected(media.id, false) + } + } + */ + + withContext(Dispatchers.Main) { + _uiState.update { + it.copy( + selectedMediaIds = emptySet(), + isInSelectionMode = false, + folderBarMode = FolderBarMode.INFO + ) + } + _uiEvent.emit(MainMediaEvent.SelectionModeChanged(false, 0)) + } + } + } + + private fun updateMediaItem( + collectionId: Long, + mediaId: Long, + progress: Int, + isUploaded: Boolean + ) { + viewModelScope.launch { + _uiState.update { state -> + val newProgress = if (isUploaded) 100 else progress + val updatedTransient = if (isUploaded || newProgress < 0) { + state.transientProgress - mediaId + } else { + state.transientProgress + (mediaId to newProgress) + } + state.copy(transientProgress = updatedTransient) + } + } + } + + private fun findMediaPosition(media: Evidence): Int { + var position = 0 + for (section in _uiState.value.sections) { + val index = section.media.indexOfFirst { it.id == media.id } + if (index != -1) { + return position + index + } + position += section.media.size + } + return -1 + } + + fun enableSelectionMode() { + viewModelScope.launch { + _uiState.update { + it.copy( + isInSelectionMode = true, + folderBarMode = FolderBarMode.SELECTION + ) + } + _uiEvent.emit( + MainMediaEvent.SelectionModeChanged( + true, + _uiState.value.selectedMediaIds.size + ) + ) + } + } + + fun getSelectedCount(): Int = _uiState.value.selectedMediaIds.size + + private fun enterEditMode() { + viewModelScope.launch { + _uiState.update { it.copy(folderBarMode = FolderBarMode.EDIT) } + _uiEvent.emit(MainMediaEvent.FocusFolderNameInput) + } + } + + private fun exitEditMode() { + viewModelScope.launch { + _uiState.update { it.copy(folderBarMode = FolderBarMode.INFO) } + } + } + + /** + * IMPROVED: Instead of calling repository directly, + * emit an event for HomeViewModel to handle. + */ + private fun requestSaveFolderName(newName: String) { + viewModelScope.launch { + val projectId = _uiState.value.projectId ?: return@launch + + // Exit edit mode immediately for responsive UI + _uiState.update { it.copy(folderBarMode = FolderBarMode.INFO) } + + // Emit event to HomeViewModel to handle the actual rename + _projectEvent.emit(MainMediaProjectEvent.RequestProjectRename(projectId, newName)) + } + } + + /** + * Request project archive. + * Called from MainMediaScreen when user selects "Archive" from menu. + */ + private fun requestArchiveProject() { + _uiState.update { it.copy(showRemoveProjectDialog = false) } + viewModelScope.launch { + val projectId = _uiState.value.projectId ?: return@launch + _projectEvent.emit(MainMediaProjectEvent.RequestProjectArchive(projectId)) + } + } + + /** + * Request project deletion. + * Called from MainMediaScreen when user confirms "Remove" from menu. + */ + fun requestDeleteProject() { + viewModelScope.launch { + val projectId = _uiState.value.projectId ?: return@launch + _projectEvent.emit(MainMediaProjectEvent.RequestProjectDelete(projectId)) + } + } + + + private fun showConfirmRemoveProjectDialog() { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Error + icon = UiImage.DrawableResource(R.drawable.ic_trash) + title = UiText.Resource(R.string.remove_from_app) + message = UiText.Resource(R.string.action_remove_project) + destructiveButton { + text = UiText.Resource(R.string.lbl_remove) + action = { requestDeleteProject() } + } + neutralButton { + text = UiText.Resource(R.string.lbl_Cancel) + action = { dialogManager.dismissDialog() } + } + } + } + + private fun showConfirmDeleteSelectedDialog() { + _uiState.update { it.copy(showDeleteSelectedMediaDialog = false) } + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Error + icon = UiImage.DrawableResource(R.drawable.ic_trash) + title = UiText.Resource(R.string.menu_delete) + message = UiText.Resource(R.string.menu_delete_desc) + destructiveButton { + text = UiText.Resource(R.string.btn_lbl_remove_media) + action = { deleteSelectedMedia() } + } + neutralButton { + text = UiText.Resource(R.string.lbl_Cancel) + action = { dialogManager.dismissDialog() } + } + } + } + + private fun showErrorRecoveryDialog(media: Evidence) { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Error + title = UiText.Resource(R.string.upload_unsuccessful) + message = UiText.Resource(R.string.upload_unsuccessful_description) + icon = UiImage.DrawableResource(R.drawable.ic_error) + positiveButton { + text = UiText.Resource(R.string.lbl_retry) + action = { + viewModelScope.launch { + mediaRepository.retryMedia(media.id) + // Note: observeData will pick up the status change automatically + } + } + } + + destructiveButton { + text = UiText.Resource(R.string.btn_lbl_remove_media) + action = { + viewModelScope.launch { + mediaRepository.deleteMedia(media.id) + } + } + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MediaCacheScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MediaCacheScreen.kt index b36287c19..22b866b2e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MediaCacheScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MediaCacheScreen.kt @@ -1,27 +1,22 @@ package net.opendasharchive.openarchive.features.main.ui -import android.content.Context -import android.os.Bundle -import android.provider.MediaStore -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -30,7 +25,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -39,6 +33,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import coil3.size.Scale +import net.opendasharchive.openarchive.R import java.io.File // MediaFile Data Class @@ -56,8 +51,10 @@ enum class FileType { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MediaCacheScreen(context: Context, onNavigateBack: () -> Unit) { - val cacheDir = context.cacheDir +fun MediaCacheScreen(onNavigateBack: () -> Unit) { + + val context = LocalContext.current + val cacheDir = File(context.filesDir, "media_temp") val files = remember { cacheDir.listFiles()?.map { it.toMediaFile() } ?: emptyList() } Scaffold( @@ -66,7 +63,7 @@ topBar ={ title = { Text("Media Cache") }, navigationIcon = { IconButton(onClick = onNavigateBack) { - Icon(Icons.Default.ArrowBack, contentDescription = null) + Icon(painter = painterResource(R.drawable.ic_arrow_back_ios), contentDescription = null) } } ) @@ -107,7 +104,7 @@ fun CacheFileItem(file: MediaFile) { when { file.isDirectory -> { Icon( - imageVector = Icons.Default.Folder, + painter = painterResource(R.drawable.ic_folder), contentDescription = file.name, modifier = Modifier.size(48.dp) ) @@ -141,7 +138,7 @@ fun CacheFileItem(file: MediaFile) { file.type == FileType.PDF -> { Icon( - imageVector = Icons.Default.Description, + painter = painterResource(R.drawable.ic_pdf), contentDescription = file.name, modifier = Modifier.size(48.dp) ) @@ -149,7 +146,7 @@ fun CacheFileItem(file: MediaFile) { else -> { Icon( - imageVector = Icons.Default.QuestionMark, + painter = painterResource(R.drawable.ic_folder), contentDescription = file.name, modifier = Modifier.size(48.dp) ) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/Navigator.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/Navigator.kt new file mode 100644 index 000000000..d4eb7b90b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/Navigator.kt @@ -0,0 +1,84 @@ +package net.opendasharchive.openarchive.features.main.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import net.opendasharchive.openarchive.util.Prefs + +class Navigator( + private val startDestination: AppRoute = defaultStartDestination(), + initialBackstack: List = listOf(startDestination) +) { + val backstack: SnapshotStateList = + initialBackstack.ifEmpty { listOf(startDestination) }.toMutableStateList() + + fun navigateTo(route: AppRoute) { + backstack.add(route) + } + + fun navigateBack() { + if (backstack.size > 1) { + backstack.removeLastOrNull() + } + } + + fun popBackTo(route: AppRoute, inclusive: Boolean = false) { + val index = backstack.indexOfLast { it == route } + if (index != -1) { + val targetIndex = if (inclusive) index else index + 1 + if (targetIndex < backstack.size) { + backstack.subList(targetIndex, backstack.size).clear() + } + if (backstack.isEmpty()) { + backstack.add(startDestination) + } + } + } + + fun navigateAndClear(route: AppRoute) { + backstack.clear() + backstack.add(route) + } + + fun currentRoute(): AppRoute? { + return backstack.lastOrNull() + } + + companion object { + private val json = Json { encodeDefaults = true } + + fun saver(): Saver = Saver( + save = { navigator -> + json.encodeToString( + ListSerializer(AppRoute.serializer()), + navigator.backstack.toList() + ) + }, + restore = { saved -> + val routes = json.decodeFromString( + ListSerializer(AppRoute.serializer()), + saved + ) + + Navigator( + initialBackstack = routes + ) + } + ) + + private fun defaultStartDestination(): AppRoute { + return if (Prefs.didCompleteOnboarding) AppRoute.HomeRoute else AppRoute.WelcomeRoute + } + } +} + +@Composable +fun rememberNavigator(): Navigator { + return rememberSaveable(saver = Navigator.saver()) { + Navigator() + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/SaveNavGraph.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/SaveNavGraph.kt new file mode 100644 index 000000000..18e00787d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/SaveNavGraph.kt @@ -0,0 +1,645 @@ +package net.opendasharchive.openarchive.features.main.ui + +import android.app.Activity +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.session.SessionTracker +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.navigation.LocalResultEventBus +import net.opendasharchive.openarchive.core.navigation.NavigationResultKeys +import net.opendasharchive.openarchive.core.navigation.ResultEventBus +import net.opendasharchive.openarchive.core.navigation.rememberResultStore +import net.opendasharchive.openarchive.core.presentation.components.TextActionButton +import net.opendasharchive.openarchive.db.sugar.Space +import net.opendasharchive.openarchive.features.core.dialog.DialogHost +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.folders.AddFolderScreen +import net.opendasharchive.openarchive.features.folders.BrowseFolderScreen +import net.opendasharchive.openarchive.features.folders.BrowseFoldersAction +import net.opendasharchive.openarchive.features.folders.BrowseFoldersViewModel +import net.opendasharchive.openarchive.features.folders.CreateNewFolderScreen +import net.opendasharchive.openarchive.features.folders.CreateNewFolderViewModel +import net.opendasharchive.openarchive.features.media.PreviewMediaAction +import net.opendasharchive.openarchive.features.media.PreviewMediaScreen +import net.opendasharchive.openarchive.features.media.PreviewMediaViewModel +import net.opendasharchive.openarchive.features.media.ReviewMediaScreen +import net.opendasharchive.openarchive.features.media.ReviewMediaViewModel +import net.opendasharchive.openarchive.features.media.camera.CameraScreenWrapper +import net.opendasharchive.openarchive.features.onboarding.OnboardingInstructionsScreen +import net.opendasharchive.openarchive.features.onboarding.OnboardingWelcomeScreen +import net.opendasharchive.openarchive.features.settings.C2paScreen +import net.opendasharchive.openarchive.features.settings.FolderDetailScreen +import net.opendasharchive.openarchive.features.settings.FolderDetailViewModel +import net.opendasharchive.openarchive.features.settings.FoldersScreen +import net.opendasharchive.openarchive.features.settings.FoldersViewModel +import net.opendasharchive.openarchive.features.settings.SpaceSetupSuccessScreen +import net.opendasharchive.openarchive.features.settings.SpaceSetupSuccessViewModel +import net.opendasharchive.openarchive.features.settings.license.SetupLicenseScreen +import net.opendasharchive.openarchive.features.settings.license.SetupLicenseViewModel +import net.opendasharchive.openarchive.features.settings.passcode.PasscodeFlowState +import net.opendasharchive.openarchive.features.settings.passcode.PasscodeGate +import net.opendasharchive.openarchive.features.settings.passcode.components.DefaultScaffold +import net.opendasharchive.openarchive.features.settings.passcode.passcode_entry.PasscodeEntryScreen +import net.opendasharchive.openarchive.features.settings.passcode.passcode_entry.PasscodeEntryViewModel +import net.opendasharchive.openarchive.features.spaces.SpaceListScreen +import net.opendasharchive.openarchive.features.spaces.SpaceListViewModel +import net.opendasharchive.openarchive.features.spaces.SpaceSetupAction +import net.opendasharchive.openarchive.features.spaces.SpaceSetupScreen +import net.opendasharchive.openarchive.features.spaces.SpaceSetupViewModel +import net.opendasharchive.openarchive.services.internetarchive.presentation.details.InternetArchiveDetailsScreen +import net.opendasharchive.openarchive.services.internetarchive.presentation.details.InternetArchiveDetailsViewModel +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.InternetArchiveLoginScreen +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.InternetArchiveLoginViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.snowbirdEntries +import net.opendasharchive.openarchive.services.webdav.presentation.detail.WebDavDetailScreen +import net.opendasharchive.openarchive.services.webdav.presentation.detail.WebDavDetailViewModel +import net.opendasharchive.openarchive.services.webdav.presentation.login.WebDavLoginScreen +import net.opendasharchive.openarchive.services.webdav.presentation.login.WebDavLoginViewModel +import net.opendasharchive.openarchive.util.Prefs +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun SaveNavGraph( + dialogManager: DialogStateManager, + navigator: Navigator +) { + val analyticsManager: AnalyticsManager = koinInject() + val sessionTracker: SessionTracker = koinInject() + val resultBus = ResultEventBus + val resultStore = rememberResultStore() + val passcodeFlowState: PasscodeFlowState = koinInject() + val passcodeGate: PasscodeGate = koinInject() + val isLocked by passcodeGate.locked.collectAsStateWithLifecycle() + + val currentRoute = navigator.backstack.lastOrNull() + AppLogger.d("Navigation", "Current route: $currentRoute") + // LaunchedEffect restarts whenever currentRoute changes + LaunchedEffect(currentRoute) { + val isPasscodeFlow = currentRoute is AppRoute.PasscodeEntryRoute || + currentRoute is AppRoute.PasscodeSetupRoute + passcodeFlowState.setActive(isPasscodeFlow) + + when (currentRoute) { + is AppRoute.CameraRoute -> { + // We are at camera route + AppLogger.d("Navigation", "At camera route") + } + + else -> Unit + } + } + + // Navigate to lock screen whenever the gate locks — catches ADB launches and share intents + LaunchedEffect(isLocked) { + if (isLocked && navigator.backstack.lastOrNull() !is AppRoute.PasscodeEntryRoute) { + navigator.navigateTo(AppRoute.PasscodeEntryRoute) + } + } + + + DialogHost(dialogManager) + + CompositionLocalProvider(LocalResultEventBus provides resultBus) { + + NavDisplay( + modifier = Modifier.fillMaxSize(), + backStack = navigator.backstack, + entryDecorators = listOf( + // Add the default decorators for managing scenes and saving state + rememberSaveableStateHolderNavEntryDecorator(), + // Then add the view model store decorator + rememberViewModelStoreNavEntryDecorator(), + rememberAnalyticsNavEntryDecorator(analyticsManager, sessionTracker) + ), + transitionSpec = { + // Slide in from right when navigating forward + slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + }, + popTransitionSpec = { + // Slide in from left when navigating back + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + predictivePopTransitionSpec = { + // Slide in from left when navigating back + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + entryProvider = entryProvider { + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + HomeScreen(viewModel) + } + + entry { route -> + + OnboardingWelcomeScreen( + onGetStartedClick = { + navigator.navigateTo(AppRoute.InstructionsRoute) + } + ) + } + + entry { route -> + + OnboardingInstructionsScreen( + onDone = { + Prefs.didCompleteOnboarding = true + navigator.navigateAndClear(AppRoute.HomeRoute) + }, + ) + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + val state by viewModel.uiState.collectAsStateWithLifecycle() + + DefaultScaffold( + title = stringResource(id = R.string.space_setup_title), + onNavigateBack = { navigator.navigateBack() } + ) { + SpaceSetupScreen( + onWebDavClick = { viewModel.onAction(SpaceSetupAction.WebDavClicked) }, + isInternetArchiveAllowed = state.isInternetArchiveAllowed, + onInternetArchiveClick = { viewModel.onAction(SpaceSetupAction.InternetArchiveClicked) }, + isDwebEnabled = state.isDwebEnabled, + onDwebClicked = { viewModel.onAction(SpaceSetupAction.DwebClicked) }, + ) + } + } + + entry { route -> + + val viewModel: SpaceListViewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.pref_title_media_servers), + onNavigateBack = { navigator.navigateBack() } + ) { + + SpaceListScreen( + viewModel = viewModel, + ) + } + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.private_server), + onNavigateBack = { navigator.navigateBack() } + ) { + + WebDavLoginScreen( + viewModel = viewModel + ) + } + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + val existingName = remember(route.spaceId) { + Space.get(route.spaceId)?.let { space -> + when { + space.name.isNotBlank() -> space.name + space.friendlyName.isNotBlank() -> space.friendlyName + else -> null + } + } + } + + DefaultScaffold( + title = existingName ?: stringResource(id = R.string.private_server), + onNavigateBack = { navigator.navigateBack() } + ) { + WebDavDetailScreen( + viewModel = viewModel, + ) + } + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.internet_archive), + onNavigateBack = { navigator.navigateBack() } + ) { + InternetArchiveLoginScreen( + viewModel = viewModel, + ) + } + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + val titleRes = when (route.spaceType) { + VaultType.INTERNET_ARCHIVE -> R.string.internet_archive + VaultType.PRIVATE_SERVER -> R.string.private_server + VaultType.DWEB_STORAGE -> R.string.dweb_title + } + + DefaultScaffold( + title = stringResource(id = titleRes), + onNavigateBack = { navigator.navigateBack() }, + showNavigationIcon = false + ) { + SetupLicenseScreen( + viewModel = viewModel, + ) + } + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.space_setup_success_title), + onNavigateBack = { navigator.navigateBack() }, + showNavigationIcon = false + ) { + SpaceSetupSuccessScreen( + viewModel = viewModel, + onNavigateBack = { + resultBus.sendResult( + resultKey = NavigationResultKeys.REFRESH_SPACES, + result = true + ) + } + ) + } + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.internet_archive), + onNavigateBack = { navigator.navigateBack() } + ) { + InternetArchiveDetailsScreen( + viewModel = viewModel, + dialogManager = dialogManager, + ) + } + } + + entry { + AddFolderScreen( + onCreateFolder = { + navigator.navigateTo(AppRoute.CreateNewFolderRoute) + }, + onBrowseFolders = { + navigator.navigateTo(AppRoute.BrowseExistingFoldersRoute) + }, + onNavigateBack = { navigator.navigateBack() } + ) + } + + entry { route -> + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + val state by viewModel.uiState.collectAsStateWithLifecycle() + + DefaultScaffold( + title = stringResource(R.string.browse_existing), + onNavigateBack = { viewModel.onBack() }, + actions = { + if (state.selectedFolder != null) { + TextActionButton( + label = R.string.add, + onClick = { + viewModel.onAction(BrowseFoldersAction.AddFolder) + + } + ) + } + } + ) { + BrowseFolderScreen( + state = state, + viewModel = viewModel + ) + } + } + + entry { route -> + val viewModel = koinViewModel() { + parametersOf(navigator, route) + } + DefaultScaffold( + title = stringResource(id = R.string.create_a_new_folder), + onNavigateBack = { navigator.navigateBack() } + ) { + CreateNewFolderScreen( + viewModel = viewModel + ) + } + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource( + id = if (route.showArchived) R.string.archived_folders else R.string.folders + ), + onNavigateBack = { navigator.navigateBack() } + ) { + FoldersScreen( + viewModel = viewModel, + onNavigateToFolderDetail = { projectId -> + navigator.navigateTo(AppRoute.FolderDetailRoute(projectId)) + }, + onNavigateToArchivedFolders = { spaceId -> + navigator.navigateTo( + AppRoute.FolderListRoute( + showArchived = true, + spaceId = spaceId, + ) + ) + } + ) + } + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + DefaultScaffold( + title = uiState.folderName, + onNavigateBack = { navigator.navigateBack() } + ) { + FolderDetailScreen( + viewModel = viewModel, + ) + } + } + + entry { + C2paScreen( + onNavigateBack = { navigator.navigateBack() } + ) + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.preview_media), + onNavigateBack = { navigator.navigateBack() }, + actions = { + TextActionButton( + label = R.string.action_upload, + onClick = { + viewModel.onAction(PreviewMediaAction.UploadAll) + + } + ) + } + ) { + PreviewMediaScreen( + viewModel = viewModel, + ) + } + } + + entry { route -> + + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource( + id = if (route.batchMode) { + R.string.bulk_edit_media_info + } else { + R.string.edit_media_info + } + ), + onNavigateBack = { navigator.navigateBack() }, + actions = { + TextActionButton( + label = R.string.done, + onClick = { viewModel.onAction(net.opendasharchive.openarchive.features.media.ReviewMediaAction.SaveAndFinish) } + ) + } + ) { + ReviewMediaScreen( + viewModel = viewModel + ) + } + } + + entry { + MediaCacheScreen { + navigator.navigateBack() + } + } + + // ==================== Passcode Entry (Lock Screen) ==================== + + entry { + val context = LocalContext.current + val viewModel: PasscodeEntryViewModel = koinViewModel() + DefaultScaffold { + PasscodeEntryScreen( + viewModel = viewModel, + onSuccess = { + passcodeGate.unlock() + navigator.navigateBack() + }, + onLockedOut = { + (context as? Activity)?.finishAndRemoveTask() + }, + onExit = { + // Send app to background rather than letting the user dismiss + (context as? Activity)?.moveTaskToBack(true) + } + ) + } + } + + entry { route -> + CameraScreenWrapper( + config = route.config, + onCaptureComplete = { uris -> + // Send captured URIs via ResultEventBus + resultBus.sendResult( + resultKey = route.resultKey, + result = CameraCaptureResult( + projectId = route.projectId, + capturedUris = uris + ) + ) + navigator.navigateBack() + }, + onCancel = { + navigator.navigateBack() + } + ) + } + + // ==================== Snowbird Routes ==================== + + // Snowbird feature entries + snowbirdEntries(navigator) + } + ) + } +} + + +private fun AnalyticsNavEntryDecorator( + analyticsManager: AnalyticsManager, + sessionTracker: SessionTracker +): NavEntryDecorator { + val screenStartTimeByKey = mutableMapOf() + var previousScreen = "" + + return NavEntryDecorator( + decorate = { entry -> + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + val contentKey = entry.contentKey + val screenName = contentKey.toAnalyticsScreenName() + + DisposableEffect(lifecycleOwner, contentKey) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + screenStartTimeByKey[contentKey] = System.currentTimeMillis() + AppLogger.setCurrentScreen(screenName) + sessionTracker.setCurrentScreen(screenName) + + scope.launch { + analyticsManager.trackScreenView(screenName, null, previousScreen) + if (previousScreen.isNotEmpty() && previousScreen != screenName) { + analyticsManager.trackNavigation(previousScreen, screenName) + } + } + } + + Lifecycle.Event.ON_PAUSE -> { + val startTime = screenStartTimeByKey[contentKey] ?: 0L + if (startTime > 0L) { + val timeSpent = (System.currentTimeMillis() - startTime) / 1000 + scope.launch { + analyticsManager.trackScreenView( + screenName, + timeSpent, + previousScreen + ) + } + previousScreen = screenName + } + } + + else -> Unit + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + entry.Content() + }, + onPop = { contentKey -> + screenStartTimeByKey.remove(contentKey) + AppLogger.d("EzzioNavigation", "Popped: $contentKey") + } + ) +} + +@Composable +private fun rememberAnalyticsNavEntryDecorator( + analyticsManager: AnalyticsManager, + sessionTracker: SessionTracker +): NavEntryDecorator { + return remember(analyticsManager, sessionTracker) { + AnalyticsNavEntryDecorator(analyticsManager, sessionTracker) + } +} + +private fun Any.toAnalyticsScreenName(): String { + return when (this) { + is AppRoute -> this::class.simpleName ?: deeplink + else -> this::class.simpleName ?: toString() + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/SharedImportState.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/SharedImportState.kt new file mode 100644 index 000000000..faf158c71 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/SharedImportState.kt @@ -0,0 +1,19 @@ +package net.opendasharchive.openarchive.features.main.ui + +import android.net.Uri +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class SharedImportState { + private val _pendingUris = MutableStateFlow?>(null) + val pendingUris: StateFlow?> = _pendingUris.asStateFlow() + + fun setPendingUris(uris: List) { + _pendingUris.value = uris + } + + fun clear() { + _pendingUris.value = null + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/ExpandableSpaceList.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/ExpandableSpaceList.kt deleted file mode 100644 index 04a2cae86..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/ExpandableSpaceList.kt +++ /dev/null @@ -1,168 +0,0 @@ -package net.opendasharchive.openarchive.features.main.ui.components - -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.outlined.KeyboardArrowDown -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.presentation.components.PrimaryButton -import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.Accordion -import net.opendasharchive.openarchive.features.core.AccordionState -import net.opendasharchive.openarchive.features.core.rememberAccordionState - -@Composable -fun ExpandableSpaceList( - serverAccordionState: AccordionState, - selectedSpace: Space? = null, - spaceList: List -) { - Accordion( - state = serverAccordionState, - headerContent = { - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - - if (selectedSpace != null) { - DrawerSpaceListItem(space = selectedSpace) - } else { - Text(stringResource(R.string.servers)) - } - - IconButton( - modifier = Modifier.rotate(serverAccordionState.animationProgress * 180), - onClick = { - serverAccordionState.toggle() - } - ) { - Icon( - imageVector = Icons.Outlined.KeyboardArrowDown, - contentDescription = stringResource(R.string.expand) - ) - } - } - }, - bodyContent = { - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - spaceList.forEach { space -> - DrawerSpaceListItem(space) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - PrimaryButton( - text = stringResource(R.string.add_server), - icon = Icons.Default.Add - ) { } - } - } - - } - ) -} - -@Composable -fun DrawerSpaceListItem( - space: Space, -) { - Row( - modifier = Modifier - .wrapContentSize() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - SpaceIcon( - type = space.tType, - modifier = Modifier.size(24.dp) - ) - - Text(space.name) - } -} - -@Composable -fun SpaceIcon( - type: Space.Type, - modifier: Modifier = Modifier, - tint: Color? = null -) { - val icon = when (type) { - Space.Type.WEBDAV -> painterResource(R.drawable.ic_space_private_server) - Space.Type.INTERNET_ARCHIVE -> painterResource(R.drawable.ic_space_interent_archive) - Space.Type.RAVEN -> painterResource(R.drawable.ic_space_dweb) - } - Icon( - modifier = modifier, - painter = icon, - contentDescription = null, - tint = tint ?: MaterialTheme.colorScheme.onBackground - ) -} - -@Preview(uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun ExpandableSpaceListPreview() { - val state = rememberAccordionState( - expanded = true, - ) - - DefaultBoxPreview { - ExpandableSpaceList( - selectedSpace = dummySpaceList[1], - spaceList = dummySpaceList, - serverAccordionState = state - ) - } -} - -val dummySpaceList = listOf( - Space( - type = Space.Type.WEBDAV.id, - username = "", - password = "", - name = "Elelan Server", - ), - Space( - type = Space.Type.INTERNET_ARCHIVE.id, - username = "", - password = "", - name = "Test Server", - ), - Space( - type = Space.Type.RAVEN.id, - username = "", - password = "", - name = "DWebServer", - ), -) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/HomeAppBar.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/HomeAppBar.kt index 3d092349e..fe342bddb 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/HomeAppBar.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/HomeAppBar.kt @@ -2,11 +2,7 @@ package net.opendasharchive.openarchive.features.main.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -25,17 +21,14 @@ import net.opendasharchive.openarchive.R @Composable fun HomeAppBar( openDrawer: () -> Unit, - onExit: () -> Unit + showDrawer: Boolean = false ) { TopAppBar( title = { Image( modifier = Modifier - .size(64.dp) - .clickable { - onExit() - }, + .size(64.dp), painter = painterResource(R.drawable.savelogo), contentDescription = "Save Logo", colorFilter = ColorFilter.tint(colorResource(R.color.colorOnPrimary)) @@ -43,37 +36,27 @@ fun HomeAppBar( }, actions = { - AnimatedVisibility( - visible = false - ) { + // Only show drawer icon when not on settings page + AnimatedVisibility(showDrawer) { IconButton( - onClick = {} + colors = IconButtonDefaults.iconButtonColors( + contentColor = colorResource(R.color.colorOnPrimary) + ), + onClick = { + openDrawer() + } ) { Icon( - Icons.Outlined.Delete, - contentDescription = null + painter = painterResource(R.drawable.ic_menu), + contentDescription = null, + modifier = Modifier.size(24.dp) ) } - - } - - IconButton( - colors = IconButtonDefaults.iconButtonColors( - contentColor = colorResource(R.color.colorOnSecondary) - ), - onClick = { - openDrawer() - } - ) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = null - ) } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = colorResource(R.color.colorPrimary) + containerColor = colorResource(R.color.colorTertiary) ) ) } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainBottomBar.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainBottomBar.kt index f257f95ef..21f4a96d4 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainBottomBar.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainBottomBar.kt @@ -1,98 +1,165 @@ package net.opendasharchive.openarchive.features.main.ui.components -import androidx.compose.foundation.layout.RowScope +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.PermMedia -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.outlined.PermMedia -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.DefaultEmptyScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.features.media.AddMediaType +import net.opendasharchive.openarchive.features.media.ContentPickerSheet +enum class HomeBottomTab { + MEDIA, + SETTINGS +} + +@OptIn(ExperimentalFoundationApi::class) @Composable fun MainBottomBar( - isSettings: Boolean, - onMyMediaClick: () -> Unit, - onSettingsClick: () -> Unit, - onAddMediaClick: () -> Unit + selectedTab: HomeBottomTab, + onTabSelected: (HomeBottomTab) -> Unit, + onAddClick: () -> Unit, + onAddLongClick: () -> Unit, ) { - NavigationBar( - modifier = Modifier.fillMaxWidth(), - containerColor = MaterialTheme.colorScheme.primary + + Box( + modifier = Modifier + .fillMaxWidth() + .background(colorResource(R.color.colorTertiary)) + .windowInsetsPadding(WindowInsets.navigationBars) ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(R.dimen.bottom_nav_height)) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + // My Media section + BottomNavItem( + iconRes = if (selectedTab == HomeBottomTab.MEDIA) R.drawable.perm_media_24px else R.drawable.outline_perm_media_24, + label = stringResource(R.string.my_media), + isSelected = selectedTab == HomeBottomTab.MEDIA, + onClick = { onTabSelected(HomeBottomTab.MEDIA) }, + modifier = Modifier.weight(1f) + ) - BottomNavMenuItem( - isSelected = !isSettings, - onClick = onMyMediaClick, - selectedIcon = Icons.Default.PermMedia, - unSelectedIcon = Icons.Outlined.PermMedia, - text = stringResource(R.string.my_media) - ) + // Add button + Box( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(height = 42.dp, width = 90.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(colorResource(R.color.colorAddButton)) + .combinedClickable( + onClick = onAddClick, + onLongClick = onAddLongClick + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = "Add Media", + tint = colorResource(R.color.colorOnAddButton), + modifier = Modifier.size(28.dp) + ) + } + } - FloatingActionButton( - modifier = Modifier.size(height = 42.dp, width = 90.dp), - onClick = onAddMediaClick, - containerColor = colorResource(R.color.colorOnPrimary), - shape = RoundedCornerShape(percent = 50), - elevation = FloatingActionButtonDefaults.elevation( - defaultElevation = 6.dp, - pressedElevation = 12.dp - ) - ) { - Icon( - modifier = Modifier.size(28.dp), - imageVector = Icons.Default.Add, - contentDescription = null + // Settings section + BottomNavItem( + iconRes = if (selectedTab == HomeBottomTab.SETTINGS) R.drawable.ic_settings_filled else R.drawable.ic_settings, + label = stringResource(R.string.action_settings), + isSelected = selectedTab == HomeBottomTab.SETTINGS, + onClick = { onTabSelected(HomeBottomTab.SETTINGS) }, + modifier = Modifier.weight(1f) ) } + } +} - BottomNavMenuItem( - isSelected = isSettings, - onClick = onSettingsClick, - selectedIcon = Icons.Default.Settings, - unSelectedIcon = Icons.Outlined.Settings, - text = stringResource(R.string.action_settings) - ) +@Preview +@Composable +private fun MainBottomBarPreview() { + DefaultEmptyScaffoldPreview { + + MainBottomBar( + selectedTab = HomeBottomTab.SETTINGS, + onTabSelected = {}, + onAddClick = {}, + onAddLongClick = {} + ) } } @Composable -fun RowScope.BottomNavMenuItem( - selectedIcon: ImageVector, - unSelectedIcon: ImageVector, +private fun BottomNavItem( + @DrawableRes iconRes: Int, + label: String, isSelected: Boolean, - text: String, - onClick: () -> Unit + onClick: () -> Unit, + modifier: Modifier = Modifier ) { - val icon = if (isSelected) selectedIcon else unSelectedIcon - NavigationBarItem( - label = { - Text(text) - }, - selected = isSelected, - onClick = onClick, - icon = { - Icon( - imageVector = icon, - contentDescription = null - ) - } - ) -} \ No newline at end of file + + Column( + modifier = modifier.clickable(onClick = onClick, indication = null, interactionSource = remember { MutableInteractionSource() }), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = label, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + color = Color.White, + fontSize = 12.sp + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainDrawerContent.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainDrawerContent.kt index c96f535a9..6c8d0522d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainDrawerContent.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainDrawerContent.kt @@ -1,22 +1,22 @@ package net.opendasharchive.openarchive.features.main.ui.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.outlined.Folder import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DrawerDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -26,194 +26,426 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.db.Space +import androidx.compose.ui.unit.sp +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.domain.mappers.toDomain +import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.TitleLarge +import net.opendasharchive.openarchive.core.presentation.theme.TitleMedium +import net.opendasharchive.openarchive.db.sugar.dummyProjectList +import net.opendasharchive.openarchive.db.sugar.dummySpaceList +import net.opendasharchive.openarchive.features.core.Accordion +import net.opendasharchive.openarchive.features.core.AccordionState import net.opendasharchive.openarchive.features.core.rememberAccordionState @Composable fun MainDrawerContent( - selectedSpace: Space? = null, - spaceList: List = emptyList() + selectedSpace: Vault? = null, + spaceList: List = emptyList(), + projects: List = emptyList(), + selectedProject: Archive? = null, + onProjectSelected: (Archive) -> Unit = {}, + onAddNewFolderClicked: () -> Unit = {}, + onSpaceSelected: (Long) -> Unit, + onAddNewSpaceClicked: () -> Unit, + showDwebEntry: Boolean = false, + onDwebSelected: () -> Unit = {}, ) { - val configuration = LocalConfiguration.current - val screenWidth = configuration.screenWidthDp.dp - val serverAccordionState = rememberAccordionState() + ModalDrawerSheet( drawerShape = DrawerDefaults.shape, - modifier = Modifier.width(screenWidth * 0.65f), - drawerContainerColor = Color.White + drawerContainerColor = colorResource(R.color.colorNavigationDrawerBackground) ) { + // Main drawer content Column( - modifier = Modifier - .fillMaxHeight() - .padding(vertical = 24.dp), - verticalArrangement = Arrangement.SpaceBetween + modifier = Modifier.fillMaxHeight() ) { - - Column( - modifier = Modifier - .padding(vertical = 24.dp) - .verticalScroll(rememberScrollState()), + // AppBar height spacer + Spacer(modifier = Modifier.height(56.dp)) + + ExpandableSpaceList( + serverAccordionState = serverAccordionState, + selectedSpace = selectedSpace, + spaceList = spaceList, + showDwebEntry = showDwebEntry, + onSpaceSelected = { selectedSpace -> + serverAccordionState.collapse() + onSpaceSelected(selectedSpace.id) + }, + onDwebSelected = { + serverAccordionState.collapse() + onDwebSelected() + }, + onAddAnotherAccountClicked = onAddNewSpaceClicked + ) + + AnimatedVisibility( + visible = serverAccordionState.expanded.not() ) { - - - Spacer(Modifier.height(12.dp)) - - ExpandableSpaceList( - serverAccordionState, - selectedSpace = selectedSpace, - spaceList = spaceList - ) - - HorizontalDivider( - color = MaterialTheme.colorScheme.surfaceVariant, - thickness = 0.3.dp, - modifier = Modifier.padding(vertical = 24.dp) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize() ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Folder, - tint = MaterialTheme.colorScheme.primary, - contentDescription = null - ) - Text("Summer Vacation") + // Divider + HorizontalDivider( + color = colorResource(R.color.c23_grey), + thickness = 0.5.dp, + modifier = Modifier + .padding(horizontal = 0.dp, vertical = 10.dp) + .alpha(if (serverAccordionState.expanded) 0.3f else 0.5f) + ) + + // Current Space name and icon (always visible, dimmed when expanded) + selectedSpace?.let { space -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .alpha(if (serverAccordionState.expanded) 0.3f else 1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SpaceIcon( + type = space.type, + modifier = Modifier.size(24.dp) + ) + + TitleMedium(space.friendlyName) + } } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Folder, - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = null - ) - Text("Prague") + // Folder list (dimmed when space list is expanded) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(top = 8.dp) + .alpha(if (serverAccordionState.expanded) 0.3f else 1f), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + projects.forEach { project -> + FolderItem( + project = project, + isSelected = project.id == selectedProject?.id, + onProjectSelected = onProjectSelected + ) + } } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically + // Add Folder button at bottom (dimmed when space list is expanded) + Button( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 24.dp) + .align(Alignment.CenterHorizontally) + .alpha(if (serverAccordionState.expanded) 0.3f else 1f), + shape = RoundedCornerShape(8.dp), + onClick = onAddNewFolderClicked, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = colorResource(R.color.black) + ) ) { - - Icon( - imageVector = Icons.Outlined.Folder, - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = null + Text( + text = "+ ${stringResource(R.string.new_folder)}", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ) ) - Text("Misc") } + } + } + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { + } +} - Icon( - imageVector = Icons.Outlined.Folder, - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = null - ) - Text("Folder") - } +@Composable +private fun SpaceListItem( + space: Vault, + isSelected: Boolean, + onSpaceSelected: (Vault) -> Unit +) { + val backgroundColor = + if (isSelected) colorResource(R.color.colorTertiary) else colorResource(R.color.colorDrawerSpaceListBackground) + val textColor = + if (isSelected) colorResource(R.color.colorOnBackground) else colorResource(R.color.colorText) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .clickable { onSpaceSelected(space) } + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SpaceIcon( + type = space.type, + modifier = Modifier.size(24.dp), + tint = colorResource(R.color.colorOnBackground) + ) + Text( + text = space.friendlyName, + style = MaterialTheme.typography.bodyLarge, + color = textColor + ) + } +} - Icon( - imageVector = Icons.Outlined.Folder, - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = null - ) - Text("Folder") - } - } +@Composable +private fun FolderItem( + project: Archive, + isSelected: Boolean, + onProjectSelected: (Archive) -> Unit +) { + val iconRes = + if (isSelected) R.drawable.baseline_folder_white_24 else R.drawable.outline_folder_white_24 + val iconColor = + if (isSelected) colorResource(R.color.colorTertiary) else colorResource(R.color.colorOnBackground) + val textColor = + if (isSelected) colorResource(R.color.colorOnBackground) else colorResource(R.color.colorText) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onProjectSelected(project) } + .padding(horizontal = 32.dp, vertical = 16.dp), // Increased left padding to 32dp + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = stringResource(R.string.folder_name), + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + Text( + text = project.description ?: "", + style = MaterialTheme.typography.bodyLarge, + color = textColor + ) + } +} + +@PreviewLightDark +@Composable +private fun MainDrawerContentPreview() { + DefaultBoxPreview { + MainDrawerContent( + spaceList = dummySpaceList.map { it.toDomain() }, + selectedSpace = dummySpaceList.first().toDomain(), + projects = dummyProjectList.map { it.toDomain() }, + selectedProject = dummyProjectList.first().toDomain(), + onProjectSelected = {}, + onAddNewFolderClicked = {}, + onSpaceSelected = {}, + onAddNewSpaceClicked = {} + ) + } +} +@Composable +fun ExpandableSpaceList( + serverAccordionState: AccordionState, + selectedSpace: Vault? = null, + spaceList: List, + showDwebEntry: Boolean = false, + onSpaceSelected: (Vault) -> Unit, + onDwebSelected: () -> Unit, + onAddAnotherAccountClicked: () -> Unit, +) { + Accordion( + state = serverAccordionState, + headerContent = { - Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TitleLarge(stringResource(R.string.servers)) - } + Icon( + painter = painterResource(R.drawable.ic_expand_more), + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + .size(24.dp) + .rotate(if (serverAccordionState.expanded) 180f else 0f) + ) + } + }, + bodyContent = { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Column( + modifier = Modifier.background(colorResource(R.color.colorDrawerSpaceListBackground)), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + spaceList.forEach { space -> + DrawerSpaceListItem( + space = space, + isSelected = space.id == selectedSpace?.id, + onSpaceSelected = onSpaceSelected + ) + } - Button( - modifier = Modifier.fillMaxWidth(0.7f), - shape = RoundedCornerShape(8f), - onClick = { + if (showDwebEntry) { + DrawerDwebItem(onClick = onDwebSelected) + } - } - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Default.Add, contentDescription = null) - Text("New Folder") - } + AddAnotherAccountItem { + onAddAnotherAccountClicked() } } + } + ) +} + +@Composable +private fun DrawerDwebItem( + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(colorResource(R.color.colorDrawerSpaceListBackground)) + .clickable { onClick() } + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SpaceIcon( + type = VaultType.DWEB_STORAGE, + modifier = Modifier.size(24.dp), + tint = colorResource(R.color.colorOnBackground) + ) + Text( + text = stringResource(R.string.dweb_title), + style = MaterialTheme.typography.bodyLarge, + color = colorResource(R.color.colorText) + ) } } @Composable -fun MainDrawerFolderListItem( - project: Project, - isSelected: Boolean = false, - onSelected: () -> Unit +private fun DrawerSpaceListItem( + space: Vault, + isSelected: Boolean, + onSpaceSelected: (Vault) -> Unit ) { + val backgroundColor = + if (isSelected) colorResource(R.color.colorTertiary) else colorResource(R.color.colorDrawerSpaceListBackground) + val textColor = + if (isSelected) colorResource(R.color.colorOnBackground) else colorResource(R.color.colorText) + Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .clickable { onSpaceSelected(space) } + .padding(horizontal = 16.dp, vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically ) { + SpaceIcon( + type = space.type, + modifier = Modifier.size(24.dp), + tint = colorResource(R.color.colorOnBackground) + ) + Text( + text = space.friendlyName, + style = MaterialTheme.typography.bodyLarge, + color = textColor + ) + } +} +@Composable +private fun AddAnotherAccountItem( + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(colorResource(R.color.colorDrawerSpaceListBackground)) + .clickable { onClick() } + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { Icon( - imageVector = Icons.Outlined.Folder, - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = null + painter = painterResource(R.drawable.ic_add), + contentDescription = null, + tint = colorResource(R.color.colorTertiary), + modifier = Modifier.size(24.dp) + ) + Text( + text = stringResource(R.string.add_another_account), + style = MaterialTheme.typography.bodyLarge, + color = colorResource(R.color.colorTertiary) ) + } +} - Text("Prague") +@Composable +fun SpaceIcon( + type: VaultType, + modifier: Modifier = Modifier, + tint: Color? = null +) { + val icon = when (type) { + VaultType.PRIVATE_SERVER -> painterResource(R.drawable.ic_space_private_server) + VaultType.INTERNET_ARCHIVE -> painterResource(R.drawable.ic_space_interent_archive) + VaultType.DWEB_STORAGE -> painterResource(R.drawable.ic_space_dweb) } + Icon( + modifier = modifier, + painter = icon, + contentDescription = null, + tint = tint ?: MaterialTheme.colorScheme.onBackground + ) } -@Preview +@PreviewLightDark @Composable -private fun MainDrawerContentPreview() { - DefaultScaffoldPreview { - MainDrawerContent() +private fun ExpandableSpaceListPreview() { + val state = rememberAccordionState( + expanded = true, + ) + + DefaultBoxPreview { + ExpandableSpaceList( + selectedSpace = dummySpaceList[1].toDomain(), + spaceList = dummySpaceList.map { it.toDomain() }, + serverAccordionState = state, + showDwebEntry = true, + onSpaceSelected = {}, + onDwebSelected = {}, + onAddAnotherAccountClicked = {} + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/AddMediaDialogFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/AddMediaDialogFragment.kt deleted file mode 100644 index 8d385aab3..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/AddMediaDialogFragment.kt +++ /dev/null @@ -1,58 +0,0 @@ -package net.opendasharchive.openarchive.features.media - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import net.opendasharchive.openarchive.databinding.FragmentAddMediaDialogBinding - -class AddMediaDialogFragment : DialogFragment() { - - private lateinit var mDialogView: View - private lateinit var mBinding: FragmentAddMediaDialogBinding - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - mDialogView = onCreateView(layoutInflater, null, savedInstanceState) - builder.setView(mDialogView) - return builder.create() - } - - override fun getView(): View { - return mDialogView - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mBinding = FragmentAddMediaDialogBinding.inflate(inflater) - - mBinding.photoCamera.setOnClickListener { - setFragmentResult(RESP_TAKE_PHOTO, bundleOf()) - dismiss() - } - - mBinding.photoGallery.setOnClickListener { - setFragmentResult(RESP_PHOTO_GALLERY, bundleOf()) - dismiss() - } - mBinding.files.setOnClickListener { - setFragmentResult(RESP_FILES, bundleOf()) - dismiss() - } - - return mBinding.root - } - - companion object { - const val RESP_TAKE_PHOTO = "add_media_dialog_fragment_take_photo_resp" - const val RESP_PHOTO_GALLERY = "add_media_dialog_fragment_photo_gallery_resp" - const val RESP_FILES = "add_media_dialog_fragment_files_resp" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerFragment.kt deleted file mode 100644 index 4cb98d75a..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerFragment.kt +++ /dev/null @@ -1,53 +0,0 @@ -package net.opendasharchive.openarchive.features.media - -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import net.opendasharchive.openarchive.databinding.FragmentContentPickerBinding - -class ContentPickerFragment(private val onMediaPicked: (AddMediaType) -> Unit): BottomSheetDialogFragment() { - - private var _binding: FragmentContentPickerBinding? = null - private val binding get() = _binding!! - - - companion object { - const val TAG = "ModalBottomSheet-ContentPickerFragment" - const val KEY_DISMISS = "ContentPickerFragment.Dismiss" - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - _binding = FragmentContentPickerBinding.inflate(inflater, container, false) - - - binding.actionUploadCamera.setOnClickListener { - onMediaPicked(AddMediaType.CAMERA) - dismiss() - } - - binding.actionUploadMedia.setOnClickListener { - onMediaPicked(AddMediaType.GALLERY) - dismiss() - } - - binding.actionUploadFiles.setOnClickListener { - onMediaPicked(AddMediaType.FILES) - dismiss() - } - - - return binding.root - } - - override fun onDismiss(dialog: DialogInterface) { - parentFragmentManager.setFragmentResult(KEY_DISMISS, Bundle()) - super.onDismiss(dialog) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerLauncher.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerLauncher.kt new file mode 100644 index 000000000..f7c4413bd --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerLauncher.kt @@ -0,0 +1,251 @@ +package net.opendasharchive.openarchive.features.media + +import android.app.Activity +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import org.koin.compose.koinInject +import net.opendasharchive.openarchive.features.media.camera.CameraConfig +import net.opendasharchive.openarchive.util.Prefs +import net.opendasharchive.openarchive.util.Utility +import java.io.File +import kotlin.coroutines.cancellation.CancellationException + +data class ContentPickerLaunchers( + val launch: (AddMediaType) -> Unit, + val isProcessing: Boolean, + val errorMessage: String? +) + +@Composable +fun rememberContentPickerLaunchers( + navigator: Navigator? = null, + useCustomCamera: Boolean = true, + projectProvider: () -> Archive?, + onError: (String) -> Unit, + onMediaImported: (List) -> Unit, +): ContentPickerLaunchers { + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val projectRepository: ProjectRepository = koinInject() + val mediaRepository: MediaRepository = koinInject() + + var isProcessing by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + // Keep last camera URI so we can import it + var cameraUri by remember { mutableStateOf(null) } + + // Debouncing mechanism to prevent multiple rapid picker launches + var lastPickerLaunchTime by remember { mutableStateOf(0L) } + val debounceMs = 1000L + + // ---- Gallery picker ---- + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(10) + ) { uris -> + if (uris.isEmpty()) return@rememberLauncherForActivityResult + + scope.launch { + isProcessing = true + errorMessage = null + try { + val archive = projectProvider() ?: run { + onError("Project provider returned null") + return@launch + } + val submission = projectRepository.getActiveSubmission(archive.id) + val evidenceList = withContext(Dispatchers.IO) { + MediaPicker.import(context, archive, submission.id, uris) + } + evidenceList.forEach { evidence -> + mediaRepository.addEvidence(evidence) + } + onMediaImported(evidenceList) + } catch (e: CancellationException) { + // ignore + AppLogger.i("ContentPickerLauncher: Gallery import cancelled", e) + } catch (e: Exception) { + errorMessage = "Failed to copy: ${e.localizedMessage}" + } finally { + isProcessing = false + } + } + } + + // ---- File picker ---- + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + + scope.launch { + isProcessing = true + errorMessage = null + try { + val archive = projectProvider() ?: run { + onError("Project provider returned null") + return@launch + } + val submission = projectRepository.getActiveSubmission(archive.id) + val evidenceList = withContext(Dispatchers.IO) { + // single-URI import + MediaPicker.import(context, archive, submission.id, uri) + ?.let { listOf(it) } + ?: emptyList() + } + evidenceList.forEach { evidence -> + mediaRepository.addEvidence(evidence) + } + onMediaImported(evidenceList) + } catch (e: Exception) { + errorMessage = "Failed to copy: ${e.localizedMessage}" + } finally { + isProcessing = false + } + } + } + + // ---- Camera (modern TakePicture) ---- + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + val finalUri = cameraUri + if (!success || finalUri == null) return@rememberLauncherForActivityResult + + scope.launch { + isProcessing = true + errorMessage = null + try { + val archive = projectProvider() ?: run { + onError("Project provider returned null") + return@launch + } + val submission = projectRepository.getActiveSubmission(archive.id) + val evidenceList = withContext(Dispatchers.IO) { + MediaPicker.import(context, archive, submission.id, finalUri) + ?.let { listOf(it) } + ?: emptyList() + } + evidenceList.forEach { evidence -> + mediaRepository.addEvidence(evidence) + } + onMediaImported(evidenceList) + } catch (e: Exception) { + errorMessage = "Camera file processing failed: ${e.localizedMessage}" + } finally { + isProcessing = false + } + } + } + + // Custom camera launcher is no longer needed here as it's handled via navigation + + // ---- Launchers exposed to caller ---- + val launchGallery: () -> Unit = { + val currentTime = System.currentTimeMillis() + if (currentTime - lastPickerLaunchTime >= debounceMs) { + lastPickerLaunchTime = currentTime + errorMessage = null + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + ) + } + } + + val launchFilePicker: () -> Unit = { + val currentTime = System.currentTimeMillis() + if (currentTime - lastPickerLaunchTime >= debounceMs) { + lastPickerLaunchTime = currentTime + errorMessage = null + filePickerLauncher.launch( + arrayOf( + "application/pdf", + "image/*", + "video/*", + "audio/mpeg", + "audio/mp3" + ) + ) + } + } + + val launchCamera: () -> Unit = launchCamera@{ + val currentTime = System.currentTimeMillis() + if (currentTime - lastPickerLaunchTime < debounceMs) return@launchCamera + lastPickerLaunchTime = currentTime + + errorMessage = null + if (useCustomCamera && navigator != null) { + val archive = projectProvider() + if (archive != null) { + val cameraConfig = CameraConfig( + allowVideoCapture = true, + allowPhotoCapture = true, + allowMultipleCapture = false, + enablePreview = true, + showFlashToggle = true, + showGridToggle = true, + showCameraSwitch = true + ) + navigator.navigateTo(AppRoute.CameraRoute(projectId = archive.id, config = cameraConfig)) + } else { + errorMessage = "No folder selected" + } + } else { + // TODO: This is a temporary persistent storage solution. + // Review this when implementing the new Evidence architecture. + val file: File? = Utility.getOutputMediaFile( + context, + "IMG_${System.currentTimeMillis()}.jpg" + ) + if (file == null) { + errorMessage = "Failed to prepare camera file" + return@launchCamera + } + + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file + ) + cameraUri = uri + cameraLauncher.launch(uri) + } + } + + val launch: (AddMediaType) -> Unit = { type -> + when (type) { + AddMediaType.GALLERY -> launchGallery() + AddMediaType.FILES -> launchFilePicker() + AddMediaType.CAMERA -> launchCamera() + } + } + + return ContentPickerLaunchers( + launch = launch, + isProcessing = isProcessing, + errorMessage = errorMessage + ) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerSheet.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerSheet.kt new file mode 100644 index 000000000..3a76256b2 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerSheet.kt @@ -0,0 +1,190 @@ +package net.opendasharchive.openarchive.features.media + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContentPickerSheet( + title: String? = null, + onClipboardClick: (() -> Unit)? = null, + onDismiss: () -> Unit, + onMediaTypeSelected: (AddMediaType) -> Unit +) { + + val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + + ModalBottomSheet( + onDismissRequest = onDismiss, + shape = shape, + containerColor = colorResource(R.color.colorPill) + ) { + ContentPickerContent( + title = title, + onClipboardClick = onClipboardClick, + onDismiss = onDismiss, + onMediaTypeSelected = onMediaTypeSelected + ) + } +} + +@Composable +private fun ContentPickerContent( + title: String? = null, + onClipboardClick: (() -> Unit)? = null, + onDismiss: () -> Unit, + onMediaTypeSelected: (AddMediaType) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + Text( + text = title ?: stringResource(R.string.content_picker_label), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + style = MaterialTheme.typography.bodyLarge.copy( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground + ) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.Bottom + ) { + PickerItem( + iconRes = R.drawable.ic_photo_camera, + labelRes = R.string.camera + ) { + onMediaTypeSelected(AddMediaType.CAMERA) + onDismiss() + } + + PickerItem( + iconRes = R.drawable.ic_image_gallery_line, + labelRes = R.string.photo_gallery + ) { + onMediaTypeSelected(AddMediaType.GALLERY) + onDismiss() + } + + PickerItem( + iconRes = R.drawable.ic_description, + labelRes = R.string.files + ) { + onMediaTypeSelected(AddMediaType.FILES) + onDismiss() + } + } + + onClipboardClick?.let { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + it() + onDismiss() + } + .padding(vertical = 20.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_content_copy), + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.paste_from_clipboard), + style = MaterialTheme.typography.bodyLarge.copy( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary + ) + ) + } + } + } +} + +@Composable +private fun PickerItem( + iconRes: Int, + labelRes: Int, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .clickable { onClick() }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = colorResource(R.color.colorOnBackground), + modifier = Modifier + .padding(bottom = 8.dp) + ) + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = MontserratFontFamily + ), + color = MaterialTheme.colorScheme.onBackground + ) + } +} + +@PreviewLightDark +@Composable +private fun ContentPickerSheetPreview() { + DefaultBoxPreview { + ContentPickerContent( + onDismiss = {}, + onMediaTypeSelected = {}, + onClipboardClick = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaLaunchers.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaLaunchers.kt index 58452d911..9579d553a 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaLaunchers.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaLaunchers.kt @@ -8,7 +8,6 @@ import androidx.activity.result.PickVisualMediaRequest data class MediaLaunchers( val galleryLauncher: ActivityResultLauncher, // Changed val filePickerLauncher: ActivityResultLauncher, - val cameraLauncher: ActivityResultLauncher, val modernCameraLauncher: ActivityResultLauncher, val customCameraLauncher: ActivityResultLauncher ) \ No newline at end of file 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 new file mode 100644 index 000000000..7c87c2460 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt @@ -0,0 +1,265 @@ +package net.opendasharchive.openarchive.features.media + +import android.content.Context +import android.net.Uri +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.util.C2paHelper +import net.opendasharchive.openarchive.util.DateUtils +import net.opendasharchive.openarchive.util.MediaThumbnailGenerator +import net.opendasharchive.openarchive.util.MetadataCollector +import net.opendasharchive.openarchive.util.Utility +import net.opendasharchive.openarchive.util.toLocalDateTime +import java.io.File +import java.io.FileNotFoundException +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +object MediaPicker { + // Debug-only artificial delay per media item import for UX/testing. + private const val DEBUG_IMPORT_DELAY_MS = 1200L + + suspend fun import( + context: Context, + archive: Archive, + submissionId: Long, + uris: List, + ): ArrayList { + val result = ArrayList() + + for (uri in uris) { + try { + val evidence = import(context, archive, submissionId, uri) + if (evidence != null) result.add(evidence) + } catch (e: Exception) { + AppLogger.e("Error importing media", e) + } + } + + return result + } + + suspend fun import( + context: Context, + archive: Archive, + submissionId: Long, + uri: Uri, + ): Evidence? { + + val title = Utility.getUriDisplayName(context, uri) + ?: uri.lastPathSegment + ?: uri.path?.substringAfterLast('/') + ?: "" + val file = Utility.getOutputMediaFile(context, title.ifBlank { "media" }) + + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + if (!Utility.writeStreamToFile(inputStream, file)) { + AppLogger.e("Failed to write stream to file for URI: $uri") + return null + } + } ?: run { + AppLogger.e("Failed to open input stream for URI: $uri") + return null + } + } catch (e: FileNotFoundException) { + AppLogger.e("File not found for URI: $uri", e) + return null + } catch (e: SecurityException) { + AppLogger.e("Permission denied for URI: $uri", e) + return null + } catch (e: java.io.IOException) { + AppLogger.e("IO error reading URI: $uri", e) + return null + } + + val fileSource = uri.path?.let { File(it) } + var createDate = DateUtils.now + var contentLength = 0L + + if (fileSource?.exists() == true) { + createDate = fileSource.lastModified() + contentLength = fileSource.length() + } else { + contentLength = file?.length() ?: 0 + } + + val mimeType = getMimeTypeWithFallback(context, uri, file?.path) + + // --- Collect full device / environment metadata --- + // Always attempt location; MetadataCollector gates on ACCESS_FINE_LOCATION permission + val captureMetadata = MetadataCollector.collectMetadata(context) + + // --- Write EXIF to the file BEFORE hashing so the hash covers the final bytes --- + if (file != null && mimeType.startsWith("image/")) { + try { + MetadataCollector.writeExifMetadata(file, captureMetadata) + } catch (e: Exception) { + // Non-fatal: log and continue; some image formats (e.g. GIF) don't support EXIF + AppLogger.w("EXIF write skipped for ${file.name}: ${e.message}") + } + } + + // --- Hash the file (after EXIF so the hash reflects the final stored bytes) --- + val mediaHashString = try { + file?.let { f -> + val digest = MessageDigest.getInstance("SHA-256") + f.inputStream().use { stream -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (stream.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + digest.digest().joinToString("") { "%02x".format(it) } + } ?: "" + } catch (e: Exception) { + AppLogger.e("Failed to generate hash for media", e) + "" + } + + val thumbnail = try { + file?.let { MediaThumbnailGenerator.generateThumbnailBytes(it, mimeType) } + } catch (e: Exception) { + AppLogger.e("Failed to generate thumbnail for media", e) + null + } + + val originalFilePath = Uri.fromFile(file).toString() + + val evidence = Evidence( + archiveId = archive.id, + submissionId = submissionId, + title = title, + originalFilePath = originalFilePath, + thumbnail = thumbnail, + mimeType = mimeType, + contentLength = contentLength, + createdAt = createDate.toLocalDateTime(), + updatedAt = createDate.toLocalDateTime(), + status = EvidenceStatus.LOCAL, + mediaHashString = mediaHashString + ) + + // --- Generate C2PA sidecar with the full proof metadata map --- + if (file != null && mediaHashString.isNotEmpty()) { + C2paHelper.generateManifest( + context = context, + mediaFile = file, + mediaHash = mediaHashString, + metadata = buildProofMetadata( + evidence = evidence, + mediaFile = file, + mediaHash = mediaHashString, + captureMetadata = captureMetadata + ) + ) + } + + return evidence + } + + /** + * Build a metadata map whose keys match the ProofMode v1.0.25 field names. + * This is written into the C2PA sidecar JSON so proofs are interoperable with + * existing ProofMode verification tools. + */ + private fun buildProofMetadata( + evidence : Evidence, + mediaFile : File, + mediaHash : String, + captureMetadata: MetadataCollector.CaptureMetadata + ): Map { + val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).also { + it.timeZone = TimeZone.getTimeZone("UTC") + } + + return buildMap { + // --- File --- + put("File Hash SHA256", mediaHash) + put("File Path", evidence.originalFilePath) + put("File Created", isoFmt.format(Date(captureMetadata.captureTime))) + put("File Modified", isoFmt.format(Date(mediaFile.lastModified()))) + + // --- Proof --- + put("Proof Generated", isoFmt.format(Date(captureMetadata.captureTime))) + put("Notes", "${captureMetadata.appName} ${captureMetadata.appVersion}") + + // --- Device --- + put("Manufacturer", captureMetadata.deviceMake) + // ProofMode "Hardware" is make + model, e.g. "samsung SM-S9010" + put("Hardware", "${captureMetadata.deviceMake} ${captureMetadata.deviceModel}") + + // --- Locale / language --- + put("Locale", captureMetadata.locale) + put("Language", captureMetadata.language) + + // --- Screen --- + captureMetadata.screenSizeInches?.let { put("ScreenSize", it.toString()) } + + // --- Location --- + captureMetadata.latitude?.let { put("Location.Latitude", it.toString()) } + captureMetadata.longitude?.let { put("Location.Longitude", it.toString()) } + captureMetadata.locationAltitude?.let { put("Location.Altitude", it.toString()) } + captureMetadata.locationAccuracy?.let { put("Location.Accuracy", it.toString()) } + captureMetadata.locationBearing?.let { put("Location.Bearing", it.toString()) } + captureMetadata.locationSpeed?.let { put("Location.Speed", it.toString()) } + captureMetadata.locationTime?.let { put("Location.Time", it.toString()) } + captureMetadata.locationProvider?.let { put("Location.Provider", it) } + + // --- Network --- + captureMetadata.networkType?.let { put("NetworkType", it) } + // DataType mirrors NetworkType label (ProofMode uses both) + captureMetadata.networkType?.let { put("DataType", it) } + captureMetadata.ipv4?.let { put("IPv4", it) } + captureMetadata.ipv6?.let { put("IPv6", it) } + + // --- Cell --- + captureMetadata.cellInfo?.let { put("CellInfo", it) } + + // --- Extra fields useful for Save --- + put("mimeType", evidence.mimeType) + put("title", evidence.title) + } + } + + /** + * Enhanced mime type detection that falls back to file extension detection + * for file URIs where ContentResolver might not have mime type info. + */ + private fun getMimeTypeWithFallback(context: Context, uri: Uri, filePath: String?): String { + val standardMimeType = Utility.getMimeType(context, uri) + if (!standardMimeType.isNullOrEmpty()) return standardMimeType + + val extension = when { + filePath != null -> File(filePath).extension + uri.path != null -> File(uri.path!!).extension + else -> null + } + + return when (extension?.lowercase()) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "mp4" -> "video/mp4" + "mov" -> "video/quicktime" + "avi" -> "video/x-msvideo" + "mkv" -> "video/x-matroska" + "webm" -> "video/webm" + "mp3" -> "audio/mpeg" + "wav" -> "audio/wav" + "ogg" -> "audio/ogg" + "m4a" -> "audio/mp4" + else -> { + AppLogger.w("Unknown file extension '$extension' for URI: $uri") + "application/octet-stream" + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt deleted file mode 100644 index b9120ee09..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt +++ /dev/null @@ -1,475 +0,0 @@ -package net.opendasharchive.openarchive.features.media - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.provider.MediaStore -import android.view.View -import android.widget.ProgressBar -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.FileProvider -import androidx.lifecycle.lifecycleScope -import com.google.android.material.snackbar.Snackbar -import net.opendasharchive.openarchive.features.media.camera.CameraActivity -import net.opendasharchive.openarchive.features.media.camera.CameraConfig -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.Utility -import net.opendasharchive.openarchive.util.extensions.makeSnackBar -import org.witness.proofmode.ProofMode -import org.witness.proofmode.crypto.HashUtils -import timber.log.Timber -import java.io.File -import java.io.FileNotFoundException -import java.util.Date - -object Picker { - - private var currentPhotoUri: Uri? = null - - // Debouncing mechanism to prevent multiple rapid picker launches - private var lastPickerLaunchTime = 0L - private const val PICKER_LAUNCH_DEBOUNCE_MS = 1000L - - fun register( - activity: ComponentActivity, - root: View, - project: () -> Project?, - completed: (List) -> Unit - ): MediaLaunchers { - - // Official Gallery Picker - val galleryLauncher = activity.registerForActivityResult( - ActivityResultContracts.PickMultipleVisualMedia(10) // Supports multiple selection - ) { uris: List? -> - if (uris.isNullOrEmpty()) return@registerForActivityResult - - val snackbar = showProgressSnackBar(activity, root, activity.getString(R.string.importing_media)) - activity.lifecycleScope.launch(Dispatchers.IO) { - val media = import(activity, project(), uris, false) - activity.lifecycleScope.launch(Dispatchers.Main) { - snackbar.dismiss() - completed(media) - } - } - } - - val filePickerLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode != AppCompatActivity.RESULT_OK) return@registerForActivityResult - - val uri = result.data?.data ?: return@registerForActivityResult - - val snackbar = showProgressSnackBar(activity, root, activity.getString(R.string.importing_media)) - - activity.lifecycleScope.launch(Dispatchers.IO) { - // We don't generate proof for file picker files. - val files = import(activity, project(), listOf(uri), false) - - activity.lifecycleScope.launch(Dispatchers.Main) { - snackbar.dismiss() - completed(files) - } - } - } - - val legacyCameraLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - currentPhotoUri?.let { uri -> - - val snackbar = showProgressSnackBar(activity, root, activity.getString(R.string.importing_media)) - - activity.lifecycleScope.launch(Dispatchers.IO) { - // We generate proof for in app capture Just because we toggle it true, it doesn't generate proof. - // It should be on in the settings too. We check that inside import - val media = import(activity, project(), listOf(uri),true) - - activity.lifecycleScope.launch(Dispatchers.Main) { - snackbar.dismiss() - completed(media) - } - } - } - } - } - - // Modern camera launcher using TakePicture contract - val modernCameraLauncher = activity.registerForActivityResult( - ActivityResultContracts.TakePicture() - ) { success -> - if (success && currentPhotoUri != null) { - val capturedImageUri: Uri = currentPhotoUri!! - val snackbar = showProgressSnackBar(activity, root, activity.getString(R.string.importing_media)) - - activity.lifecycleScope.launch(Dispatchers.IO) { - try { - // Import the captured photo with proof generation enabled - val media = import(activity, project(), listOf(capturedImageUri), true) - - activity.lifecycleScope.launch(Dispatchers.Main) { - snackbar.dismiss() - completed(media) - } - } catch (e: Exception) { - AppLogger.e("Error processing camera capture", e) - activity.lifecycleScope.launch(Dispatchers.Main) { - snackbar.dismiss() - Toast.makeText(activity, "Failed to process photo", Toast.LENGTH_SHORT).show() - } - } - } - } else { - // Camera capture failed or was cancelled - AppLogger.w("Camera capture failed or cancelled") - currentPhotoUri = null - } - } - - // Custom camera launcher - val customCameraLauncher = activity.registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - val capturedUris = result.data?.getStringArrayListExtra(CameraActivity.EXTRA_CAPTURED_URIS) - if (!capturedUris.isNullOrEmpty()) { - val uris = capturedUris.map { Uri.parse(it) } - val snackbar = showProgressSnackBar(activity, root, activity.getString(R.string.importing_media)) - - activity.lifecycleScope.launch(Dispatchers.IO) { - try { - // Import the captured media with proof generation enabled - // This ensures proper mimetype detection and Media object setup - val media = import(activity, project(), uris, true) - - activity.lifecycleScope.launch(Dispatchers.Main) { - snackbar.dismiss() - completed(media) - } - } catch (e: Exception) { - AppLogger.e("Error processing camera captures", e) - activity.lifecycleScope.launch(Dispatchers.Main) { - snackbar.dismiss() - Toast.makeText(activity, "Failed to process captures", Toast.LENGTH_SHORT).show() - } - } - } - } else { - AppLogger.w("No captures returned from custom camera") - } - } else { - AppLogger.w("Custom camera capture cancelled or failed") - } - } - - return MediaLaunchers( - galleryLauncher = galleryLauncher, - filePickerLauncher = filePickerLauncher, - cameraLauncher = legacyCameraLauncher, - modernCameraLauncher = modernCameraLauncher, - customCameraLauncher = customCameraLauncher - ) - } - - fun pickMedia(launcher: ActivityResultLauncher) { - // Debounce: Prevent multiple launches within PICKER_LAUNCH_DEBOUNCE_MS - val currentTime = System.currentTimeMillis() - if (currentTime - lastPickerLaunchTime < PICKER_LAUNCH_DEBOUNCE_MS) { - AppLogger.w("Picker launch ignored due to debouncing (too soon after previous launch)") - return - } - lastPickerLaunchTime = currentTime - - val request = PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) - try { - launcher.launch(request) - } catch (e: IllegalStateException) { - AppLogger.e("Error launching media picker", e) - } - } - - fun canPickFiles(context: Context): Boolean { - return mFilePickerIntent.resolveActivity(context.packageManager) != null - } - - fun pickFiles(launcher: ActivityResultLauncher) { - // Debounce: Prevent multiple launches within PICKER_LAUNCH_DEBOUNCE_MS - val currentTime = System.currentTimeMillis() - if (currentTime - lastPickerLaunchTime < PICKER_LAUNCH_DEBOUNCE_MS) { - AppLogger.w("File picker launch ignored due to debouncing (too soon after previous launch)") - return - } - lastPickerLaunchTime = currentTime - - launcher.launch(mFilePickerIntent) - } - - private val mFilePickerIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - - putExtra( - Intent.EXTRA_MIME_TYPES, - arrayOf( - "application/pdf", // pdf - "image/*", // all images - "video/*", // all videos - "audio/mpeg", // mp3 (most devices) - "audio/mp3" // some devices use this - ) - ) - } - - /** - * Launch custom camera with configuration options. - * Supports both photo and video capture with preview functionality. - */ - fun launchCustomCamera(activity: Activity, launcher: ActivityResultLauncher, config: CameraConfig = CameraConfig()) { - // Debounce: Prevent multiple launches within PICKER_LAUNCH_DEBOUNCE_MS - val currentTime = System.currentTimeMillis() - if (currentTime - lastPickerLaunchTime < PICKER_LAUNCH_DEBOUNCE_MS) { - AppLogger.w("Custom camera launch ignored due to debouncing (too soon after previous launch)") - return - } - lastPickerLaunchTime = currentTime - - val intent = CameraActivity.createIntent(activity, config) - launcher.launch(intent) - } - - /** - * Modern camera photo capture using TakePicture contract. - * This is the recommended approach for new implementations. - */ - fun takePhotoModern(activity: Activity, launcher: ActivityResultLauncher) { - // Debounce: Prevent multiple launches within PICKER_LAUNCH_DEBOUNCE_MS - val currentTime = System.currentTimeMillis() - if (currentTime - lastPickerLaunchTime < PICKER_LAUNCH_DEBOUNCE_MS) { - AppLogger.w("Modern camera launch ignored due to debouncing (too soon after previous launch)") - return - } - lastPickerLaunchTime = currentTime - - try { - val file = Utility.getOutputMediaFileByCache(activity, "IMG_${System.currentTimeMillis()}.jpg") - - file?.let { - val uri = FileProvider.getUriForFile( - activity, - "${activity.packageName}.provider", - it - ) - - currentPhotoUri = uri - AppLogger.d("Taking photo with modern launcher, URI: $uri") - launcher.launch(uri) - } ?: run { - AppLogger.e("Failed to create temp file for camera") - Toast.makeText(activity, "Failed to prepare camera", Toast.LENGTH_SHORT).show() - } - } catch (e: Exception) { - AppLogger.e("Error setting up camera", e) - Toast.makeText(activity, "Camera setup failed", Toast.LENGTH_SHORT).show() - } - } - - /** - * Legacy camera photo capture (kept for backward compatibility). - * Use takePhotoModern() for new implementations. - */ - @Deprecated("Use takePhotoModern() instead") - fun takePhoto(activity: Activity, launcher: ActivityResultLauncher) { - val file = Utility.getOutputMediaFileByCache(activity, "IMG_${System.currentTimeMillis()}.jpg") - - file?.let { - val uri = FileProvider.getUriForFile( - activity, "${activity.packageName}.provider", - it - ) - - currentPhotoUri = uri - - val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { - putExtra(MediaStore.EXTRA_OUTPUT, uri) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) // Ensure permission is granted - } - - if (takePictureIntent.resolveActivity(activity.packageManager) != null) { - launcher.launch(takePictureIntent) - } else { - Toast.makeText(activity, "Camera not available", Toast.LENGTH_SHORT).show() - } - } - } - - private fun import(context: Context, project: Project?, uris: List, generateProof: Boolean): ArrayList { - val result = ArrayList() - - for (uri in uris) { - try { - //Simply pass the generate proof boolean for single file import which is looped here - val media = import(context, project, uri, generateProof) - if (media != null) result.add(media) - } catch (e: Exception) { - AppLogger.e( "Error importing media", e) - } - } - - return result - } - - fun import(context: Context, project: Project?, uri: Uri, generateProof: Boolean): Media? { - - val project = project ?: return null - - val title = Utility.getUriDisplayName(context, uri) ?: "" - val file = Utility.getOutputMediaFileByCache(context, title) - - // Use try-with-resources pattern for proper resource management - try { - context.contentResolver.openInputStream(uri)?.use { inputStream -> - if (!Utility.writeStreamToFile(inputStream, file)) { - AppLogger.e("Failed to write stream to file for URI: $uri") - return null - } - } ?: run { - AppLogger.e("Failed to open input stream for URI: $uri") - return null - } - } catch (e: FileNotFoundException) { - AppLogger.e("File not found for URI: $uri", e) - return null - } catch (e: SecurityException) { - AppLogger.e("Permission denied for URI: $uri", e) - return null - } catch (e: java.io.IOException) { - AppLogger.e("IO error reading URI: $uri", e) - return null - } - - // Create media object - val media = Media() - val coll = project.openCollection - media.collectionId = coll.id - - val fileSource = uri.path?.let { File(it) } - var createDate = Date() - - if (fileSource?.exists() == true) { - createDate = Date(fileSource.lastModified()) - media.contentLength = fileSource.length() - } - else { - media.contentLength = file?.length() ?: 0 - } - - media.originalFilePath = Uri.fromFile(file).toString() - // Enhanced mime type detection for file URIs - media.mimeType = getMimeTypeWithFallback(context, uri, file?.path) - media.createDate = createDate - media.updateDate = media.createDate - media.sStatus = Media.Status.Local - - //We generate hash regardless if proof is on or off because we don't want unexpected behaviour when we are looking for proof files when uploaded later. - // Generate hash regardless of proof mode setting for consistency - try { - media.mediaHashString = file?.let { - HashUtils.getSHA256FromFileContent(it.inputStream()) - } ?: "" - } catch (e: Exception) { - AppLogger.e("Failed to generate hash for media", e) - media.mediaHashString = "" - } - - media.projectId = project.id - media.title = title - media.save() - - // Generate ProofMode data if enabled - if (generateProof && Prefs.useProofMode) { - - try { - //If Proof mode is on we need this to be on always - // Ensure location and network tracking are enabled for camera captures - // Only enabled for camera captures (generateProof = true) - Prefs.proofModeLocation = true - Prefs.proofModeNetwork = true - - AppLogger.d("Generating ProofMode data for URI: $uri, Hash: ${media.mediaHashString}") - - // Generate proof using the ProofMode library - ProofMode.generateProof(context, uri, media.mediaHashString) - - AppLogger.i("ProofMode generation completed for media: ${media.title}") - } catch (e: Exception) { - AppLogger.e("Failed to generate ProofMode data", e) - Timber.w("ProofMode generation failed: ${e.message}") - } - } else { - if (generateProof) { - AppLogger.w("ProofMode generation requested but useProofMode is disabled") - } - Timber.w("Skipping proof generation - generateProof: $generateProof, useProofMode: ${Prefs.useProofMode}") - } - return media - } - - - - @SuppressLint("RestrictedApi") - private fun showProgressSnackBar(activity: Activity, root: View, message: String): Snackbar { - val bar = root.makeSnackBar(message) - (bar.view as? Snackbar.SnackbarLayout)?.addView(ProgressBar(activity)) - bar.show() - return bar - } - - /** - * Enhanced mime type detection that falls back to file extension detection - * for file URIs where ContentResolver might not have mime type info. - */ - private fun getMimeTypeWithFallback(context: Context, uri: Uri, filePath: String?): String { - // First try the standard way - val standardMimeType = Utility.getMimeType(context, uri) - if (!standardMimeType.isNullOrEmpty()) { - return standardMimeType - } - - // Fallback to file extension detection - val extension = when { - filePath != null -> File(filePath).extension - uri.path != null -> File(uri.path!!).extension - else -> null - } - - return when (extension?.lowercase()) { - "jpg", "jpeg" -> "image/jpeg" - "png" -> "image/png" - "gif" -> "image/gif" - "webp" -> "image/webp" - "mp4" -> "video/mp4" - "mov" -> "video/quicktime" - "avi" -> "video/x-msvideo" - "mkv" -> "video/x-matroska" - "webm" -> "video/webm" - "mp3" -> "audio/mpeg" - "wav" -> "audio/wav" - "ogg" -> "audio/ogg" - "m4a" -> "audio/mp4" - else -> { - AppLogger.w("Unknown file extension '$extension' for URI: $uri") - "application/octet-stream" // Generic binary type - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewActivity.kt deleted file mode 100644 index 2f8033394..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewActivity.kt +++ /dev/null @@ -1,429 +0,0 @@ -package net.opendasharchive.openarchive.features.media - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup.MarginLayoutParams -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.recyclerview.widget.GridLayoutManager -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityPreviewBinding -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.asUiImage -import net.opendasharchive.openarchive.features.core.asUiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.util.PermissionManager -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets -import net.opendasharchive.openarchive.util.extensions.hide -import net.opendasharchive.openarchive.util.extensions.isVisible -import net.opendasharchive.openarchive.util.extensions.show -import net.opendasharchive.openarchive.util.extensions.toggle -import kotlin.math.max -import net.opendasharchive.openarchive.features.media.camera.CameraConfig -import net.opendasharchive.openarchive.features.settings.passcode.AppConfig -import org.koin.android.ext.android.inject -import kotlin.getValue - -class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Listener { - - companion object { - private const val PROJECT_ID_EXTRA = "project_id" - - fun start(context: Context, projectId: Long) { - val i = Intent(context, PreviewActivity::class.java) - i.putExtra(PROJECT_ID_EXTRA, projectId) - - context.startActivity(i) - } - } - - private val appConfig by inject() - - private lateinit var mBinding: ActivityPreviewBinding - - private lateinit var mediaLaunchers: MediaLaunchers - - private val mLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - refresh() - } - - private var mProject: Project? = null - - private val mAdapter: PreviewAdapter? - get() = mBinding.mediaGrid.adapter as? PreviewAdapter - - private var mMedia: List - get() = mAdapter?.currentList ?: emptyList() - set(value) { - mAdapter?.submitList(value) { - runOnUiThread { - mediaSelectionChanged() - } - } - } - - private lateinit var permissionManager: PermissionManager - - private var navigationBarInset = 0 - private var initialMediaGridBottomPadding = 0 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivityPreviewBinding.inflate(layoutInflater) - initialMediaGridBottomPadding = mBinding.mediaGrid.paddingBottom - - mBinding.btAddMoreLayout.applyEdgeToEdgeInsets(WindowInsetsCompat.Type.navigationBars()) { insets -> - bottomMargin = insets.bottom - } - - mBinding.bottomBar.applyEdgeToEdgeInsets(WindowInsetsCompat.Type.navigationBars()) { insets -> - bottomMargin = insets.bottom - } - - setContentView(mBinding.root) - - permissionManager = PermissionManager(this, dialogManager) - - mProject = Project.getById(intent.getLongExtra(PROJECT_ID_EXTRA, -1)) - - mediaLaunchers = Picker.register(this, mBinding.root, { mProject }, { - refresh() - }) - - setupToolbar( - title = getString(R.string.preview_media), - showBackButton = true - ) - - mBinding.mediaGrid.layoutManager = GridLayoutManager(this, 2) - mBinding.mediaGrid.adapter = PreviewAdapter(this) - mBinding.mediaGrid.setHasFixedSize(true) - mBinding.mediaGrid.clipToPadding = false - ViewCompat.setOnApplyWindowInsetsListener(mBinding.mediaGrid) { _, windowInsets -> - navigationBarInset = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom - requestRecyclerBottomPaddingUpdate() - windowInsets - } - requestRecyclerBottomPaddingUpdate() - - mBinding.btAddMore.setOnClickListener(this) - mBinding.btBatchEdit.setOnClickListener(this) - mBinding.btSelectAll.setOnClickListener(this) - mBinding.btRemove.setOnClickListener(this) - - if (Picker.canPickFiles(this)) { - mBinding.btAddMore.setOnLongClickListener { - //mBinding.addMenu.container.show(animate = true) - initAddMediaBottomSheet() - true - } - - mBinding.addMenu.container.setOnClickListener { - it.hide(animate = true) - } - - mBinding.addMenu.menu.setNavigationItemSelectedListener { - when (it.itemId) { - R.id.action_upload_media -> { - onClick(mBinding.btAddMore) - } - - R.id.action_upload_camera -> { - if (appConfig.useCustomCamera) { - // Use custom camera with photo and video support - val cameraConfig = CameraConfig( - allowVideoCapture = true, - allowPhotoCapture = true, - allowMultipleCapture = false, // Allow adding multiple items - enablePreview = true, - showFlashToggle = true, - showGridToggle = true, - showCameraSwitch = true - ) - Picker.launchCustomCamera( - this@PreviewActivity, - mediaLaunchers.customCameraLauncher, - cameraConfig - ) - } else { - permissionManager.checkCameraPermission { - Picker.takePhotoModern( - activity = this@PreviewActivity, - launcher = mediaLaunchers.modernCameraLauncher - ) - } - } - } - - R.id.action_upload_files -> { - Picker.pickFiles(mediaLaunchers.filePickerLauncher) - } - } - - mBinding.addMenu.container.hide(animate = true) - - true - } - } - - - refresh() - } - - private fun initAddMediaBottomSheet() { - - if (Picker.canPickFiles(this)) { - val modalBottomSheet = ContentPickerFragment { action -> - when (action) { - AddMediaType.CAMERA -> { - if (appConfig.useCustomCamera) { - // Use custom camera with photo and video support - val cameraConfig = CameraConfig( - allowVideoCapture = true, - allowPhotoCapture = true, - allowMultipleCapture = true, // Allow adding multiple items in preview - enablePreview = true, - showFlashToggle = true, - showGridToggle = true, - showCameraSwitch = true - ) - Picker.launchCustomCamera( - this@PreviewActivity, - mediaLaunchers.customCameraLauncher, - cameraConfig - ) - } else { - permissionManager.checkCameraPermission { - Picker.takePhotoModern( - activity = this@PreviewActivity, - launcher = mediaLaunchers.modernCameraLauncher - ) - } - } - } - AddMediaType.FILES -> Picker.pickFiles(mediaLaunchers.filePickerLauncher) - AddMediaType.GALLERY -> onClick(mBinding.btAddMore) - } - } - modalBottomSheet.show(supportFragmentManager, ContentPickerFragment.TAG) - } - } - - override fun onResume() { - super.onResume() - - showFirstTimeBatch() - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_preview, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_upload -> { - uploadMedia() - return true - } - } - - return super.onOptionsItemSelected(item) - } - - override fun onClick(view: View?) { - when (view) { - mBinding.btAddMore -> { - permissionManager.checkMediaPermissions { - Picker.pickMedia(mediaLaunchers.galleryLauncher) - } - } - - mBinding.btBatchEdit -> { - val selected = mMedia.filter { it.selected } - - if (selected.size == 1) { - mLauncher.launch(ReviewActivity.getIntent(this, mMedia, selected.first())) - } else if (selected.size > 1) { - mLauncher.launch( - ReviewActivity.getIntent( - this, - mMedia.filter { it.selected }, - batchMode = true - ) - ) - } - } - - mBinding.btSelectAll -> { - val select = mMedia.firstOrNull { !it.selected } != null - - mMedia.forEach { - if (it.selected != select) { - it.selected = select - - mAdapter?.notifyItemChanged(mMedia.indexOf(it)) - } - } - - mediaSelectionChanged() - } - - mBinding.btRemove -> { - mMedia.forEach { - if (it.selected) { - it.delete() - } - } - - refresh() - } - } - } - - override fun mediaClicked(media: Media) { - mLauncher.launch(ReviewActivity.getIntent(this, mMedia, media)) - } - - override fun mediaSelectionChanged() { - val selectedCount = mMedia.count { it.selected } - val hasSelection = selectedCount > 0 - val totalCount = mMedia.size - - if (hasSelection) { - mBinding.btAddMore.hide() - mBinding.bottomBar.show() - } else { - mBinding.btAddMore.toggle(mProject != null) - mBinding.bottomBar.hide() - } - - val shouldShowDeselectAll = totalCount > 1 && selectedCount == totalCount - val selectButtonText = if (shouldShowDeselectAll) { - R.string.deselect_all - } else { - R.string.select_all - } - mBinding.btSelectAll.setText(selectButtonText) - - requestRecyclerBottomPaddingUpdate() - } - - private fun refresh() { - val media = Media.getByStatus(listOf(Media.Status.Local), Media.ORDER_CREATED) - media.forEach { - if (it.selected) { - it.selected = false - it.save() - } - } - mMedia = media - - // Automatically navigate back if all media items have been removed - if (media.isEmpty()) { - finish() - } - } - - private fun showFirstTimeBatch() { - if (Prefs.batchHintShown) return - - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - icon = R.drawable.ic_media_new.asUiImage() - iconColor = dialogManager.requireResourceProvider().getColor(R.color.colorTertiary) - title = R.string.edit_multiple.asUiText() - message = R.string.press_and_hold_to_select_and_edit_multiple_media.asUiText() - positiveButton { - text = UiText.StringResource(R.string.lbl_got_it) - action = { - dialogManager.dismissDialog() - } - } - } - - - - Prefs.batchHintShown = true - } - - private fun uploadMedia() { - val queue = { - mMedia.forEach { - it.sStatus = Media.Status.Queued - it.selected = false - it.save() - } - - finish() - } - - if (Prefs.dontShowUploadHint) { - - queue() - - } else { - - var doNotShowAgain = false - - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - iconColor = dialogManager.requireResourceProvider().getColor(R.color.colorTertiary) - message = R.string.once_uploaded_you_will_not_be_able_to_edit_media.asUiText() - showCheckbox = true - checkboxText = UiText.StringResource(R.string.do_not_show_me_this_again) - onCheckboxChanged = { isChecked -> - doNotShowAgain = isChecked - } - positiveButton { - text = UiText.DynamicString("Proceed to upload") - action = { - Prefs.dontShowUploadHint = doNotShowAgain - queue() - } - } - neutralButton { - text = UiText.DynamicString("Actually, let me edit") - } - } - } - } - - private fun requestRecyclerBottomPaddingUpdate() { - mBinding.mediaGrid.post { - updateRecyclerBottomPadding() - } - } - - private fun updateRecyclerBottomPadding() { - val overlayHeight = max( - visibleHeightWithBottomMargin(mBinding.btAddMoreLayout), - visibleHeightWithBottomMargin(mBinding.bottomBar) - ) - - val targetBottomPadding = initialMediaGridBottomPadding + max(navigationBarInset, overlayHeight) - - if (mBinding.mediaGrid.paddingBottom != targetBottomPadding) { - mBinding.mediaGrid.updatePadding(bottom = targetBottomPadding) - } - } - - private fun visibleHeightWithBottomMargin(view: View): Int { - if (!view.isVisible || view.height == 0) return 0 - val lp = view.layoutParams as? MarginLayoutParams - - return view.height + (lp?.bottomMargin ?: 0) - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewAdapter.kt deleted file mode 100644 index 2768c210b..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewAdapter.kt +++ /dev/null @@ -1,102 +0,0 @@ -package net.opendasharchive.openarchive.features.media - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import net.opendasharchive.openarchive.databinding.RvMediaBoxBinding -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.features.media.adapter.PreviewViewHolder -import java.lang.ref.WeakReference - -class PreviewAdapter(listener: Listener? = null): ListAdapter(DIFF_CALLBACK) { - - interface Listener { - - fun mediaClicked(media: Media) - - fun mediaSelectionChanged() - } - - companion object { - private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Media, newItem: Media): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: Media, newItem: Media): Boolean { - return oldItem.originalFilePath == newItem.originalFilePath - && oldItem.mimeType == newItem.mimeType - && oldItem.createDate == newItem.createDate - && oldItem.updateDate == newItem.updateDate - && oldItem.uploadDate == newItem.uploadDate - && oldItem.serverUrl == newItem.serverUrl - && oldItem.title == newItem.title - && oldItem.description == newItem.description - && oldItem.author == newItem.author - && oldItem.location == newItem.location - && oldItem.tags == newItem.tags - && oldItem.licenseUrl == newItem.licenseUrl - && oldItem.mediaHash.contentEquals(newItem.mediaHash) - && oldItem.mediaHashString == newItem.mediaHashString - && oldItem.status == newItem.status - && oldItem.statusMessage == newItem.statusMessage - && oldItem.projectId == newItem.projectId - && oldItem.collectionId == newItem.collectionId - && oldItem.contentLength == newItem.contentLength - && oldItem.progress == newItem.progress - && oldItem.flag == newItem.flag - && oldItem.priority == newItem.priority - && oldItem.selected == newItem.selected - } - } - } - - private val mListener = WeakReference(listener) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder { - val binding = RvMediaBoxBinding.inflate(LayoutInflater.from(parent.context), parent, false) - val mvh = PreviewViewHolder(binding) - - mvh.itemView.setOnClickListener { view -> - val media = getMedia(view) ?: return@setOnClickListener - - if (currentList.firstOrNull { it.selected } != null) { - toggleSelection(media) - - return@setOnClickListener - } - - mListener.get()?.mediaClicked(media) - } - - mvh.itemView.setOnLongClickListener { view -> - val media = getMedia(view) ?: return@setOnLongClickListener false - - toggleSelection(media) - - return@setOnLongClickListener true - } - - return mvh - } - - override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) { - holder.bind(getItem(position), batchMode = true, doImageFade = false) - } - - private fun getMedia(view: View): Media? { - val id = view.tag as? Long ?: return null - - return currentList.firstOrNull { it.id == id } - } - - private fun toggleSelection(media: Media) { - media.selected = !media.selected - - notifyItemChanged(currentList.indexOf(media)) - - mListener.get()?.mediaSelectionChanged() - } -} \ No newline at end of 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 new file mode 100644 index 000000000..d812521eb --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewMediaScreen.kt @@ -0,0 +1,503 @@ +package net.opendasharchive.openarchive.features.media + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.navigation.NavigationResultKeys +import net.opendasharchive.openarchive.core.navigation.ResultEffect +import net.opendasharchive.openarchive.core.presentation.media.MediaStatusOverlay +import net.opendasharchive.openarchive.core.presentation.media.MediaThumbnail +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.features.main.ui.CameraCaptureResult +import org.koin.compose.koinInject + +@Composable +fun PreviewMediaScreen( + viewModel: PreviewMediaViewModel, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val projectRepository: ProjectRepository = koinInject() + val mediaRepository: MediaRepository = koinInject() + + val pickerLaunchers = rememberContentPickerLaunchers( + navigator = viewModel.getNavigator(), + projectProvider = { + state.selectedProject + }, + onError = { error -> + AppLogger.e("Error in PreviewMediaScreen: $error") + + }, + onMediaImported = { mediaList -> + AppLogger.i("Media imported: ${mediaList.size}") + viewModel.onAction(PreviewMediaAction.Refresh) + } + ) + + LaunchedEffect(Unit) { + viewModel.uiEvent.collectLatest { event -> + when (event) { + + is PreviewMediaEvent.LaunchPicker -> { + pickerLaunchers.launch(event.type) + } + + } + } + } + + // Intercept camera capture results so HomeScreen doesn't receive them and navigate to a new PreviewMediaScreen + ResultEffect(resultKey = NavigationResultKeys.CAMERA_CAPTURE_RESULT) { result -> + scope.launch(Dispatchers.IO) { + val archive = projectRepository.getProject(result.projectId) + if (archive != null && result.capturedUris.isNotEmpty()) { + val submission = projectRepository.getActiveSubmission(archive.id) + val evidenceList = MediaPicker.import( + context, + archive, + submission.id, + result.capturedUris, + ) + evidenceList.forEach { evidence -> + mediaRepository.addEvidence(evidence) + } + } + } + } + + PreviewMediaContent( + state = state, + onAction = viewModel::onAction, + ) + +} + +@Composable +private fun PreviewMediaContent( + state: PreviewMediaState, + onAction: (PreviewMediaAction) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + val bottomBarHeight = 84.dp + + LazyVerticalGrid( + modifier = Modifier + .fillMaxSize(), + columns = GridCells.Fixed(2), + verticalArrangement = Arrangement.spacedBy(0.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues( + start = 0.dp, + end = 0.dp, + top = 0.dp, + bottom = bottomBarHeight + WindowInsets.navigationBars + .only(WindowInsetsSides.Bottom) + .asPaddingValues() + .calculateBottomPadding() + ) + ) { + items(state.mediaList, key = { it.id }) { media -> + MediaListItem( + media = media, + isInSelectionMode = state.isInSelectionMode, + isSelected = state.selectedIds.contains(media.id), + onClick = { onAction(PreviewMediaAction.MediaClicked(media.id)) }, + onLongPress = { onAction(PreviewMediaAction.MediaLongPressed(media.id)) } + ) + } + } + + if (!state.isInSelectionMode && state.showAddMore) { + AddMoreBar( + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)), + onAddMore = { onAction(PreviewMediaAction.AddMore) }, + onAddMenu = { + onAction(PreviewMediaAction.ShowAddMenu) + } + ) + } + + if (state.isInSelectionMode) { + SelectionBar( + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)), + selectionCount = state.selectionCount, + totalCount = state.mediaList.size, + onBatchEdit = { onAction(PreviewMediaAction.BatchEdit) }, + onToggleSelectAll = { onAction(PreviewMediaAction.ToggleSelectAll) }, + onDelete = { onAction(PreviewMediaAction.RemoveSelected) } + ) + } + + } + + if (state.showContentPicker) { + + ContentPickerSheet( + onDismiss = { + onAction(PreviewMediaAction.ContentPickerDismissed) + }, + onMediaTypeSelected = { type -> + onAction(PreviewMediaAction.ContentPickerPicked(type)) + } + ) + } +} + +@PreviewLightDark +@Composable +private fun PreviewMediaContentPreview() { + val sampleMedia = listOf( + Evidence(id = 1, originalFilePath = "", mimeType = "image/jpeg", title = "Image 1"), + Evidence(id = 2, originalFilePath = "", mimeType = "video/mp4", title = "Video 1"), + Evidence(id = 3, originalFilePath = "", mimeType = "application/pdf", title = "Doc 1"), + Evidence(id = 4, originalFilePath = "", mimeType = "audio/mp3", title = "Audio 1") + ) + SaveAppTheme { + PreviewMediaContent( + state = PreviewMediaState( + mediaList = sampleMedia, + selectionCount = 0, + showAddMore = true, + selectedIds = emptySet() + ), + onAction = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun PreviewMediaContentSelectionPreview() { + val sampleMedia = listOf( + Evidence(id = 1, originalFilePath = "", mimeType = "image/jpeg", title = "Image 1"), + Evidence(id = 2, originalFilePath = "", mimeType = "video/mp4", title = "Video 1"), + Evidence(id = 3, originalFilePath = "", mimeType = "application/pdf", title = "Doc 1"), + Evidence(id = 4, originalFilePath = "", mimeType = "audio/mp3", title = "Audio 1") + ) + SaveAppTheme { + PreviewMediaContent( + state = PreviewMediaState( + mediaList = sampleMedia, + selectionCount = 2, + showAddMore = true, + selectedIds = setOf(1, 2) + ), + onAction = {}, + ) + } +} + +@Composable +private fun AddMoreBar( + modifier: Modifier = Modifier, + onAddMore: () -> Unit, + onAddMenu: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + + AddMoreButton( + onClick = onAddMore, + onLongClick = onAddMenu + ) + } +} + +@Composable +private fun AddMoreButton( + onClick: () -> Unit, + onLongClick: () -> Unit +) { + Box( + modifier = Modifier + .heightIn(min = ThemeDimensions.touchable) + .background( + color = MaterialTheme.colorScheme.tertiary, + shape = RoundedCornerShape(8.dp) // or a fixed dp + ) + .border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.onTertiary.copy(alpha = 0.2f), + shape = RoundedCornerShape(8.dp) + ) + .combinedClickable( + role = Role.Button, + onClick = onClick, + onLongClick = onLongClick + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.baseline_add_24), + contentDescription = null, + tint = colorResource(R.color.black), + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = stringResource(R.string.add_more), + style = MaterialTheme.typography.titleLarge, + color = colorResource(R.color.black) + ) + } + } +} + +@Composable +private fun SelectionBar( + modifier: Modifier = Modifier, + selectionCount: Int, + totalCount: Int, + onBatchEdit: () -> Unit, + onToggleSelectAll: () -> Unit, + onDelete: () -> Unit +) { + val selectAllText = if (totalCount > 1 && selectionCount == totalCount) { + stringResource(R.string.deselect_all) + } else { + stringResource(R.string.select_all) + } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + SelectionButton( + iconRes = R.drawable.ic_batchedit, + contentDescription = stringResource(R.string.edit_multiple), + onClick = onBatchEdit + ) + + SelectionTextButton( + text = selectAllText, + onClick = onToggleSelectAll + ) + + SelectionButton( + iconRes = R.drawable.ic_delete, + contentDescription = stringResource(R.string.menu_delete), + onClick = onDelete + ) + } +} + +@Composable +private fun SelectionButton( + iconRes: Int, + contentDescription: String, + onClick: () -> Unit +) { + val horizontalPadding = dimensionResource(R.dimen.selection_button_icon_padding_horizontal) + val verticalPadding = dimensionResource(R.dimen.selection_button_padding_vertical) + Button( + onClick = onClick, + modifier = Modifier, + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.selection_button_glass), + contentColor = colorResource(R.color.colorTertiary) + ), + shape = RoundedCornerShape(50), + border = BorderStroke( + width = dimensionResource(R.dimen.selection_button_stroke_width), + color = colorResource(R.color.selection_button_stroke) + ), + contentPadding = PaddingValues( + horizontal = verticalPadding, + vertical = verticalPadding + ) + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = iconRes), + contentDescription = contentDescription, + tint = colorResource(R.color.colorTertiary) + ) + } +} + +@Composable +private fun SelectionTextButton( + text: String, + onClick: () -> Unit +) { + val horizontalPadding = dimensionResource(R.dimen.selection_button_text_padding_horizontal) + val verticalPadding = dimensionResource(R.dimen.selection_button_padding_vertical) + Button( + onClick = onClick, + modifier = Modifier + .heightIn(min = ThemeDimensions.touchable) + .padding(horizontal = 4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.selection_button_glass), + contentColor = colorResource(R.color.colorTertiary) + ), + shape = RoundedCornerShape(dimensionResource(R.dimen.selection_button_corner_radius)), + border = BorderStroke( + width = dimensionResource(R.dimen.selection_button_stroke_width), + color = colorResource(R.color.selection_button_stroke) + ), + contentPadding = PaddingValues( + horizontal = horizontalPadding, + vertical = verticalPadding + ) + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold + ) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MediaListItem( + media: Evidence, + isInSelectionMode: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + onLongPress: () -> Unit +) { + var showTitle by remember(media.id) { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(3.dp) + .background(MaterialTheme.colorScheme.background) + .border( + width = if (isInSelectionMode && isSelected) 2.dp else 0.dp, + color = if (isInSelectionMode && isSelected) colorResource(R.color.c23_teal) else Color.Transparent, + shape = RoundedCornerShape(4.dp) + ) + .background( + color = if (isInSelectionMode && isSelected) Color(0x4D00B4A6) else Color.Transparent, + shape = RoundedCornerShape(4.dp) + ) + .pointerInput(isInSelectionMode, isSelected) { + detectTapGestures( + onTap = { onClick() }, + onLongPress = { onLongPress() } + ) + }, + contentAlignment = Alignment.Center + ) { + MediaThumbnail( + evidence = media, + isSelected = isInSelectionMode && isSelected, + alpha = if (isInSelectionMode && isSelected) 0.5f else 1f, + placeholderPadding = 24.dp, + pdfMaxDimensionPx = 512, + showStatusOverlay = false, + onTitleVisibilityChanged = { showTitle = it } + ) + + if (showTitle && media.title.isNotEmpty()) { + Text( + text = media.title, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(8.dp), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = MontserratFontFamily, + color = MaterialTheme.colorScheme.onSurface + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + MediaStatusOverlay( + evidence = media, + showProgressText = true, + backgroundColor = colorResource(R.color.transparent_loading_overlay), + progressIndicatorSize = 42, + showQueuedState = true, + showUploadingState = true + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewMediaViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewMediaViewModel.kt new file mode 100644 index 000000000..5d8007e68 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewMediaViewModel.kt @@ -0,0 +1,289 @@ +package net.opendasharchive.openarchive.features.media + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.features.core.UiColor +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.asUiImage +import net.opendasharchive.openarchive.features.core.asUiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.util.Prefs +import net.opendasharchive.openarchive.upload.UploadJobScheduler + +data class PreviewMediaState( + val mediaList: List = emptyList(), + val isLoading: Boolean = true, + val selectionCount: Int = 0, + val showAddMore: Boolean = false, + val selectedIds: Set = emptySet(), + val showContentPicker: Boolean = false, + val selectedProjectId: Long? = null, + val selectedProject: Archive? = null +) { + val isInSelectionMode: Boolean + get() = selectedIds.isNotEmpty() +} + +sealed class PreviewMediaAction { + data class MediaClicked(val mediaId: Long) : PreviewMediaAction() + data class MediaLongPressed(val mediaId: Long) : PreviewMediaAction() + data object ToggleSelectAll : PreviewMediaAction() + data object RemoveSelected : PreviewMediaAction() + data object UploadAll : PreviewMediaAction() + data object BatchEdit : PreviewMediaAction() + data object AddMore : PreviewMediaAction() + data object ShowAddMenu : PreviewMediaAction() + data object Refresh : PreviewMediaAction() + data object ContentPickerDismissed : PreviewMediaAction() + data class ContentPickerPicked(val type: AddMediaType) : PreviewMediaAction() +} + +sealed class PreviewMediaEvent { + data class LaunchPicker(val type: AddMediaType) : PreviewMediaEvent() +} + +class PreviewMediaViewModel( + private val route: AppRoute.PreviewMediaRoute, + private val navigator: Navigator, + private val dialogManager: DialogStateManager, + private val projectRepository: ProjectRepository, + private val mediaRepository: MediaRepository, + private val uploadJobScheduler: UploadJobScheduler +) : ViewModel() { + + private val _uiState = MutableStateFlow(PreviewMediaState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Expose navigator for content picker launchers + internal fun getNavigator(): Navigator = navigator + + private val _uiEvent = Channel(Channel.BUFFERED) + val uiEvent = _uiEvent.receiveAsFlow() + + init { + observeData() + } + + fun onAction(action: PreviewMediaAction) { + when (action) { + is PreviewMediaAction.MediaClicked -> handleMediaClicked(action.mediaId) + is PreviewMediaAction.MediaLongPressed -> toggleSelection(action.mediaId) + PreviewMediaAction.ToggleSelectAll -> handleToggleSelectAll() + PreviewMediaAction.RemoveSelected -> handleRemoveSelected() + PreviewMediaAction.UploadAll -> invokeUpload() + PreviewMediaAction.BatchEdit -> handleBatchEdit() + PreviewMediaAction.AddMore -> emitEvent(PreviewMediaEvent.LaunchPicker(AddMediaType.GALLERY)) + PreviewMediaAction.ShowAddMenu -> _uiState.update { it.copy(showContentPicker = true) } + PreviewMediaAction.Refresh -> { /* Flow handles this automatically */ } + PreviewMediaAction.ContentPickerDismissed -> _uiState.update { it.copy(showContentPicker = false) } + is PreviewMediaAction.ContentPickerPicked -> { + _uiState.update { it.copy(showContentPicker = false) } + emitEvent(PreviewMediaEvent.LaunchPicker(action.type)) + } + } + } + + private fun observeData() { + val projectId = route.projectId + + combine( + projectRepository.observeProject(projectId), + mediaRepository.observeLocalMedia(), + _uiState.map { it.selectedIds }.distinctUntilChanged() + ) { project, localMedia, selectedIds -> + + _uiState.update { state -> + state.copy( + mediaList = localMedia.map { it.copy(isSelected = selectedIds.contains(it.id)) }, + isLoading = false, + selectionCount = selectedIds.size, + showAddMore = project != null, + selectedProjectId = projectId, + selectedProject = project + ) + } + + // Legacy-like behavior: auto-finish if we previously had media but now it's empty + if (localMedia.isEmpty() && !_uiState.value.isLoading) { + navigator.navigateBack() + } + + // Initial cleanup of lingering selections in the DB (kept for safety) + if (localMedia.any { it.isSelected }) { + viewModelScope.launch { + localMedia.filter { it.isSelected }.forEach { + mediaRepository.setSelected(it.id, false) + } + } + } + }.launchIn(viewModelScope) + + showFirstTimeBatch() + } + + private fun handleMediaClicked(mediaId: Long) { + val currentState = _uiState.value + val media = currentState.mediaList.firstOrNull { it.id == mediaId } ?: return + + if (currentState.isInSelectionMode) { + toggleSelection(media.id) + } else { + + viewModelScope.launch { + navigator.navigateTo( + AppRoute.ReviewMediaRoute( + mediaIds = currentState.mediaList.mapNotNull { it.id }.toLongArray(), + selectedIdx = currentState.mediaList.indexOf(media), + batchMode = false + ) + ) + } + } + } + + private fun toggleSelection(mediaId: Long?) { + if (mediaId == null) return + val updatedSelected = _uiState.value.selectedIds.toMutableSet().apply { + if (contains(mediaId)) remove(mediaId) else add(mediaId) + } + val selectionCount = updatedSelected.size + + _uiState.update { + it.copy( + mediaList = it.mediaList.toList(), + selectionCount = selectionCount, + selectedIds = updatedSelected + ) + } + } + + private fun handleToggleSelectAll() { + val current = _uiState.value + val allIds = current.mediaList.mapNotNull { it.id }.toSet() + val shouldSelectAll = current.selectedIds.size != allIds.size + + _uiState.update { + val newSelection = if (shouldSelectAll) allIds else emptySet() + it.copy( + mediaList = it.mediaList.toList(), + selectionCount = newSelection.size, + selectedIds = newSelection + ) + } + } + + private fun handleBatchEdit() { + val current = _uiState.value + val selected = current.mediaList.filter { current.selectedIds.contains(it.id) } + + if (selected.isEmpty()) return + + viewModelScope.launch { + val batchMode = selected.size > 1 + val mediaForReview = if (batchMode) selected else current.mediaList + val selectedMedia = if (batchMode) null else selected.firstOrNull() + + navigator.navigateTo( + AppRoute.ReviewMediaRoute( + mediaIds = mediaForReview.mapNotNull { it.id }.toLongArray(), + selectedIdx = mediaForReview.indexOf(selectedMedia), + batchMode = batchMode + ) + ) + } + } + + private fun handleRemoveSelected() { + viewModelScope.launch { + val selectedIds = _uiState.value.selectedIds.toList() + _uiState.update { it.copy(selectedIds = emptySet()) } // Clear selection immediately + selectedIds.forEach { id -> + mediaRepository.deleteMedia(id) + } + } + } + + private fun invokeUpload() { + + if (Prefs.dontShowUploadHint) { + handleUploadAll() + } else { + var doNotShowAgain = false + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Warning + iconColor = UiColor.Resource(R.color.colorTertiary) + message = R.string.once_uploaded_you_will_not_be_able_to_edit_media.asUiText() + showCheckbox = true + checkboxText = UiText.Resource(R.string.do_not_show_me_this_again) + onCheckboxChanged = { isChecked -> + doNotShowAgain = isChecked + } + positiveButton { + text = UiText.Resource(R.string.proceed_to_upload) + action = { + Prefs.dontShowUploadHint = doNotShowAgain + handleUploadAll() + } + } + neutralButton { + text = UiText.Resource(R.string.actually_let_me_edit) + } + } + } + } + + private fun handleUploadAll() { + viewModelScope.launch { + val mediaIds = _uiState.value.mediaList.map { it.id } + mediaRepository.queueAllForUpload(mediaIds) + uploadJobScheduler.schedule() + navigator.navigateAndClear(AppRoute.HomeRoute) + } + } + + private fun emitEvent(event: PreviewMediaEvent) { + viewModelScope.launch { _uiEvent.send(event) } + } + + private fun showFirstTimeBatch() { + if (Prefs.batchHintShown) return + + dialogManager.showDialog { + icon = R.drawable.ic_media_new.asUiImage() + iconColor = UiColor.Resource(R.color.colorTertiary) + title = R.string.edit_multiple.asUiText() + message = R.string.press_and_hold_to_select_and_edit_multiple_media.asUiText() + onDismissAction { + Prefs.batchHintShown = true + } + positiveButton { + text = UiText.Resource(R.string.lbl_got_it) + action = { + Prefs.batchHintShown = true + } + } + + } + + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewActivity.kt deleted file mode 100644 index 3c029dc84..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewActivity.kt +++ /dev/null @@ -1,474 +0,0 @@ -package net.opendasharchive.openarchive.features.media - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.ImageView -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.colorResource -import androidx.core.net.toUri -import androidx.exifinterface.media.ExifInterface -import coil3.ImageLoader -import coil3.load -import coil3.request.error -import coil3.video.VideoFrameDecoder -import coil3.video.videoFrameMillis -import java.io.File -import java.io.IOException -import java.text.NumberFormat -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityReviewBinding -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.UiImage -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.util.PdfThumbnailLoader -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets -import net.opendasharchive.openarchive.util.extensions.hide -import net.opendasharchive.openarchive.util.extensions.show -import net.opendasharchive.openarchive.util.extensions.toggle - -class ReviewActivity : BaseActivity(), View.OnClickListener { - - companion object { - private const val EXTRA_CURRENT_MEDIA_ID = "archive_extra_current_media_id" - private const val EXTRA_SELECTED_IDX = "selected_idx" - private const val EXTRA_BATCH_MODE = "batch_mode" - - fun getIntent(context: Context, media: List, selected: Media? = null, batchMode: Boolean = false): Intent { - val i = Intent(context, ReviewActivity::class.java) - i.putExtra(EXTRA_CURRENT_MEDIA_ID, media.map { it.id }.toLongArray()) - - if (selected != null) { - i.putExtra(EXTRA_SELECTED_IDX, media.indexOf(selected)) - } - - i.putExtra(EXTRA_BATCH_MODE, batchMode) - - return i - } - } - - private lateinit var mBinding: ActivityReviewBinding - - private var mStore = emptyList() - - private var mIndex = 0 - - private var mBatchMode = false - private val pdfScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val pdfThumbnailJobs = mutableMapOf() - private val videoImageLoader by lazy { - ImageLoader.Builder(this) - .components { add(VideoFrameDecoder.Factory()) } - .build() - } - - private val mMedia - get() = mStore.getOrNull(mIndex) - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivityReviewBinding.inflate(layoutInflater) - - mBinding.descriptionContainer.applyEdgeToEdgeInsets { insets -> - bottomMargin = insets.bottom - } - setContentView(mBinding.root) - - mBatchMode = intent.getBooleanExtra(EXTRA_BATCH_MODE, false) - - setupToolbar( - title = if (mBatchMode) "Bulk Edit Media Info" else getString(R.string.edit_media_info), - showBackButton = true - ) - - mStore = intent.getLongArrayExtra(EXTRA_CURRENT_MEDIA_ID) - ?.map { Media.get(it) }?.filterNotNull() ?: emptyList() - - mIndex = savedInstanceState?.getInt(EXTRA_SELECTED_IDX) ?: intent.getIntExtra(EXTRA_SELECTED_IDX, 0) - - - - mBinding.btFlag.setOnClickListener(this) - - mBinding.image.setOnClickListener(this) - - mBinding.btPageBack.setOnClickListener { - mIndex = max(0, mIndex - 1) - refresh() - } - - mBinding.btPageFrwd.setOnClickListener { - mIndex = min(mIndex + 1, mStore.size - 1) - refresh() - } - - mBinding.description.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { } - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { } - - override fun afterTextChanged(s: Editable?) { - val value = s?.toString() ?: "" - - if (mBatchMode) { - mStore.forEach { - it.description = value - } - } - else { - mMedia?.description = value - } - - save() - } - }) - - mBinding.location.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { } - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { } - - override fun afterTextChanged(s: Editable?) { - val value = s?.toString() ?: "" - - if (mBatchMode) { - mStore.forEach { - it.location = value - } - } - else { - mMedia?.location = value - } - - save() - } - }) - - refresh() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - - outState.putInt(EXTRA_SELECTED_IDX, mIndex) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_review, menu) - - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - // "Done" button is only there for user convenience. - // No difference to back, actually. We store everything - // right away. - android.R.id.home, R.id.menu_done -> { - finish() - - return true - } - } - - return super.onOptionsItemSelected(item) - } - - override fun onClick(view: View?) { - when (view) { - mBinding.btFlag -> { - showFirstTimeFlag() - - val isFlagged = !this.isFlagged - - if (mBatchMode) { - mStore.forEach { it.flag = isFlagged } - } - else { - mMedia?.flag = isFlagged - } - - save() - - updateFlagState() - } - } - } - - private fun refresh() { - if (mBatchMode) { - mBinding.batchContainer.show() - mBinding.singleContainer.hide() - - mBinding.counter.text = NumberFormat.getIntegerInstance().format(mStore.size) - - for (i in 0..2) { - val media = mStore.getOrNull(i) - - val iv = when (i) { - 0 -> mBinding.batchImg3 - 1 -> mBinding.batchImg2 - else -> mBinding.batchImg1 - } - - if (media == null) { - iv.hide() - } - else { - load(media, iv) - } - } - } - else { - mBinding.batchContainer.hide() - mBinding.singleContainer.show() - - mBinding.counter.text = getString(R.string.counter, mIndex + 1, mStore.size) - - load(mMedia, mBinding.image) - } - - updateFlagState() - - mBinding.btPageBack.toggle( !mBatchMode && mIndex > 0) - mBinding.btPageFrwd.toggle(!mBatchMode && mIndex < mStore.size - 1) - - if (mBatchMode) { - var description = mMedia?.description - var location = mMedia?.location - - // If all descriptions/locations are the same, prefill the TextView - // with that. - for (media in mStore) { - if (media.description != description) { - description = null - } - if (media.location != location) { - location = null - } - - if ((description == null) && (location == null)) { - break - } - } - - mBinding.description.setText(description) - mBinding.location.setText(location) - } - else { - mBinding.description.setText(mMedia?.description) - - // Try to populate location from EXIF if not already set - val currentLocation = mMedia?.location - val locationToDisplay = if (currentLocation.isNullOrEmpty()) { - extractLocationFromExif(mMedia) ?: "" - } else { - currentLocation - } - - mBinding.location.setText(locationToDisplay) - } - } - - private fun updateFlagState() { - if (isFlagged) { - mBinding.btFlag.setIconResource(R.drawable.ic_flag_selected) - mBinding.btFlag.contentDescription = getText(R.string.status_flagged) - } - else { - mBinding.btFlag.setIconResource(R.drawable.ic_flag_unselected) - mBinding.btFlag.contentDescription = getText(R.string.hint_flag) - } - } - - private val isFlagged: Boolean - get() { - var flagged = mMedia?.flag ?: false - - if (mBatchMode && flagged) { - // Only show flagged, if all are flagged. - if (mStore.firstOrNull { !it.flag } != null) { - flagged = false - } - } - - return flagged - } - - private fun showFirstTimeFlag() { - if (Prefs.flagHintShown) return - - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - title = UiText.StringResource(R.string.popup_flag_title) - message = UiText.StringResource(R.string.popup_flag_desc) - icon = UiImage.DrawableResource(R.drawable.ic_flag_selected) - iconColor = Color(getColor(R.color.orange_light)) - positiveButton { - text = UiText.StringResource(R.string.lbl_got_it) - action = { - dialogManager.dismissDialog() - } - } - } - - Prefs.flagHintShown = true - } - - private fun save() { - for (media in mStore) { - media.licenseUrl = media.project?.licenseUrl ?: media.space?.license - - if (media.sStatus == Media.Status.New) media.sStatus = Media.Status.Local - - media.save() - } - } - - private fun load(media: Media?, imageView: ImageView) { - imageView.show() - clearPdfJob(imageView) - - if (media?.mimeType?.startsWith("image") == true) { - val fileExists = try { - media.fileUri.path?.let { path -> - File(path).exists() - } ?: false - } catch (e: Exception) { - false - } - - if (fileExists) { - imageView.load(media.fileUri) { - error(R.drawable.ic_image) - } - } else { - imageView.setImageResource(R.drawable.ic_image) - } - } - else if (media?.mimeType?.startsWith("video") == true) { - val videoUri = when { - !media.originalFilePath.isNullOrBlank() -> media.originalFilePath.toUri() - else -> media.fileUri - } - - imageView.setImageResource(R.drawable.ic_video) - imageView.load(videoUri, videoImageLoader) { - videoFrameMillis(1000) // Use a representative frame - error(R.drawable.ic_video) - listener(onError = { _, _ -> - imageView.setImageResource(R.drawable.ic_video) - }) - } - } - else if (media?.mimeType?.startsWith("audio") == true) { - imageView.setImageResource(R.drawable.ic_music) - } - else if (media?.mimeType == "application/pdf") { - loadPdfPreview(media, imageView) - } - else { - imageView.setImageResource(R.drawable.no_thumbnail) - } - } - - private fun clearPdfJob(imageView: ImageView) { - pdfThumbnailJobs.remove(imageView)?.cancel() - } - - private fun loadPdfPreview(media: Media?, imageView: ImageView) { - imageView.scaleType = ImageView.ScaleType.CENTER_CROP - imageView.setPadding(0, 0, 0, 0) - imageView.imageTintList = null - imageView.setImageResource(R.drawable.ic_pdf) - - if (media == null) return - - pdfThumbnailJobs[imageView] = PdfThumbnailLoader.loadThumbnail( - imageView = imageView, - uri = media.fileUri, - placeholderRes = R.drawable.ic_pdf, - scope = pdfScope, - context = this, - maxDimensionPx = 1200 - ) - } - - override fun onDestroy() { - super.onDestroy() - pdfScope.cancel() - pdfThumbnailJobs.values.forEach { it.cancel() } - pdfThumbnailJobs.clear() - } - - /** - * Extracts GPS location from image EXIF data. - * Returns formatted location string (latitude, longitude) or null if not available. - */ - private fun extractLocationFromExif(media: Media?): String? { - if (media == null || !media.mimeType.startsWith("image")) { - return null - } - - return try { - val exif: ExifInterface? = when { - // Try to open from file URI first (handles content:// URIs) - media.fileUri != null -> { - try { - contentResolver.openInputStream(media.fileUri)?.use { inputStream -> - ExifInterface(inputStream) - } - } catch (e: Exception) { - null - } - } - // Fall back to file path if available - !media.originalFilePath.isNullOrEmpty() -> { - val file = File(media.originalFilePath) - if (file.exists()) { - ExifInterface(file.absolutePath) - } else { - null - } - } - else -> null - } - - if (exif != null) { - val latLong = FloatArray(2) - if (exif.getLatLong(latLong)) { - val latitude = latLong[0].toDouble() - val longitude = latLong[1].toDouble() - - // Format as readable coordinates - String.format("%.6f, %.6f", latitude, longitude) - } else { - null - } - } else { - null - } - } catch (e: IOException) { - null - } catch (e: Exception) { - null - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewMediaScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewMediaScreen.kt new file mode 100644 index 000000000..71a20e9a6 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewMediaScreen.kt @@ -0,0 +1,497 @@ +package net.opendasharchive.openarchive.features.media + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.presentation.media.MediaThumbnail +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomTextField +import org.koin.androidx.compose.koinViewModel + +@Composable +fun ReviewMediaScreen( + viewModel: ReviewMediaViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + + } + } + + ReviewMediaContent( + state = state, + onAction = viewModel::onAction + ) +} + +@Composable +private fun ReviewMediaContent( + state: ReviewMediaState, + onAction: (ReviewMediaAction) -> Unit +) { + val screenHeight = androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp + val previewHeight = screenHeight * 0.55f + + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.background) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(previewHeight) + ) { + if (state.isBatchMode) { + BatchPreviewSection(state = state) + } else { + SinglePreviewSection(state = state) + } + + PreviewOverlayActions( + state = state, + onAction = onAction + ) + } + + MetadataSection( + state = state, + onAction = onAction, + modifier = Modifier + .fillMaxWidth() + .alpha(0.85f) + ) + } +} + +@Composable +private fun SinglePreviewSection(state: ReviewMediaState) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + MediaPreview( + media = state.currentMedia, + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +private fun BatchPreviewSection(state: ReviewMediaState) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + val batchMedia = state.batchPreviewMedia + + // First card (back) - smaller, at bottom left + batchMedia.getOrNull(0)?.let { media -> + Card( + modifier = Modifier + .padding(start = 16.dp, top = 16.dp) + .fillMaxWidth(0.8f) + .fillMaxHeight(0.7f) + .align(Alignment.TopStart), + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + MediaPreview( + media = media, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + + // Second card (middle) - offset from first card + batchMedia.getOrNull(1)?.let { media -> + Card( + modifier = Modifier + .padding(start = 48.dp, top = 48.dp) + .fillMaxWidth(0.85f) + .fillMaxHeight(0.85f) + .align(Alignment.TopStart), + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + MediaPreview( + media = media, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + + // Third card (front) - largest, offset from second card + batchMedia.getOrNull(2)?.let { media -> + Card( + modifier = Modifier + .padding(start = 80.dp, top = 80.dp, end = 24.dp) + .fillMaxWidth() + .fillMaxWidth(0.95f) + .align(Alignment.TopStart), + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + MediaPreview( + media = media, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + } +} + +@Composable +private fun MediaPreview( + media: Evidence?, + modifier: Modifier = Modifier, + background: Color = MaterialTheme.colorScheme.background, + contentScale: ContentScale = ContentScale.Fit +) { + Box( + modifier = modifier + .background(background), + contentAlignment = Alignment.Center + ) { + if (media == null) { + Icon( + painter = painterResource(R.drawable.no_thumbnail), + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = Color.Gray + ) + } else { + MediaThumbnail( + evidence = media, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + showStatusOverlay = false + ) + } + } +} + +@Composable +private fun PreviewOverlayActions( + state: ReviewMediaState, + onAction: (ReviewMediaAction) -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + // Top gradient overlay + Box( + modifier = Modifier + .fillMaxWidth() + .height(102.dp) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.5f), + Color.Transparent + ) + ) + ) + .align(Alignment.TopCenter) + ) + + // Counter badge (top left) + Surface( + modifier = Modifier + .padding(start = 16.dp, top = 16.dp) + .align(Alignment.TopStart), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f) + ) { + Text( + text = state.counter, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold) + ) + } + + // Flag button (top right) - matching XML size + IconButton( + onClick = { onAction(ReviewMediaAction.ToggleFlag) }, + modifier = Modifier + .padding(end = 4.dp, top = 4.dp) + .size(42.dp) + .align(Alignment.TopEnd) + ) { + Icon( + painter = painterResource( + if (state.isFlagged) R.drawable.ic_flag_selected + else R.drawable.ic_flag_unselected + ), + contentDescription = stringResource( + if (state.isFlagged) R.string.status_flagged + else R.string.hint_flag + ), + tint = if (state.isFlagged) { + colorResource(R.color.orange_light) + } else { + Color.White + }, + modifier = Modifier.size(24.dp) + ) + } + + // Navigation arrows (single mode only) with semi-transparent backgrounds + AnimatedVisibility( + visible = state.showBackButton, + modifier = Modifier.align(Alignment.CenterStart), + enter = fadeIn(), + exit = fadeOut() + ) { + Surface( + modifier = Modifier.padding(start = 8.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.3f) + ) { + IconButton( + onClick = { onAction(ReviewMediaAction.NavigatePrevious) }, + modifier = Modifier.size(56.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_left_ios), + contentDescription = stringResource(R.string.previous), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(48.dp) + ) + } + } + } + + AnimatedVisibility( + visible = state.showForwardButton, + modifier = Modifier.align(Alignment.CenterEnd), + enter = fadeIn(), + exit = fadeOut() + ) { + Surface( + modifier = Modifier.padding(end = 8.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.3f) + ) { + IconButton( + onClick = { onAction(ReviewMediaAction.NavigateNext) }, + modifier = Modifier.size(56.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_right_ios), + contentDescription = stringResource(R.string.next), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(48.dp) + ) + } + } + } + } +} + +@Composable +private fun MetadataSection( + state: ReviewMediaState, + onAction: (ReviewMediaAction) -> Unit, + modifier: Modifier = Modifier +) { + val descriptionFocusRequester = remember { FocusRequester() } + + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 8.dp) + ) { + // Location field (single line) + CustomTextField( + value = state.location, + onValueChange = { onAction(ReviewMediaAction.UpdateLocation(it)) }, + placeholder = stringResource(R.string.add_a_location_optional), + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + onImeAction = { + descriptionFocusRequester.requestFocus() + }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Description field (multiline) + MultilineTextField( + value = state.description, + onValueChange = { onAction(ReviewMediaAction.UpdateDescription(it)) }, + placeholder = stringResource(R.string.add_notes_optional), + modifier = Modifier + .fillMaxWidth() + .focusRequester(descriptionFocusRequester) + ) + } +} + +@Composable +private fun MultilineTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + placeholder = { + Text( + text = placeholder, + style = MaterialTheme.typography.bodyMedium.copy( + fontStyle = FontStyle.Italic, + fontSize = 13.sp, + fontFamily = MontserratFontFamily + ) + ) + }, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.background, + unfocusedContainerColor = MaterialTheme.colorScheme.background, + focusedBorderColor = MaterialTheme.colorScheme.tertiary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + cursorColor = MaterialTheme.colorScheme.tertiary + ), + minLines = 3, + maxLines = 5 + ) +} + + +@PreviewLightDark +@Composable +private fun ReviewMediaSingleModePreview() { + DefaultScaffoldPreview { + ReviewMediaContent( + state = ReviewMediaState( + mediaList = listOf(Evidence(id = 1)), + currentIndex = 0, + isBatchMode = false, + description = "", + location = "", + isFlagged = false, + counter = "1/10", + showBackButton = true, + showForwardButton = true + ), + onAction = {} + ) + } +} + +@PreviewLightDark +@Composable +private fun ReviewMediaBatchModePreview() { + DefaultScaffoldPreview { + ReviewMediaContent( + state = ReviewMediaState( + mediaList = listOf(Evidence(id = 1), Evidence(id = 2), Evidence(id = 3)), + currentIndex = 0, + isBatchMode = true, + description = "Shared description", + location = "New York, NY", + isFlagged = true, + counter = "3", + showBackButton = false, + showForwardButton = false + ), + onAction = {} + ) + } +} + +@PreviewLightDark +@Composable +private fun ReviewMediaWithDataPreview() { + DefaultScaffoldPreview { + ReviewMediaContent( + state = ReviewMediaState( + mediaList = listOf(Evidence(id = 1)), + currentIndex = 0, + isBatchMode = false, + description = "A beautiful sunset captured at the beach during my vacation.", + location = "40.7128, -74.0060", + isFlagged = true, + counter = "5/10", + showBackButton = true, + showForwardButton = true + ), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewMediaViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewMediaViewModel.kt new file mode 100644 index 000000000..81e0e3c92 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewMediaViewModel.kt @@ -0,0 +1,410 @@ +package net.opendasharchive.openarchive.features.media + +import android.content.ContentResolver +import androidx.exifinterface.media.ExifInterface +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.core.UiColor +import net.opendasharchive.openarchive.features.core.UiImage +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.util.Prefs +import java.io.File + +data class ReviewMediaState( + val mediaList: List = emptyList(), + val currentIndex: Int = 0, + val isBatchMode: Boolean = false, + val description: String = "", + val location: String = "", + val isFlagged: Boolean = false, + val isLoading: Boolean = false, + val counter: String = "", + val showBackButton: Boolean = false, + val showForwardButton: Boolean = false +) { + val currentMedia: Evidence? + get() = mediaList.getOrNull(currentIndex) + + val batchPreviewMedia: List + get() = if (isBatchMode) mediaList.take(3) else emptyList() +} + +sealed class ReviewMediaAction { + data object NavigateBack : ReviewMediaAction() + data object NavigatePrevious : ReviewMediaAction() + data object NavigateNext : ReviewMediaAction() + data object ToggleFlag : ReviewMediaAction() + data class UpdateDescription(val value: String) : ReviewMediaAction() + data class UpdateLocation(val value: String) : ReviewMediaAction() + data object SaveAndFinish : ReviewMediaAction() +} + +sealed class ReviewMediaEvent { + +} + +class ReviewMediaViewModel( + private val route: AppRoute.ReviewMediaRoute, + private val navigator: Navigator, + private val contentResolver: ContentResolver, + private val dialogManager: DialogStateManager, + private val mediaRepository: MediaRepository, + private val projectRepository: ProjectRepository, + private val spaceRepository: SpaceRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(ReviewMediaState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + init { + loadMediaList() + } + + private fun loadMediaList() { + viewModelScope.launch { + + val mediaItems = mutableListOf() + route.mediaIds.forEach { id -> + mediaRepository.getEvidence(id)?.let { mediaItems.add(it) } + } + val mediaList = mediaItems.toList() + + if (mediaList.isEmpty()) { + navigator.navigateBack() + return@launch + } + + _uiState.update { state -> + state.copy( + mediaList = mediaList, + currentIndex = route.selectedIdx.coerceIn(0, mediaList.size - 1), + isBatchMode = route.batchMode, + isLoading = false + ) + } + + refreshUI() + } + } + + fun onAction(action: ReviewMediaAction) { + when (action) { + ReviewMediaAction.NavigateBack -> handleNavigateBack() + ReviewMediaAction.NavigatePrevious -> handleNavigatePrevious() + ReviewMediaAction.NavigateNext -> handleNavigateNext() + ReviewMediaAction.ToggleFlag -> handleToggleFlag() + is ReviewMediaAction.UpdateDescription -> handleUpdateDescription(action.value) + is ReviewMediaAction.UpdateLocation -> handleUpdateLocation(action.value) + ReviewMediaAction.SaveAndFinish -> handleSaveAndFinish() + } + } + + private fun handleNavigateBack() { + viewModelScope.launch { + saveAllMedia() + navigator.navigateBack() + } + } + + private fun handleNavigatePrevious() { + val currentIndex = _uiState.value.currentIndex + if (currentIndex > 0) { + _uiState.update { it.copy(currentIndex = currentIndex - 1) } + refreshUI() + } + } + + private fun handleNavigateNext() { + val currentIndex = _uiState.value.currentIndex + val maxIndex = _uiState.value.mediaList.size - 1 + if (currentIndex < maxIndex) { + _uiState.update { it.copy(currentIndex = currentIndex + 1) } + refreshUI() + } + } + + private fun handleToggleFlag() { + viewModelScope.launch { + val state = _uiState.value + val newFlagState = !state.isFlagged + + // Show hint only once + if (newFlagState) { + showFlagHint(newFlagState) + } + + val updatedMediaList = state.mediaList.map { evidence -> + if (state.isBatchMode || evidence.id == state.currentMedia?.id) { + evidence.copy(isFlagged = newFlagState) + } else { + evidence + } + } + + _uiState.update { it.copy(mediaList = updatedMediaList) } + saveAllMedia() + updateFlagState() + } + } + + private fun showFlagHint(flagged: Boolean) { + if (Prefs.flagHintShown) { + return + } + + dialogManager.showDialog { + title = UiText.Resource(R.string.popup_flag_title) + message = UiText.Resource(R.string.popup_flag_desc) + icon = UiImage.DrawableResource(R.drawable.ic_flag_selected) + iconColor = UiColor.Resource(R.color.orange_light) + positiveButton { + text = UiText.Resource(R.string.lbl_got_it) + } + } + Prefs.flagHintShown = true + + } + + private fun handleUpdateDescription(value: String) { + viewModelScope.launch { + + _uiState.update { state -> + val updatedMediaList = state.mediaList.map { evidence -> + if (state.isBatchMode || evidence.id == state.currentMedia?.id) { + evidence.copy(description = value) + } else { + evidence + } + } + state.copy( + description = value, + mediaList = updatedMediaList + ) + } + + saveAllMedia() + } + } + + private fun handleUpdateLocation(value: String) { + viewModelScope.launch { + + _uiState.update { state -> + val updatedMediaList = state.mediaList.map { evidence -> + if (state.isBatchMode || evidence.id == state.currentMedia?.id) { + evidence.copy(location = value) + } else { + evidence + } + } + state.copy( + location = value, + mediaList = updatedMediaList + ) + } + + saveAllMedia() + } + } + + private fun handleSaveAndFinish() { + viewModelScope.launch { + saveAllMedia() + navigator.navigateBack() + } + } + + private fun refreshUI() { + viewModelScope.launch { + val state = _uiState.value + + if (state.isBatchMode) { + refreshBatchMode() + } else { + refreshSingleMode() + } + + updateNavigationButtons() + updateFlagState() + updateCounter() + } + } + + private suspend fun refreshBatchMode() { + val state = _uiState.value + val firstMedia = state.mediaList.firstOrNull() + + // Check if all descriptions/locations are the same + var commonDescription = firstMedia?.description + var commonLocation = firstMedia?.location + + for (media in state.mediaList) { + if (media.description != commonDescription) { + commonDescription = null + } + if (media.location != commonLocation) { + commonLocation = null + } + + if (commonDescription == null && commonLocation == null) { + break + } + } + + _uiState.update { + it.copy( + description = commonDescription ?: "", + location = commonLocation ?: "" + ) + } + } + + private suspend fun refreshSingleMode() { + val state = _uiState.value + val currentMedia = state.currentMedia ?: return + + val description = currentMedia.description + val currentLocation = currentMedia.location + + val location = if (currentLocation.isNullOrEmpty()) { + extractLocationFromExif(currentMedia) ?: "" + } else { + currentLocation + } + + _uiState.update { + it.copy( + description = description, + location = location + ) + } + } + + private fun updateNavigationButtons() { + val state = _uiState.value + _uiState.update { + it.copy( + showBackButton = !state.isBatchMode && state.currentIndex > 0, + showForwardButton = !state.isBatchMode && state.currentIndex < state.mediaList.size - 1 + ) + } + } + + private fun updateFlagState() { + viewModelScope.launch { + val state = _uiState.value + + var flagged = state.currentMedia?.isFlagged ?: false + + if (state.isBatchMode && flagged) { + // Only show flagged if all are flagged + if (state.mediaList.any { !it.isFlagged }) { + flagged = false + } + } + + _uiState.update { it.copy(isFlagged = flagged) } + } + } + + private fun updateCounter() { + val state = _uiState.value + val counter = if (state.isBatchMode) { + "${state.mediaList.size}" + } else { + "${state.currentIndex + 1}/${state.mediaList.size}" + } + + _uiState.update { it.copy(counter = counter) } + } + + private suspend fun saveAllMedia() { + val mediaList = _uiState.value.mediaList + mediaList.forEach { evidence -> + var finalEvidence = evidence + if (finalEvidence.licenseUrl == null) { + val archive = projectRepository.getProject(evidence.archiveId) + val license = archive?.licenseUrl ?: archive?.vaultId?.let { + spaceRepository.getSpaceById(it)?.licenseUrl + } + finalEvidence = finalEvidence.copy(licenseUrl = license) + } + + if (finalEvidence.status == EvidenceStatus.NEW) { + finalEvidence = finalEvidence.copy(status = EvidenceStatus.LOCAL) + } + + mediaRepository.updateEvidence(finalEvidence) + } + } + + private suspend fun extractLocationFromExif(media: Evidence): String? { + if (!media.mimeType.startsWith("image")) { + return null + } + + return withContext(Dispatchers.IO) { + try { + val exif: ExifInterface? = when { + media.fileUri != null -> { + try { + contentResolver.openInputStream(media.fileUri)?.use { inputStream -> + ExifInterface(inputStream) + } + } catch (e: Exception) { + null + } + } + + !media.originalFilePath.isNullOrEmpty() -> { + val file = File(media.originalFilePath) + if (file.exists()) { + ExifInterface(file.absolutePath) + } else { + null + } + } + + else -> null + } + + if (exif != null) { + val latLong = FloatArray(2) + if (exif.getLatLong(latLong)) { + val latitude = latLong[0].toDouble() + val longitude = latLong[1].toDouble() + String.format("%.6f, %.6f", latitude, longitude) + } else { + null + } + } else { + null + } + } catch (e: Exception) { + null + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/adapter/PreviewViewHolder.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/adapter/PreviewViewHolder.kt deleted file mode 100644 index d13cdc0f9..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/adapter/PreviewViewHolder.kt +++ /dev/null @@ -1,302 +0,0 @@ -package net.opendasharchive.openarchive.features.media.adapter - -import android.annotation.SuppressLint -import android.content.res.ColorStateList -import android.widget.ImageView -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.CircularProgressDrawable -import coil3.load -import coil3.request.Disposable -import coil3.request.crossfade -import coil3.request.error -import coil3.request.placeholder -import java.io.File -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.RvMediaBoxBinding -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.util.PdfThumbnailLoader -import net.opendasharchive.openarchive.util.extensions.hide -import net.opendasharchive.openarchive.util.extensions.show - -class PreviewViewHolder(val binding: RvMediaBoxBinding) : RecyclerView.ViewHolder(binding.root) { - - private val mContext = itemView.context - private val pdfScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private var pdfThumbnailJob: Job? = null - private var imageRequest: Disposable? = null - - fun bind( - media: Media? = null, - batchMode: Boolean = false, - doImageFade: Boolean = true - ) { - - itemView.tag = media?.id - binding.image.tag = media?.id - - resetImageState() - hideTitle() - - val isSelected = batchMode && media?.selected == true - - if (isSelected) { - //itemView.setBackgroundResource(R.color.colorPrimary) - binding.selectedIndicator.show() - } else { - //itemView.setBackgroundResource(R.color.transparent) - binding.selectedIndicator.hide() - } - - binding.image.alpha = if (media?.sStatus == Media.Status.Uploaded || !doImageFade) 1f else 0.5f - - val progress = CircularProgressDrawable(mContext).apply { - strokeWidth = 5f - centerRadius = 30f - start() - } - - if (media?.mimeType?.startsWith("image") == true) { - // static images - check if file exists before attempting to load - val fileExists = try { - media.fileUri.path?.let { path -> - File(path).exists() - } ?: false - } catch (e: Exception) { - AppLogger.e(e) - false - } - - if (fileExists) { - binding.image.apply { - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - scaleType = ImageView.ScaleType.CENTER_CROP - setPadding(0, 0, 0, 0) - clearColorFilter() - show() - imageRequest = load(media.fileUri) { - placeholder(progress) - error(R.drawable.ic_image) - } - } - } else { - AppLogger.w("Image file not found: ${media.fileUri.path}") - val padding = (24 * mContext.resources.displayMetrics.density).toInt() - binding.image.apply { - scaleType = ImageView.ScaleType.FIT_CENTER - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - setPadding(padding, padding, padding, padding) - imageRequest = load(R.drawable.ic_image) { - crossfade(false) - } - applyPlaceholderTint(isSelected) - show() - } - showTitle(media?.title) - } - binding.videoIndicator.hide() - } else if (media?.mimeType?.startsWith("video") == true) { - // video thumbnail - check if file exists before attempting to load - val fileExists = try { - media.fileUri.path?.let { path -> - File(path).exists() - } ?: false - } catch (e: Exception) { - AppLogger.e(e) - false - } - - if (fileExists) { - binding.image.apply { - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - scaleType = ImageView.ScaleType.CENTER_CROP - setPadding(0, 0, 0, 0) - clearColorFilter() - show() - imageRequest = load(media.fileUri) { - placeholder(progress) - error(R.drawable.ic_video) - } - } - } else { - AppLogger.w("Video file not found: ${media.fileUri.path}") - val padding = (24 * mContext.resources.displayMetrics.density).toInt() - binding.image.apply { - scaleType = ImageView.ScaleType.FIT_CENTER - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - setPadding(padding, padding, padding, padding) - imageRequest = load(R.drawable.ic_video) { - crossfade(false) - } - applyPlaceholderTint(isSelected) - show() - } - showTitle(media?.title) - } - binding.videoIndicator.show() - } else if (media?.mimeType?.startsWith("audio") == true) { - binding.videoIndicator.hide() - placeholderIcon(R.drawable.ic_music, media?.title, isSelected) - } else if (media?.mimeType == "application/pdf") { - loadPdfThumbnail(media, isSelected) - binding.videoIndicator.hide() - } else if (media?.mimeType?.startsWith("application") == true) { - placeholderIcon(R.drawable.ic_unknown_file, media?.title, isSelected) - binding.videoIndicator.hide() - } else { - placeholderIcon(R.drawable.no_thumbnail, media?.title, isSelected) - binding.videoIndicator.hide() - } - media?.let { updateOverlay(it) } - } - - private fun resetImageState() { - pdfThumbnailJob?.cancel() - pdfThumbnailJob = null - imageRequest?.dispose() - imageRequest = null - binding.image.setImageDrawable(null) - binding.image.apply { - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - setPadding(0, 0, 0, 0) - scaleType = ImageView.ScaleType.CENTER_CROP - clearColorFilter() - imageTintList = null - } - hideTitle() - } - - private fun placeholderIcon(drawableRes: Int, title: String?, isSelected: Boolean) { - val padding = (24 * mContext.resources.displayMetrics.density).toInt() - binding.image.apply { - scaleType = ImageView.ScaleType.FIT_CENTER - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - setPadding(padding, padding, padding, padding) - imageRequest = load(drawableRes) { - crossfade(false) - } - clearColorFilter() - applyPlaceholderTint(isSelected) - show() - } - showTitle(title) - } - - private fun showTitle(title: String?) { - if (title.isNullOrBlank()) { - hideTitle() - } else { - binding.mediaTitle.text = title - binding.mediaTitle.show() - } - } - - private fun hideTitle() { - binding.mediaTitle.text = "" - binding.mediaTitle.hide() - } - - private fun applyPlaceholderTint(isSelected: Boolean) { - val tint = if (isSelected) { - ContextCompat.getColor(mContext, R.color.colorOnPrimaryContainer) - } else { - ContextCompat.getColor(mContext, R.color.colorOnSurfaceVariant) - } - binding.image.imageTintList = ColorStateList.valueOf(tint) - } - - private fun updateOverlay(media: Media) { - val sbTitle = StringBuffer() - when (media.sStatus) { - Media.Status.Error -> { - AppLogger.i("Media Item ${media.id} is error") - sbTitle.append(mContext.getString(R.string.error)) - binding.overlayContainer.show() - binding.progress.hide() - binding.progressText.hide() - binding.error.show() - } - Media.Status.Queued -> { - AppLogger.i("Media Item ${media.id} is queued") - binding.overlayContainer.show() - binding.progress.isIndeterminate = true - binding.progress.show() - binding.progressText.hide() - binding.error.hide() - } - Media.Status.Uploading -> { - val progressValue = media.uploadPercentage ?: 0 - AppLogger.i("Media Item ${media.id} is uploading") - binding.overlayContainer.show() - binding.progress.isIndeterminate = false - binding.progress.show() - binding.progressText.show() - if (progressValue > 2) { - binding.progress.setProgressCompat(progressValue, true) - } - binding.progressText.text = "$progressValue%" - binding.error.hide() - } - else -> { - binding.overlayContainer.hide() - binding.progress.hide() - binding.progressText.hide() - binding.error.hide() - } - } - } - - private fun loadPdfThumbnail(media: Media?, isSelected: Boolean) { - if (media == null) { - showPdfPlaceholder(null, isSelected) - return - } - - val uri = media.fileUri - val file = media.file - if (uri.scheme == "file" && !file.exists()) { - showPdfPlaceholder(media.title, isSelected) - return - } - - pdfThumbnailJob = PdfThumbnailLoader.loadThumbnail( - imageView = binding.image, - uri = uri, - placeholderRes = R.drawable.ic_pdf, - scope = pdfScope, - maxDimensionPx = 512, - context = mContext, - requestKey = media.id, - onPlaceholder = { showPdfPlaceholder(null, isSelected) } - ) { success -> - if (success) { - hideTitle() - } else { - showTitle(media.title) - } - } - } - - private fun showPdfPlaceholder(title: String?, isSelected: Boolean) { - val padding = (24 * mContext.resources.displayMetrics.density).toInt() - binding.image.apply { - scaleType = ImageView.ScaleType.FIT_CENTER - setBackgroundColor(ContextCompat.getColor(mContext, android.R.color.transparent)) - setPadding(padding, padding, padding, padding) - setImageResource(R.drawable.ic_pdf) - clearColorFilter() - applyPlaceholderTint(isSelected) - show() - } - if (title.isNullOrBlank()) { - hideTitle() - } else { - showTitle(title) - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraActivity.kt deleted file mode 100644 index 2bd9ff9cb..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraActivity.kt +++ /dev/null @@ -1,274 +0,0 @@ -package net.opendasharchive.openarchive.features.media.camera - -import android.Manifest -import android.app.Activity -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Bundle -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Surface -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import android.os.Build -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.features.core.BaseComposeActivity -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme - -class CameraActivity : BaseComposeActivity() { - - companion object { - const val EXTRA_CAMERA_CONFIG = "camera_config" - const val EXTRA_CAPTURED_URIS = "captured_uris" - const val REQUEST_CODE_CAMERA = 1001 - - fun createIntent( - activity: Activity, - config: CameraConfig = CameraConfig() - ): Intent { - return Intent(activity, CameraActivity::class.java).apply { - putExtra(EXTRA_CAMERA_CONFIG, config) - } - } - } - - private var cameraConfig: CameraConfig? = null - private var showPermissionScreen by mutableStateOf(false) - private var isCameraPermissionPermanentlyDenied by mutableStateOf(false) - private var isAudioPermissionPermanentlyDenied by mutableStateOf(false) - private var hasCameraPermissionBeenRequested by mutableStateOf(false) - private var hasAudioPermissionBeenRequested by mutableStateOf(false) - - private val cameraPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - AppLogger.d("Camera permission result: $granted") - hasCameraPermissionBeenRequested = true - - if (granted) { - showPermissionScreen = false - isCameraPermissionPermanentlyDenied = false - // If camera permission is granted, request audio permission for video if needed - requestAudioPermissionIfNeeded() - } else { - // Check if permission was permanently denied (only after we've requested it) - isCameraPermissionPermanentlyDenied = !shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) - showPermissionScreen = true - } - } - - private val audioPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - AppLogger.d("Audio permission result: $granted") - hasAudioPermissionBeenRequested = true - - if (!granted) { - // Check if audio permission was permanently denied (only after we've requested it) - isAudioPermissionPermanentlyDenied = !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) - } else { - isAudioPermissionPermanentlyDenied = false - } - // Audio permission result doesn't affect UI state for now - // Video recording will work without audio if needed - } - - private fun checkCameraPermission(): Boolean { - return ContextCompat.checkSelfPermission( - this, Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED - } - - private fun requestCameraPermission() { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - - private fun requestAudioPermissionIfNeeded() { - if (cameraConfig?.allowVideoCapture == true) { - val audioGranted = ContextCompat.checkSelfPermission( - this, Manifest.permission.RECORD_AUDIO - ) == PackageManager.PERMISSION_GRANTED - - if (!audioGranted) { - audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - } - - private fun setupEdgeToEdge() { - // Enable edge-to-edge display - WindowCompat.setDecorFitsSystemWindows(window, false) - - val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Android 11+ (API 30+) - Enhanced for Android 15 - windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - - // Hide system bars but keep them accessible with gestures - windowInsetsController.hide( - WindowInsetsCompat.Type.statusBars() or - WindowInsetsCompat.Type.navigationBars() - ) - - // For Android 15+, ensure proper handling of display cutouts and camera cutouts - if (Build.VERSION.SDK_INT >= 35) { - // Android 15 (API 35) specific enhancements - // The display cutout padding in Compose will handle camera notches - AppLogger.d("Android 15+ detected - using enhanced edge-to-edge with cutout support") - } - } else { - // Legacy approach for older Android versions - windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) - } - - // Make status bar and navigation bar transparent - window.statusBarColor = android.graphics.Color.TRANSPARENT - window.navigationBarColor = android.graphics.Color.TRANSPARENT - - // For Android 15+, also handle the navigation bar appearance - window.isNavigationBarContrastEnforced = false - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // Enhanced edge-to-edge setup for Android 15+ and camera cutouts - setupEdgeToEdge() - - // Get camera config from intent - cameraConfig = intent.getSerializableExtra(EXTRA_CAMERA_CONFIG) as? CameraConfig - ?: CameraConfig() - - // Keep screen on during camera use - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - - // Optionally override screen brightness based on configuration - if (cameraConfig?.overrideScreenBrightness == true) { - val layoutParams = window.attributes - layoutParams.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL - window.attributes = layoutParams - AppLogger.d("Screen brightness overridden to maximum") - } else { - AppLogger.d("Using system screen brightness") - } - - // Check camera permission and request if needed - if (checkCameraPermission()) { - showPermissionScreen = false - // If camera permission is granted, request audio permission for video if needed - requestAudioPermissionIfNeeded() - } else { - // For first launch, we don't know if it's permanently denied yet - // Just show permission screen and let user try to grant - isCameraPermissionPermanentlyDenied = false - isAudioPermissionPermanentlyDenied = false - - // Show permission screen immediately - showPermissionScreen = true - } - - setContent { - SaveAppTheme { - Surface( - modifier = Modifier.fillMaxSize() - ) { - if (showPermissionScreen) { - CameraPermissionScreen( - isCameraPermissionPermanentlyDenied = isCameraPermissionPermanentlyDenied, - isAudioPermissionPermanentlyDenied = isAudioPermissionPermanentlyDenied, - needsAudioPermission = cameraConfig?.allowVideoCapture == true, - onRequestPermissions = { requestCameraPermission() }, - onOpenSettings = { - // Open app settings - val intent = android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = android.net.Uri.fromParts("package", packageName, null) - } - startActivity(intent) - }, - onCancel = { finishWithResult(Activity.RESULT_CANCELED, emptyList()) } - ) - } else { - CameraScreen( - config = cameraConfig ?: CameraConfig(), - onCaptureComplete = { uris -> - finishWithResult(Activity.RESULT_OK, uris) - }, - onCancel = { - finishWithResult(Activity.RESULT_CANCELED, emptyList()) - } - ) - } - } - } - } - } - - override fun onResume() { - super.onResume() - // Re-apply immersive mode when returning to the activity - setupEdgeToEdge() - - // Re-check permissions when returning from settings - checkAndUpdatePermissionStates() - } - - private fun checkAndUpdatePermissionStates() { - val wasCameraPermissionGranted = checkCameraPermission() - - if (wasCameraPermissionGranted && showPermissionScreen) { - // Camera permission was granted while we were showing permission screen - showPermissionScreen = false - isCameraPermissionPermanentlyDenied = false - - // If camera permission is now granted, request audio permission for video if needed - requestAudioPermissionIfNeeded() - } else if (!wasCameraPermissionGranted && !showPermissionScreen) { - // Camera permission was revoked while we were showing camera - // Only consider it permanently denied if we've already requested it before - isCameraPermissionPermanentlyDenied = hasCameraPermissionBeenRequested && - !shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) - showPermissionScreen = true - } - - // Update audio permission state if video capture is enabled - if (cameraConfig?.allowVideoCapture == true) { - val isAudioGranted = ContextCompat.checkSelfPermission( - this, Manifest.permission.RECORD_AUDIO - ) == PackageManager.PERMISSION_GRANTED - - if (!isAudioGranted && hasAudioPermissionBeenRequested) { - // Only consider it permanently denied if we've already requested it before - isAudioPermissionPermanentlyDenied = !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) - } else if (isAudioGranted) { - isAudioPermissionPermanentlyDenied = false - } - } - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - if (hasFocus) { - // Re-apply immersive mode when the window regains focus - setupEdgeToEdge() - } - } - - private fun finishWithResult(resultCode: Int, uris: List) { - val resultIntent = Intent().apply { - putExtra(EXTRA_CAPTURED_URIS, ArrayList(uris.map { it.toString() })) - } - setResult(resultCode, resultIntent) - finish() - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraBottomControls.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraBottomControls.kt index a34d439eb..ed893c782 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraBottomControls.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraBottomControls.kt @@ -8,8 +8,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -21,6 +19,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -98,7 +97,7 @@ fun CameraBottomControls( .background(Color.Black.copy(alpha = 0.3f), CircleShape) ) { Icon( - imageVector = if (cameraState.isFrontCamera) Icons.Default.CameraFront else Icons.Default.CameraRear, + painter = if (cameraState.isFrontCamera) painterResource( R.drawable.ic_camera_rear) else painterResource(R.drawable.ic_camera_front), contentDescription = stringResource(R.string.switch_camera), tint = Color.White ) @@ -126,11 +125,10 @@ private fun CameraModeSelector( ) { CameraCaptureMode.entries.forEach { mode -> val isSelected = currentMode == mode - val context = androidx.compose.ui.platform.LocalContext.current Text( text = when (mode) { - CameraCaptureMode.PHOTO -> context.getString(R.string.photo_label) - CameraCaptureMode.VIDEO -> context.getString(R.string.video_label) + CameraCaptureMode.PHOTO -> stringResource(R.string.photo_label) + CameraCaptureMode.VIDEO -> stringResource(R.string.video_label) }, modifier = Modifier .clip(RoundedCornerShape(16.dp)) @@ -182,7 +180,7 @@ private fun CameraCaptureButton( contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Default.CameraAlt, + painter = painterResource(R.drawable.ic_camera_alt), contentDescription = stringResource(R.string.capture_photo), tint = Color.Black, modifier = Modifier.size(32.dp) @@ -252,7 +250,7 @@ private fun CameraCaptureButton( contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Default.Videocam, + painter = painterResource(R.drawable.ic_videocam), contentDescription = stringResource(R.string.start_recording), tint = Color.White, modifier = Modifier.size(32.dp) @@ -290,9 +288,10 @@ private fun CapturedItemsIndicator( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( - imageVector = Icons.Default.Check, + painter = painterResource(R.drawable.ic_done), contentDescription = stringResource(R.string.done), - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), + tint = Color.White ) Text( text = stringResource(R.string.done), diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt index 38e4b5122..fc38dd7ab 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt @@ -8,33 +8,28 @@ import java.io.Serializable * This class allows fine-grained control over camera behavior, performance, and * resource usage. All settings have sensible defaults for typical use cases. */ +@kotlinx.serialization.Serializable data class CameraConfig( // ===== Capture Modes ===== /** Enable photo capture functionality */ val allowVideoCapture: Boolean = true, - /** Enable video recording functionality */ val allowPhotoCapture: Boolean = true, - /** Allow capturing multiple photos/videos in one session */ val allowMultipleCapture: Boolean = false, - /** Enable preview functionality (should generally stay true) */ val enablePreview: Boolean = true, - /** Initial capture mode when camera opens */ val initialMode: CameraCaptureMode = CameraCaptureMode.PHOTO, - // ===== UI Controls ===== /** Show flash toggle button (only appears if camera has flash hardware) */ val showFlashToggle: Boolean = true, - /** Show grid overlay toggle button for composition assistance */ val showGridToggle: Boolean = true, - /** Show button to switch between front and back cameras */ val showCameraSwitch: Boolean = true, - + // When true, uses IMG_123.jpg instead of 20250119_143045.IMG_123.jpg + val useCleanFilenames: Boolean = false, // ===== Power Management ===== /** * Override screen brightness to maximum when camera is active. @@ -48,7 +43,6 @@ data class CameraConfig( * Default: true (prevents automatic brightness reduction) */ val overrideScreenBrightness: Boolean = true, - /** * Enable automatic camera pause after inactivity. * @@ -59,7 +53,6 @@ data class CameraConfig( * Recommendation: Keep enabled for better battery life */ val enableIdleTimeout: Boolean = true, - /** * Duration in seconds before camera automatically pauses (when [enableIdleTimeout] is true). * @@ -71,7 +64,6 @@ data class CameraConfig( * Default: 60 seconds */ val idleTimeoutSeconds: Int = 60, - // ===== Preview Optimization ===== /** * Camera preview resolution. @@ -87,7 +79,6 @@ data class CameraConfig( * Default: HD */ val previewResolution: PreviewResolution = PreviewResolution.HD, - /** * PreviewView rendering implementation mode. * @@ -102,7 +93,6 @@ data class CameraConfig( * Default: PERFORMANCE */ val implementationMode: ImplementationMode = ImplementationMode.PERFORMANCE, - // ===== Video Recording Settings ===== /** * Video recording quality. @@ -119,7 +109,6 @@ data class CameraConfig( * Default: HD */ val videoQuality: VideoQuality = VideoQuality.HD, - /** * Enable audio recording with video (requires RECORD_AUDIO permission). * @@ -127,7 +116,6 @@ data class CameraConfig( * Default: true */ val enableAudio: Boolean = true, - // ===== Video Playback Optimization ===== /** * Defer video player initialization until preview is actually shown. @@ -140,7 +128,6 @@ data class CameraConfig( * Default: true */ val enableLazyVideoLoading: Boolean = true, - /** * Target buffer duration in milliseconds required to start/resume video playback. * @@ -152,7 +139,6 @@ data class CameraConfig( * Default: 1500ms */ val videoBufferMs: Int = 1500, - /** * Minimum total buffer duration for video playback (in milliseconds). * @@ -164,7 +150,6 @@ data class CameraConfig( * Default: 2500ms */ val minVideoBufferMs: Int = 2500, - /** * Maximum buffer duration for video playback (in milliseconds). * @@ -174,7 +159,7 @@ data class CameraConfig( * Recommendation: 5000-10000ms * Default: 5000ms */ - val maxVideoBufferMs: Int = 5000 + val maxVideoBufferMs: Int = 5000, ) : Serializable /** @@ -182,7 +167,7 @@ data class CameraConfig( */ enum class CameraCaptureMode : Serializable { PHOTO, - VIDEO + VIDEO, } /** @@ -198,7 +183,7 @@ enum class PreviewResolution : Serializable { FHD, /** Maximum supported resolution - Use sparingly */ - MAX + MAX, } /** @@ -217,7 +202,7 @@ enum class ImplementationMode : Serializable { * Uses TextureView for rendering. * Use only if PERFORMANCE mode causes rendering issues. */ - COMPATIBLE + COMPATIBLE, } /** @@ -236,5 +221,5 @@ enum class VideoQuality : Serializable { FHD, /** 4K - Only for high-end devices (if supported) */ - UHD -} \ No newline at end of file + UHD, +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPermissionScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPermissionScreen.kt index a39ec2e26..d8b4f7fbd 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPermissionScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPermissionScreen.kt @@ -3,15 +3,13 @@ package net.opendasharchive.openarchive.features.media.camera import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -31,7 +29,6 @@ fun CameraPermissionScreen( onOpenSettings: () -> Unit = {}, onCancel: () -> Unit ) { - val context = LocalContext.current Box( modifier = modifier @@ -48,7 +45,7 @@ fun CameraPermissionScreen( ) { // Header Icon( - imageVector = Icons.Default.CameraAlt, + painter = painterResource(R.drawable.ic_camera_alt), contentDescription = null, modifier = Modifier.size(80.dp), tint = Color.White @@ -112,7 +109,7 @@ fun CameraPermissionScreen( onClick = onOpenSettings, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary + containerColor = MaterialTheme.colorScheme.tertiary ) ) { Row( @@ -120,7 +117,7 @@ fun CameraPermissionScreen( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Default.Settings, + painter = painterResource(R.drawable.ic_settings), contentDescription = null, modifier = Modifier.size(18.dp) ) @@ -175,7 +172,7 @@ fun CameraPermissionScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( - imageVector = Icons.Default.Settings, + painter = painterResource(R.drawable.ic_settings), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White.copy(alpha = 0.7f) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPreviewScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPreviewScreen.kt index d3a11d363..1ceb36a52 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPreviewScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPreviewScreen.kt @@ -4,19 +4,40 @@ import android.content.Context import android.media.MediaMetadataRetriever import android.net.Uri import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -28,7 +49,6 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage import coil3.request.ImageRequest -import androidx.compose.ui.res.stringResource import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.logger.AppLogger @@ -42,7 +62,7 @@ fun CameraPreviewScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current - + Box( modifier = modifier .fillMaxSize() @@ -61,6 +81,7 @@ fun CameraPreviewScreen( contentScale = ContentScale.Fit ) } + CameraCaptureMode.VIDEO -> { // Video preview with playback capability // Uses optimized buffer settings from config @@ -77,7 +98,7 @@ fun CameraPreviewScreen( ) } } - + // Top controls Row( modifier = Modifier @@ -97,14 +118,14 @@ fun CameraPreviewScreen( .background(Color.Black.copy(alpha = 0.6f), CircleShape) ) { Icon( - imageVector = Icons.Default.ArrowBack, + painter = painterResource(R.drawable.ic_arrow_back), contentDescription = stringResource(R.string.back), tint = Color.White ) } - + Spacer(modifier = Modifier.weight(1f)) - + // Media type indicator Row( modifier = Modifier @@ -114,9 +135,9 @@ fun CameraPreviewScreen( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( - imageVector = when (item.type) { - CameraCaptureMode.PHOTO -> Icons.Default.Photo - CameraCaptureMode.VIDEO -> Icons.Default.Videocam + painter = when (item.type) { + CameraCaptureMode.PHOTO -> painterResource(R.drawable.ic_photo_camera) + CameraCaptureMode.VIDEO -> painterResource(R.drawable.ic_videocam) }, contentDescription = item.type.name, tint = Color.White, @@ -133,7 +154,7 @@ fun CameraPreviewScreen( ) } } - + // Bottom controls - positioned above video player controls Row( modifier = Modifier @@ -153,8 +174,10 @@ fun CameraPreviewScreen( containerColor = Color.Transparent, contentColor = Color.White ), - border = ButtonDefaults.outlinedButtonBorder.copy( - brush = androidx.compose.ui.graphics.SolidColor(Color.White) + border = ButtonDefaults.outlinedButtonBorder( + enabled = true + ).copy( + brush = SolidColor(Color.White) ), shape = RoundedCornerShape(28.dp) ) { @@ -163,7 +186,7 @@ fun CameraPreviewScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( - imageVector = Icons.Default.Refresh, + painter = painterResource(R.drawable.refresh), contentDescription = stringResource(R.string.retake), modifier = Modifier.size(20.dp) ) @@ -174,7 +197,7 @@ fun CameraPreviewScreen( ) } } - + // Confirm/Use button Button( onClick = { onConfirm(item) }, @@ -190,12 +213,17 @@ fun CameraPreviewScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( - imageVector = Icons.Default.Check, - contentDescription = if (config.allowMultipleCapture) stringResource(R.string.use) else stringResource(R.string.done), + painter = painterResource(R.drawable.ic_done), + tint = Color.White, + contentDescription = if (config.allowMultipleCapture) stringResource(R.string.use) else stringResource( + R.string.done + ), modifier = Modifier.size(20.dp) ) Text( - text = if (config.allowMultipleCapture) stringResource(R.string.use) else stringResource(R.string.done), + text = if (config.allowMultipleCapture) stringResource(R.string.use) else stringResource( + R.string.done + ), fontSize = 16.sp, fontWeight = FontWeight.Medium ) @@ -212,24 +240,25 @@ private fun VideoDurationOverlay( ) { val context = LocalContext.current var duration by remember { mutableLongStateOf(0L) } - + LaunchedEffect(uri) { try { val retriever = MediaMetadataRetriever() retriever.setDataSource(context, uri) - duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() ?: 0L retriever.release() } catch (e: Exception) { AppLogger.e("Error getting video duration", e) duration = 0L } } - + if (duration > 0) { val seconds = duration / 1000 val minutes = seconds / 60 val remainingSeconds = seconds % 60 - + Text( text = String.format("%02d:%02d", minutes, remainingSeconds), modifier = modifier @@ -340,8 +369,10 @@ private fun createExoPlayer( ) .build() - AppLogger.d("Creating ExoPlayer with buffer config: min=${config.minVideoBufferMs}ms, " + - "max=${config.maxVideoBufferMs}ms, target=${config.videoBufferMs}ms") + AppLogger.d( + "Creating ExoPlayer with buffer config: min=${config.minVideoBufferMs}ms, " + + "max=${config.maxVideoBufferMs}ms, target=${config.videoBufferMs}ms" + ) // ===== Player Creation ===== return ExoPlayer.Builder(context) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt index 497ea922e..94372dd6d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt @@ -3,10 +3,12 @@ package net.opendasharchive.openarchive.features.media.camera import android.content.Context import android.net.Uri import android.util.Size +import androidx.camera.compose.CameraXViewfinder import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider @@ -14,7 +16,6 @@ import androidx.camera.video.Quality import androidx.camera.video.QualitySelector import androidx.camera.video.Recorder import androidx.camera.video.VideoCapture -import androidx.camera.view.PreviewView import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -42,14 +43,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.FlashAuto -import androidx.compose.material.icons.filled.FlashOff -import androidx.compose.material.icons.filled.FlashOn -import androidx.compose.material.icons.filled.GridOff -import androidx.compose.material.icons.filled.GridOn -import androidx.compose.material.icons.filled.Pause import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -69,18 +62,19 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.delay import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.util.ComposePermissionManager import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -90,6 +84,7 @@ fun CameraScreen( config: CameraConfig = CameraConfig(), onCaptureComplete: (List) -> Unit, onCancel: () -> Unit, + permissionManager: ComposePermissionManager, viewModel: CameraViewModel = viewModel() ) { val context = LocalContext.current @@ -190,33 +185,32 @@ fun CameraScreen( } } ) { - // Camera preview - var previewView by remember { mutableStateOf(null) } - + // Camera preview state + var surfaceRequest by remember { mutableStateOf(null) } + // ===== Camera Setup ===== - // Setup camera when preview view is ready, camera switches, or idle state changes - // NOTE: Flash mode changes are handled separately to avoid unnecessary rebinding - LaunchedEffect(previewView, cameraState.isFrontCamera, isIdle) { + // Setup camera when camera switches or idle state changes + LaunchedEffect(cameraState.isFrontCamera, isIdle) { // Only setup camera if not in idle state if (!isIdle) { - previewView?.let { preview -> - setupCamera( - context = context, - config = config, - previewView = preview, - lifecycleOwner = lifecycleOwner, - cameraState = cameraState, - onCameraReady = { provider, cam, imgCapture, vidCapture -> - cameraProvider = provider - camera = cam - imageCapture = imgCapture - videoCapture = vidCapture - }, - onFlashSupportChanged = { isSupported -> - viewModel.updateFlashSupport(isSupported) - } - ) - } + setupCamera( + context = context, + config = config, + onSurfaceRequestReady = { request -> + surfaceRequest = request + }, + lifecycleOwner = lifecycleOwner, + cameraState = cameraState, + onCameraReady = { provider, cam, imgCapture, vidCapture -> + cameraProvider = provider + camera = cam + imageCapture = imgCapture + videoCapture = vidCapture + }, + onFlashSupportChanged = { isSupported -> + viewModel.updateFlashSupport(isSupported) + } + ) } } @@ -257,7 +251,7 @@ fun CameraScreen( // This provides continuous light during video recording or video mode preview LaunchedEffect(cameraState.flashMode, cameraState.captureMode, camera) { // Wait a bit for camera to be fully ready - kotlinx.coroutines.delay(100) + delay(100) camera?.let { cam -> try { @@ -293,26 +287,13 @@ fun CameraScreen( } } - // ===== Preview View Configuration ===== - // Uses configured implementation mode (PERFORMANCE or COMPATIBLE) - AndroidView( - factory = { ctx -> - PreviewView(ctx).apply { - scaleType = PreviewView.ScaleType.FILL_CENTER - - // Use configured implementation mode for optimal performance - implementationMode = when (config.implementationMode) { - ImplementationMode.PERFORMANCE -> PreviewView.ImplementationMode.PERFORMANCE - ImplementationMode.COMPATIBLE -> PreviewView.ImplementationMode.COMPATIBLE - } - - AppLogger.d("PreviewView initialized with ${config.implementationMode} mode") - }.also { preview -> - previewView = preview - } - }, - modifier = Modifier.fillMaxSize() - ) + // CameraX viewfinder + surfaceRequest?.let { request -> + CameraXViewfinder( + surfaceRequest = request, + modifier = Modifier.fillMaxSize() + ) + } // Grid overlay if (cameraState.showGrid && config.showGridToggle) { @@ -345,6 +326,7 @@ fun CameraScreen( viewModel.capturePhoto( context = context, imageCapture = capture, + useCleanFilenames = config.useCleanFilenames, onSuccess = { uri -> AppLogger.d("Photo captured: $uri") }, @@ -355,17 +337,22 @@ fun CameraScreen( } }, onVideoStart = { - videoCapture?.let { capture -> - viewModel.startVideoRecording( - context = context, - videoCapture = capture, - onSuccess = { uri -> - AppLogger.d("Video captured: $uri") - }, - onError = { error -> - AppLogger.e("Video capture failed", error) - } - ) + if (permissionManager.isAudioGranted()) { + videoCapture?.let { capture -> + viewModel.startVideoRecording( + context = context, + videoCapture = capture, + useCleanFilenames = config.useCleanFilenames, + onSuccess = { uri -> + AppLogger.d("Video captured: $uri") + }, + onError = { error -> + AppLogger.e("Video capture failed", error) + } + ) + } + } else { + permissionManager.requestAudioPermission() } }, onVideoStop = { @@ -401,7 +388,7 @@ fun CameraScreen( ) { // Pause icon Icon( - imageVector = Icons.Default.Pause, + painter = painterResource(R.drawable.ic_pause), contentDescription = null, tint = Color.White, modifier = Modifier.size(64.dp) @@ -445,7 +432,7 @@ private fun CameraTopControls( .background(Color.Black.copy(alpha = 0.3f), CircleShape) ) { Icon( - imageVector = Icons.Default.Close, + painter = painterResource(R.drawable.ic_close), contentDescription = stringResource(R.string.close), tint = Color.White ) @@ -477,7 +464,7 @@ private fun CameraTopControls( .background(Color.Black.copy(alpha = 0.3f), CircleShape) ) { Icon( - imageVector = if (cameraState.showGrid) Icons.Default.GridOn else Icons.Default.GridOff, + painter = if (cameraState.showGrid) painterResource(R.drawable.ic_grid_on) else painterResource(R.drawable.ic_grid_off), contentDescription = stringResource(R.string.grid), tint = if (cameraState.showGrid) Color.Yellow else Color.White ) @@ -498,12 +485,12 @@ private fun CameraTopControls( .background(Color.Black.copy(alpha = 0.3f), CircleShape) ) { val flashIcon = when (cameraState.flashMode) { - ImageCapture.FLASH_MODE_ON -> Icons.Default.FlashOn - ImageCapture.FLASH_MODE_AUTO -> Icons.Default.FlashAuto - else -> Icons.Default.FlashOff + ImageCapture.FLASH_MODE_ON -> painterResource(R.drawable.ic_flash_on) + ImageCapture.FLASH_MODE_AUTO -> painterResource(R.drawable.ic_flash_auto) + else -> painterResource(R.drawable.ic_flash_off) } Icon( - imageVector = flashIcon, + painter = flashIcon, contentDescription = stringResource(R.string.flash), tint = Color.White ) @@ -592,7 +579,7 @@ private fun RecordingTimerCompact( private fun setupCamera( context: Context, config: CameraConfig, - previewView: PreviewView?, + onSurfaceRequestReady: (SurfaceRequest) -> Unit, lifecycleOwner: androidx.lifecycle.LifecycleOwner, cameraState: CameraState, onCameraReady: (ProcessCameraProvider, Camera, ImageCapture, VideoCapture) -> Unit, @@ -605,7 +592,7 @@ private fun setupCamera( bindCamera( cameraProvider, config, - previewView, + onSurfaceRequestReady, lifecycleOwner, cameraState, onCameraReady, @@ -638,7 +625,7 @@ private fun setupCamera( private fun bindCamera( cameraProvider: ProcessCameraProvider, config: CameraConfig, - previewView: PreviewView?, + onSurfaceRequestReady: (SurfaceRequest) -> Unit, lifecycleOwner: androidx.lifecycle.LifecycleOwner, cameraState: CameraState, onCameraReady: (ProcessCameraProvider, Camera, ImageCapture, VideoCapture) -> Unit, @@ -676,8 +663,8 @@ private fun bindCamera( .setResolutionSelector(resolutionSelector) .build() .also { - previewView?.let { pv -> - it.surfaceProvider = pv.surfaceProvider + it.setSurfaceProvider { request -> + onSurfaceRequestReady(request) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreenWrapper.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreenWrapper.kt new file mode 100644 index 000000000..e7a93644b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreenWrapper.kt @@ -0,0 +1,212 @@ +package net.opendasharchive.openarchive.features.media.camera + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.view.WindowManager +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.util.rememberComposePermissionManager + +/** + * Wrapper composable that replicates all the logic from CameraActivity. + * + * Handles: + * - Camera and audio permission checking and requesting + * - Permission launcher registration + * - Showing CameraPermissionScreen when permissions are denied + * - Showing CameraScreen when permissions are granted + * - Permission state management (permanently denied, etc.) + * - Checking permissions on resume (when returning from settings) + * + * @param config Camera configuration + * @param onCaptureComplete Called when user confirms captured media + * @param onCancel Called when user cancels + */ +@Composable +fun CameraScreenWrapper( + config: CameraConfig = CameraConfig(), + onCaptureComplete: (List) -> Unit, + onCancel: () -> Unit +) { + val context = LocalContext.current + val permissionManager = rememberComposePermissionManager() + + // Permission states + var showPermissionScreen by remember { mutableStateOf(false) } + var isCameraPermissionPermanentlyDenied by remember { mutableStateOf(false) } + var isAudioPermissionPermanentlyDenied by remember { mutableStateOf(false) } + var hasCameraPermissionBeenRequested by remember { mutableStateOf(false) } + var hasAudioPermissionBeenRequested by remember { mutableStateOf(false) } + + // Helper functions are provided by ComposePermissionManager + + // Request camera permission function + val requestCameraPermission = { + hasCameraPermissionBeenRequested = true + permissionManager.requestCameraPermission() + } + + // Open app settings + val openSettings = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } + + // Check and update permission states (for when returning from settings) + val checkAndUpdatePermissionStates = { + val wasCameraPermissionGranted = permissionManager.isCameraGranted() + + if (wasCameraPermissionGranted && showPermissionScreen) { + // Camera permission was granted while we were showing permission screen + showPermissionScreen = false + isCameraPermissionPermanentlyDenied = false + } else if (!wasCameraPermissionGranted && !showPermissionScreen) { + // Camera permission was revoked while we were showing camera + // Only consider it permanently denied if we've already requested it before + isCameraPermissionPermanentlyDenied = hasCameraPermissionBeenRequested && + !permissionManager.shouldShowCameraRationale() + showPermissionScreen = true + } + + // We no longer proactively check audio here as it's deferred to CameraScreen + } + + LaunchedEffect(permissionManager.cameraStatus()) { + if (permissionManager.isCameraGranted()) { + AppLogger.d("Camera permission result: true") + hasCameraPermissionBeenRequested = true + showPermissionScreen = false + isCameraPermissionPermanentlyDenied = false + } else if (hasCameraPermissionBeenRequested) { + AppLogger.d("Camera permission result: false") + isCameraPermissionPermanentlyDenied = !permissionManager.shouldShowCameraRationale() + showPermissionScreen = true + } + } + + LaunchedEffect(permissionManager.audioStatus()) { + if (permissionManager.isAudioGranted()) { + AppLogger.d("Audio permission result: true") + hasAudioPermissionBeenRequested = true + isAudioPermissionPermanentlyDenied = false + } else if (hasAudioPermissionBeenRequested) { + AppLogger.d("Audio permission result: false") + isAudioPermissionPermanentlyDenied = !permissionManager.shouldShowAudioRationale() + } + } + + // Initial permission check + LaunchedEffect(Unit) { + if (permissionManager.isCameraGranted()) { + showPermissionScreen = false + } else { + // For first launch, we don't know if it's permanently denied yet + // Just show permission screen and let user try to grant + isCameraPermissionPermanentlyDenied = false + isAudioPermissionPermanentlyDenied = false + showPermissionScreen = true + } + } + + // Window management: Keep screen on, brightness override, and immersive mode + DisposableEffect(Unit) { + val activity = context as? Activity + val window = activity?.window + + if (window != null) { + AppLogger.d("CameraScreenWrapper: Applying window flags and immersive mode") + + // 1. Keep screen on + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + // 2. Brightness override + val originalBrightness = window.attributes.screenBrightness + if (config.overrideScreenBrightness) { + val layoutParams = window.attributes + layoutParams.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL + window.attributes = layoutParams + } + + // 3. Immersive mode setup + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + val originalSystemBarsBehavior = windowInsetsController.systemBarsBehavior + + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + + // Android 15+ handling if needed (though mostly handled by navigationBarsPadding in Compose) + if (Build.VERSION.SDK_INT >= 35) { + window.isNavigationBarContrastEnforced = false + } + + onDispose { + AppLogger.d("CameraScreenWrapper: Resetting window flags and immersive mode") + + // Reset keep screen on + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + // Reset brightness + val layoutParams = window.attributes + layoutParams.screenBrightness = originalBrightness + window.attributes = layoutParams + + // Reset immersive mode + windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = originalSystemBarsBehavior + } + } else { + onDispose { } + } + } + + // Re-check permissions when composition becomes active (simulates onResume) + // This handles the case when user returns from settings + // Use a lifecycle observer to handle resume events + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { + checkAndUpdatePermissionStates() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + // Show appropriate screen based on permission state + Surface( + modifier = Modifier.fillMaxSize() + ) { + if (showPermissionScreen) { + CameraPermissionScreen( + isCameraPermissionPermanentlyDenied = isCameraPermissionPermanentlyDenied, + isAudioPermissionPermanentlyDenied = false, // Defer audio check + needsAudioPermission = false, // Don't mention audio in initial setup + onRequestPermissions = { requestCameraPermission() }, + onOpenSettings = { openSettings() }, + onCancel = onCancel + ) + } else { + CameraScreen( + config = config, + onCaptureComplete = onCaptureComplete, + onCancel = onCancel, + permissionManager = permissionManager + ) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt index 051c26b4f..938c69f52 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt @@ -93,14 +93,19 @@ class CameraViewModel : ViewModel() { fun capturePhoto( context: Context, imageCapture: ImageCapture, + useCleanFilenames: Boolean = false, onSuccess: (Uri) -> Unit, onError: (Exception) -> Unit ) { viewModelScope.launch { try { val filename = "IMG_${System.currentTimeMillis()}.jpg" - val outputFile = Utility.getOutputMediaFileByCache(context, filename) - + val outputFile = if (useCleanFilenames) { + Utility.getOutputMediaFileByCacheNoTimestamp(context, filename) + } else { + Utility.getOutputMediaFileByCache(context, filename) + } + if (outputFile == null) { onError(Exception("Failed to create output file")) return@launch @@ -148,6 +153,7 @@ class CameraViewModel : ViewModel() { fun startVideoRecording( context: Context, videoCapture: androidx.camera.video.VideoCapture, + useCleanFilenames: Boolean = false, onSuccess: (Uri) -> Unit, onError: (Exception) -> Unit ) { @@ -158,8 +164,12 @@ class CameraViewModel : ViewModel() { try { val filename = "VID_${System.currentTimeMillis()}.mp4" - val outputFile = Utility.getOutputMediaFileByCache(context, filename) - + val outputFile = if (useCleanFilenames) { + Utility.getOutputMediaFileByCacheNoTimestamp(context, filename) + } else { + Utility.getOutputMediaFileByCache(context, filename) + } + if (outputFile == null) { onError(Exception("Failed to create output file")) return diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23Activity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23Activity.kt deleted file mode 100644 index 8069d9a51..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23Activity.kt +++ /dev/null @@ -1,79 +0,0 @@ -package net.opendasharchive.openarchive.features.onboarding - -import android.animation.ObjectAnimator -import android.content.Intent -import android.os.Bundle -import android.text.Spanned -import android.view.Window -import android.view.WindowManager -import android.view.animation.BounceInterpolator -import androidx.activity.OnBackPressedCallback -import androidx.annotation.ColorRes -import androidx.core.content.ContextCompat -import androidx.core.text.HtmlCompat -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityOnboarding23Binding -import net.opendasharchive.openarchive.features.core.BaseActivity - -class Onboarding23Activity : BaseActivity() { - - private lateinit var mBinding: ActivityOnboarding23Binding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - window.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE - ) - supportRequestWindowFeature(Window.FEATURE_NO_TITLE) - - mBinding = ActivityOnboarding23Binding.inflate(layoutInflater) - setContentView(mBinding.root) - - mBinding.getStarted.setOnClickListener { - startActivity( - Intent( - this, - Onboarding23InstructionsActivity::class.java - ) - ) - } - - // Handle back button to exit app instead of returning to MainActivity - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - // Exit the app when back is pressed during onboarding - finishAffinity() - } - }) - - for (textView in arrayOf( - mBinding.titleBlock.shareText, - mBinding.titleBlock.archiveText, - mBinding.titleBlock.verifyText, - mBinding.titleBlock.encryptText - )) { - textView.text = colorizeFirstLetter(textView.text, R.color.colorTertiary) - } - } - - override fun onResume() { - super.onResume() - - val oa = ObjectAnimator.ofFloat(mBinding.arrow, "translationX", 0F, 25F, 0F) - oa.interpolator = BounceInterpolator() - oa.startDelay = 3000 - oa.duration = 2000 - oa.repeatCount = 999999 - oa.start() - } - - private fun colorizeFirstLetter(text: CharSequence, @ColorRes color: Int): Spanned { - val colorHexString = - Integer.toHexString(0xffffff and ContextCompat.getColor(this, color)) - val html = - "${text.substring(0, 1)}${text.substring(1)}" - return HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23FragmentStateAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23FragmentStateAdapter.kt deleted file mode 100644 index acfa7cd47..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23FragmentStateAdapter.kt +++ /dev/null @@ -1,54 +0,0 @@ -package net.opendasharchive.openarchive.features.onboarding - -import android.content.Context -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.Lifecycle -import androidx.viewpager2.adapter.FragmentStateAdapter -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.features.onboarding.Onboarding23SlideFragment.Companion.newInstance - -class Onboarding23FragmentStateAdapter( - fragmentManager: FragmentManager, - lifecycle: Lifecycle, - context: Context -) : FragmentStateAdapter(fragmentManager, lifecycle) { - - private val context: Context = context.applicationContext - - override fun createFragment(position: Int): Fragment { - when (position) { - 0 -> return newInstance( - context, - R.string.intro_header_secure, - R.string.intro_text_secure, - ) - - 1 -> return newInstance( - context, - R.string.intro_header_archive, - R.string.intro_text_archive, - R.string.intro_link_archive, - ) - - 2 -> return newInstance( - context, - R.string.intro_header_verify, - R.string.intro_text_verify, - R.string.intro_link_verify, - ) - - 3 -> return newInstance( - context, - R.string.intro_header_encrypt, - R.string.intro_text_encrypt, - R.string.intro_link_encrypt, - ) - } - throw IndexOutOfBoundsException() - } - - override fun getItemCount(): Int { - return 4 - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23InstructionsActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23InstructionsActivity.kt deleted file mode 100644 index cf4d0a11f..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23InstructionsActivity.kt +++ /dev/null @@ -1,137 +0,0 @@ -package net.opendasharchive.openarchive.features.onboarding - -import android.content.Intent -import android.os.Bundle -import android.view.View -import android.view.Window -import android.view.WindowManager -import android.view.inputmethod.InputMethodManager -import androidx.activity.OnBackPressedCallback -import androidx.activity.enableEdgeToEdge -import androidx.core.content.ContextCompat -import androidx.core.view.WindowCompat -import androidx.viewpager2.widget.ViewPager2 -import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityOnboarding23InstructionsBinding -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.main.MainActivity -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets - -class Onboarding23InstructionsActivity : BaseActivity() { - - private lateinit var mBinding: ActivityOnboarding23InstructionsBinding - - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() - super.onCreate(savedInstanceState) - WindowCompat.setDecorFitsSystemWindows(window, false) - window.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE - ) - supportRequestWindowFeature(Window.FEATURE_NO_TITLE) - - mBinding = ActivityOnboarding23InstructionsBinding.inflate(layoutInflater) - - mBinding.fab.applyEdgeToEdgeInsets { insets -> - bottomMargin = insets.bottom - } - - setContentView(mBinding.root) - - mBinding.skipButton.setOnClickListener { - done() - } - - mBinding.viewPager.adapter = - Onboarding23FragmentStateAdapter(supportFragmentManager, lifecycle, this) - - mBinding.dotsIndicator.attachTo(mBinding.viewPager) - - mBinding.fab.setOnClickListener { - if (isLastPage()) { - done() - } else { - mBinding.coverImage.alpha = 0F - mBinding.viewPager.currentItem++ - } - } - - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (isFirstPage()) { - finish() - } else { - mBinding.viewPager.currentItem-- - } - } - }) - - mBinding.viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - if (isLastPage()) { - mBinding.skipButton.visibility = View.INVISIBLE - val icon = ContextCompat.getDrawable(mBinding.fab.context, R.drawable.ic_done) - mBinding.fab.setImageDrawable(icon) - } else { - mBinding.skipButton.visibility = View.VISIBLE - val icon = ContextCompat.getDrawable(mBinding.fab.context, R.drawable.ic_arrow_forward_ios,) - icon?.isAutoMirrored = true - mBinding.fab.setImageDrawable(icon) - } - - } - - override fun onPageScrollStateChanged(state: Int) { - when (state) { - ViewPager2.SCROLL_STATE_DRAGGING -> mBinding.coverImage.alpha = 0F - ViewPager2.SCROLL_STATE_IDLE -> updateCoverImage() - ViewPager2.SCROLL_STATE_SETTLING -> { /* ignored */ } - } - } - }) - } - - override fun onResume() { - super.onResume() - - updateCoverImage() - } - - private fun updateCoverImage() { - when (mBinding.viewPager.currentItem) { - 0 -> mBinding.coverImage.setImageResource(R.drawable.onboarding_secure_png) - 1 -> mBinding.coverImage.setImageResource(R.drawable.onboarding_archive_png) - 2 -> mBinding.coverImage.setImageResource(R.drawable.onboarding_verify_png) - 3 -> mBinding.coverImage.setImageResource(R.drawable.onboarding_encrypt_png) - } - mBinding.coverImage.alpha = 0F - mBinding.coverImage.animate().setDuration(200L).alpha(1F).start() - } - - private fun isFirstPage(): Boolean { - return mBinding.viewPager.currentItem <= 0 - } - - private fun isLastPage(): Boolean { - val pageCount: Int = - if (mBinding.viewPager.adapter == null) 0 else mBinding.viewPager.adapter?.itemCount!! - return mBinding.viewPager.currentItem + 1 >= pageCount - } - - private fun done() { - // Hide keyboard before finishing activity - val imm = getSystemService(InputMethodManager::class.java) - currentFocus?.let { view -> - imm?.hideSoftInputFromWindow(view.windowToken, 0) - view.clearFocus() // Remove focus from any input field - } - - Prefs.didCompleteOnboarding = true - // We are moving space setup to MainActivity - startActivity(Intent(this, MainActivity::class.java)) - finish() - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23SlideFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23SlideFragment.kt deleted file mode 100644 index 1034588dc..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23SlideFragment.kt +++ /dev/null @@ -1,76 +0,0 @@ -package net.opendasharchive.openarchive.features.onboarding - -import android.content.Context -import android.os.Bundle -import android.text.Spanned -import android.text.method.LinkMovementMethod -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.StringRes -import androidx.core.text.HtmlCompat -import androidx.fragment.app.Fragment -import net.opendasharchive.openarchive.databinding.FragmentOnboarding23SlideBinding - -private const val ARG_TITLE = "title_param" -private const val ARG_SUMMARY = "summary_param" -private const val ARG_APP_LINK = "app_link_param" - -class Onboarding23SlideFragment : Fragment() { - - private var mTitle: String? = null - private var mSummary: String? = null - private var mAppLink: String? = null - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - arguments?.let { - mTitle = it.getString(ARG_TITLE) - mSummary = it.getString(ARG_SUMMARY) - mAppLink = it.getString(ARG_APP_LINK) - } - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentOnboarding23SlideBinding.inflate(inflater) - binding.title.text = mTitle - - // Format the summary text with the dynamic link - mSummary?.let { summaryTemplate -> - - val formattedSummary = summaryTemplate.format(mAppLink) - - val spannedText: Spanned = HtmlCompat.fromHtml(formattedSummary, HtmlCompat.FROM_HTML_MODE_COMPACT) - - binding.summary.text = spannedText - - binding.summary.movementMethod = LinkMovementMethod.getInstance() // Enable link clicks - } - - return binding.root - } - - companion object { - @JvmStatic - fun newInstance( - context: Context, - @StringRes title: Int, - @StringRes summary: Int, - @StringRes link: Int? = null - ) = - Onboarding23SlideFragment().apply { - arguments = Bundle().apply { - putString(ARG_TITLE, context.getString(title)) - putString(ARG_SUMMARY, context.getString(summary)) - link?.let { - putString(ARG_APP_LINK, context.getString(it)) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/OnboardingInstructionsScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/OnboardingInstructionsScreen.kt new file mode 100644 index 000000000..9945f3b9f --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/OnboardingInstructionsScreen.kt @@ -0,0 +1,359 @@ +package net.opendasharchive.openarchive.features.onboarding + +import android.content.res.Configuration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import androidx.compose.ui.zIndex +import com.tbuonomo.viewpagerdotsindicator.compose.DotsIndicator +import com.tbuonomo.viewpagerdotsindicator.compose.model.DotGraphic +import com.tbuonomo.viewpagerdotsindicator.compose.type.WormIndicatorType +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.LocalColors +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.features.onboarding.components.HtmlText +import net.opendasharchive.openarchive.features.onboarding.components.OnboardingSlide + +@Composable +fun OnboardingInstructionsScreen( + onDone: () -> Unit, +) { + val slides = listOf( + OnboardingSlide( + titleRes = R.string.intro_header_secure, + textRes = R.string.intro_text_secure, + imageRes = R.drawable.onboarding_secure_png + ), + OnboardingSlide( + titleRes = R.string.intro_header_archive, + textRes = R.string.intro_text_archive, + linkRes = R.string.intro_link_archive, + imageRes = R.drawable.onboarding_archive_png + ), + OnboardingSlide( + titleRes = R.string.intro_header_verify, + textRes = R.string.intro_text_verify, + linkRes = R.string.intro_link_verify, + imageRes = R.drawable.onboarding_verify_png + ), + OnboardingSlide( + titleRes = R.string.intro_header_encrypt, + textRes = R.string.intro_text_encrypt, + linkRes = R.string.intro_link_encrypt, + imageRes = R.drawable.onboarding_encrypt_png + ) + ) + + val pagerState = rememberPagerState(pageCount = { slides.size }) + val scope = rememberCoroutineScope() + val currentPage = pagerState.currentPage + val isLastPage = currentPage == slides.size - 1 + + // Crossfade the cover image when the settled page changes (stays visible during swipe) + var coverImageRes by remember { mutableIntStateOf(slides[currentPage].imageRes) } + val coverAlpha = remember { Animatable(1f) } + + LaunchedEffect(pagerState.currentPage) { + coverAlpha.snapTo(0f) + coverImageRes = slides[pagerState.currentPage].imageRes + coverAlpha.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 200) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + // Right side panel with tertiary background - extends to top edge behind status bars + Column( + modifier = Modifier + .align(Alignment.TopEnd) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.tertiary) + .width(120.dp) // Approximate width based on buttons + .zIndex(0f) // Behind other elements + .padding(bottom = 8.dp) // Only bottom padding, let it extend to top + ) { + // Top system bar space + Spacer( + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + ) + + // Skip button (top) - matches XML: insetTop="32dp" + marginTop="8dp" = 40dp + if (!isLastPage) { + TextButton( + onClick = onDone, + modifier = Modifier + .padding(top = 20.dp, start = 8.dp, end = 8.dp) // Match XML positioning + .align(Alignment.CenterHorizontally) + ) { + Text( + text = stringResource(R.string.skip), + color = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold // Match XML montserrat_semi_bold + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // FAB (bottom) - with navigation bar padding, proper color, and scale animation + // Matches XML behavior: scales UP when pressed and stays large until released + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 1.15f else 1f, // Scale UP when pressed (like XML) + animationSpec = tween(durationMillis = 100), + label = "fabScale" + ) + + FloatingActionButton( + onClick = { + if (isLastPage) { + onDone() + } else { + scope.launch { + pagerState.animateScrollToPage(currentPage + 1) + } + } + }, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(24.dp) + .windowInsetsPadding(WindowInsets.navigationBars) + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + containerColor = LocalColors.current.primaryBright, // Use theme color + contentColor = MaterialTheme.colorScheme.onBackground, + shape = RoundedCornerShape(8.dp), + interactionSource = interactionSource + ) { + Icon( + painter = painterResource(if (isLastPage) R.drawable.ic_done else R.drawable.ic_arrow_forward_ios), + contentDescription = if (isLastPage) "Done" else stringResource(R.string.next), + tint = Color.Black + ) + } + } + + // Main layout structure following the XML layout (weightSum=125) + Column( + modifier = Modifier + .fillMaxSize() + .zIndex(1f) // Above the right panel + ) { + // Top spacer (weight=8 of 125) - with status bar padding + Spacer( + modifier = Modifier + .weight(8f) + .windowInsetsPadding(WindowInsets.statusBars) + ) + + // Cover Image (weight=65 of 125) - extends to touch left edge and under the right panel + Box( + modifier = Modifier + .fillMaxWidth() + .weight(65f) + .padding(end = 40.dp) // Only end padding for buttons + .offset(x = (-8).dp) // Use offset instead of negative padding to extend left + ) { + Image( + painter = painterResource(coverImageRes), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = coverAlpha.value }, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart + ) + } + + // Middle spacer (weight=37 of 125) + Spacer(modifier = Modifier.weight(37f)) + + // Bottom area with dots indicator (weight=15 of 125) + // Calculate width: 4 dots * 10dp + 3 spacings * 7dp + buffer for worm animation + val indicatorWidth = (slides.size * 10.dp) + ((slides.size - 1) * 7.dp) + 30.dp + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(15f) + .padding(horizontal = 24.dp) + .windowInsetsPadding(WindowInsets.navigationBars), + contentAlignment = Alignment.CenterStart + ) { + // Dots indicator - constrained width to prevent expansion + Box( + modifier = Modifier + .width(indicatorWidth) + .wrapContentHeight() + ) { + DotsIndicator( + dotCount = slides.size, + modifier = Modifier.fillMaxWidth(), // Fill the constrained Box width + dotSpacing = 10.dp, // Match XML dotsSpacing="7dp" + pagerState = pagerState, + type = WormIndicatorType( + dotsGraphic = DotGraphic( + size = 10.dp, // Match XML dotsSize="10dp" + borderWidth = 5.dp, // Match XML dotsStrokeWidth="5dp" + borderColor = Color(0xFF666666), // Match XML c23_medium_grey + color = Color.Transparent, // Empty center for inactive dots + ), + wormDotGraphic = DotGraphic( + size = 10.dp, // Match XML dotsSize="10dp" + color = MaterialTheme.colorScheme.onBackground, // Match XML dotsColor + ) + ) + ) + } + } + } + + // ViewPager equivalent - HorizontalPager + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .zIndex(-1f) // Behind the tertiary panel so content gets clipped by it + ) { page -> + OnboardingSlideContent( + slide = slides[page], + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Composable +fun OnboardingSlideContent( + slide: OnboardingSlide, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .padding( + start = 24.dp, + end = 140.dp + ) // Increased to ensure text stays behind 120dp tertiary bar + ) { + // Top spacer (weight=8 of 125) + Spacer(modifier = Modifier.weight(8f)) + + // Image spacer (weight=65 of 125) + Spacer(modifier = Modifier.weight(65f)) + + // Content area (weight=42 of 125) + Column( + modifier = Modifier + .weight(42f) + .padding(top = 8.dp) + ) { + // Title - matches XML: textFontWeight="800" (ExtraBold), textSize="28sp" + Text( + text = stringResource(slide.titleRes).uppercase(), + style = MaterialTheme.typography.headlineSmall.copy( + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold // Match XML textFontWeight="800" + ), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Summary with HTML support + HtmlText( + textRes = slide.textRes, + linkRes = slide.linkRes, + color = MaterialTheme.colorScheme.onBackground, + linkColor = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp + ) + } + + // Bottom spacer (weight=10 of 125) + Spacer(modifier = Modifier.weight(10f)) + } +} + + +@PreviewLightDark +@Composable +private fun OnboardingInstructionsScreenPreview() { + SaveAppTheme { + OnboardingInstructionsScreen( + onDone = {}, + ) + } +} + + +@PreviewLightDark +@Composable +private fun OnboardingSlideContentPreview() { + val sampleSlide = OnboardingSlide( + titleRes = R.string.intro_header_secure, + textRes = R.string.intro_text_secure, + imageRes = R.drawable.onboarding_secure_png + ) + SaveAppTheme { + OnboardingSlideContent( + slide = sampleSlide, + modifier = Modifier.fillMaxSize() + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/OnboardingWelcomeScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/OnboardingWelcomeScreen.kt new file mode 100644 index 000000000..234995ff1 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/OnboardingWelcomeScreen.kt @@ -0,0 +1,224 @@ +package net.opendasharchive.openarchive.features.onboarding + +import android.content.res.Configuration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseOutBounce +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.features.onboarding.components.AutoSizeText +import net.opendasharchive.openarchive.features.onboarding.components.StyledTitleText + +@Composable +fun OnboardingWelcomeScreen( + onGetStartedClick: () -> Unit = {} +) { + val arrowOffset = remember { Animatable(0f) } + + LaunchedEffect(Unit) { + delay(3000) // Initial delay like XML (startDelay = 3000) + while (true) { + // Animate from 0 → 25 → 0 with bounce effect, matching XML animation + // XML: duration = 2000ms with BounceInterpolator + arrowOffset.animateTo( + targetValue = 25f, + animationSpec = tween( + durationMillis = 1000, + easing = EaseOutBounce // Similar to BounceInterpolator + ) + ) + arrowOffset.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 1000, + easing = EaseOutBounce + ) + ) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .systemBarsPadding() + ) { + // Main content area (equivalent to LinearLayout above nav_block in XML) + // XML has weightSum="63" with title_block weight="55", leaving 8 units at bottom + Column( + modifier = Modifier + .weight(1f) // Take remaining space above nav block + .fillMaxWidth() + .padding(start = 16.dp, top = 24.dp, end = 32.dp), + verticalArrangement = Arrangement.Center // Match XML android:gravity="center_vertical" + ) { + + Spacer(modifier = Modifier.weight(4f)) + + // Logo (weight=8 of 55) - wrap_content width like XML + Box( + modifier = Modifier + .weight(8f) + .fillMaxWidth(), + contentAlignment = Alignment.BottomStart // Push logo down to create space above + ) { + Image( + painter = painterResource(R.drawable.save_oa), + contentDescription = null, + modifier = Modifier.wrapContentSize(), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.tertiary), + contentScale = ContentScale.Fit + ) + } + + // Spacer (weight=8 of 55) + Spacer(modifier = Modifier.weight(4f)) + + // Four title texts (weight=8 each of 55) + StyledTitleText( + text = stringResource(R.string.intro_header_secure), + modifier = Modifier + .fillMaxWidth() + .weight(8f) + ) + + StyledTitleText( + text = stringResource(R.string.intro_header_archive), + modifier = Modifier + .fillMaxWidth() + .weight(8f) + ) + + StyledTitleText( + text = stringResource(R.string.intro_header_verify), + modifier = Modifier + .fillMaxWidth() + .weight(8f) + ) + + StyledTitleText( + text = stringResource(R.string.intro_header_encrypt), + modifier = Modifier + .fillMaxWidth() + .weight(8f) + ) + + // Spacer (weight=2 of 55) + Spacer(modifier = Modifier.weight(2f)) + + // Description text (weight=5 of 55) - with auto text sizing like XML + AutoSizeText( + text = stringResource(R.string.secure_mobile_media_preservation), + modifier = Modifier + .fillMaxWidth() + .weight(5f), + color = MaterialTheme.colorScheme.onBackground, + minFontSize = 16.sp, + maxFontSize = 500.sp, + fontWeight = FontWeight.SemiBold + ) + + // Bottom spacer (weight=8 of 63 total in XML) + // XML has weightSum="63" with content using weight="55", leaving 8 units for bottom spacing + Spacer(modifier = Modifier.weight(8f)) + } + + // Nav block at bottom (equivalent to android:layout_alignParentBottom="true") + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalAlignment = Alignment.Top + ) { + // Hand image with negative margins like XML + Image( + painter = painterResource(R.drawable.onboarding23_app_hand), + contentDescription = null, + modifier = Modifier + .width(170.dp) + .height(200.dp) + .offset(x = (-8).dp, y = (8).dp) // Negative margins like XML + ) + + // Get Started section + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + onClick = onGetStartedClick, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + .padding(horizontal = 24.dp) + .padding(top = 16.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = stringResource(R.string.get_started), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(end = 16.dp) + ) + + Image( + painter = painterResource(R.drawable.onboarding23_arrow_right), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .graphicsLayer { translationX = arrowOffset.value }, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) + ) + } + } + } + + +} + + +@Preview(name = "Welcome Screen Light", showBackground = true) +@Preview(name = "Welcome Screen Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun OnboardingWelcomeScreenPreviewLight() { + SaveAppTheme { + OnboardingWelcomeScreen( + onGetStartedClick = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt deleted file mode 100644 index 28a67d9d1..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt +++ /dev/null @@ -1,151 +0,0 @@ -package net.opendasharchive.openarchive.features.onboarding - -import android.os.Bundle -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.navigation.NavController -import androidx.navigation.NavGraph -import androidx.navigation.findNavController -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupActionBarWithNavController -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivitySpaceSetupBinding -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.ToolbarConfigurable -import net.opendasharchive.openarchive.features.settings.FoldersFragment - -enum class StartDestination { - SPACE_TYPE, - SPACE_LIST, - ADD_FOLDER, - ADD_NEW_FOLDER, - ARCHIVED_FOLDER_LIST -} - -class SpaceSetupActivity : BaseActivity() { - - companion object { - const val EXTRA_FOLDER_ID = "folder_id" - const val EXTRA_FOLDER_NAME = "folder_name" - const val LABEL_START_DESTINATION = "start_destination" - } - - private lateinit var binding: ActivitySpaceSetupBinding - - private lateinit var navController: NavController - private lateinit var navGraph: NavGraph - private lateinit var appBarConfiguration: AppBarConfiguration - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - binding = ActivitySpaceSetupBinding.inflate(layoutInflater) - - setContentView(binding.root) - - setupToolbar( - showBackButton = true - ) - - -// onBackButtonPressed { -// -// if (supportFragmentManager.backStackEntryCount > 1) { -// // We still have fragments in the back stack to pop -// supportFragmentManager.popBackStack() -// true // fully handled here -// } else { -// // No more fragments left in back stack, let the system finish Activity -// false -// } -// } - - - initSpaceSetupNavigation() - } - - private fun initSpaceSetupNavigation() { - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.space_nav_host_fragment) as NavHostFragment - - navController = navHostFragment.navController - navGraph = navController.navInflater.inflate(R.navigation.app_nav_graph) - - val startDestinationString = - intent.getStringExtra(LABEL_START_DESTINATION) ?: StartDestination.SPACE_TYPE.name - val startDestination = StartDestination.valueOf(startDestinationString) - when (startDestination) { - StartDestination.SPACE_LIST -> { - navGraph.setStartDestination(R.id.fragment_space_list) - } - StartDestination.ADD_FOLDER -> { - navGraph.setStartDestination(R.id.fragment_add_folder) - } - StartDestination.ADD_NEW_FOLDER -> { - navGraph.setStartDestination(R.id.fragment_create_new_folder) - } - StartDestination.ARCHIVED_FOLDER_LIST -> { - navGraph.setStartDestination(R.id.fragment_folders) - - // Pass arguments from intent to navigation graph - val showArchived = intent.getBooleanExtra(FoldersFragment.EXTRA_SHOW_ARCHIVED, false) - val selectedSpaceId = intent.getLongExtra(FoldersFragment.EXTRA_SELECTED_SPACE_ID, -1L) - val selectedProjectId = intent.getLongExtra(FoldersFragment.EXTRA_SELECTED_PROJECT_ID, -1L) - - val bundle = bundleOf( - "show_archived" to showArchived, - "selected_space_id" to selectedSpaceId, - "selected_project_id" to selectedProjectId - ) - - navController.setGraph(navGraph, bundle) - return // Early return to avoid setting graph again - } - else -> { - navGraph.setStartDestination(R.id.fragment_space_setup) - } - } - navController.graph = navGraph - - appBarConfiguration = AppBarConfiguration(emptySet()) - setupActionBarWithNavController(navController, appBarConfiguration) - } - - fun updateToolbarFromFragment(fragment: Fragment) { - if (fragment is ToolbarConfigurable) { - val title = fragment.getToolbarTitle() - val subtitle = fragment.getToolbarSubtitle() - val showBackButton = fragment.shouldShowBackButton() - setupToolbar(title = title, showBackButton = showBackButton) - supportActionBar?.subtitle = subtitle - } else { - // Default toolbar configuration if fragment doesn't implement interface - setupToolbar(title = "Servers", showBackButton = true) - supportActionBar?.subtitle = null - } - } - - override fun onSupportNavigateUp(): Boolean { - return findNavController(R.id.space_nav_host_fragment).navigateUp() || super.onSupportNavigateUp() - } - - override fun onDestroy() { - super.onDestroy() - - // Clear any pending messages or callbacks in the main thread handler - window?.decorView?.handler?.removeCallbacksAndMessages(null) - binding.commonAppBar.commonToolbar.setNavigationOnClickListener(null) - - // Remove navigation reference (if using Jetpack Navigation) - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.space_nav_host_fragment) as? NavHostFragment - navHostFragment?.let { - it.childFragmentManager.fragments.forEach { fragment -> - fragment.view?.let { view -> - view.handler?.removeCallbacksAndMessages(null) - } - } - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/AutoSizeText.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/AutoSizeText.kt new file mode 100644 index 000000000..18c91e4f4 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/AutoSizeText.kt @@ -0,0 +1,42 @@ +package net.opendasharchive.openarchive.features.onboarding.components + +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +@Composable +fun AutoSizeText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + minFontSize: TextUnit = 12.sp, + maxFontSize: TextUnit = 100.sp, + fontWeight: FontWeight = FontWeight.Normal, + textAlign: TextAlign = TextAlign.Unspecified +) { + // Use theme typography as base to get correct font family + val baseStyle = MaterialTheme.typography.bodyLarge + + BasicText( + text = text, + modifier = modifier, + autoSize = TextAutoSize.StepBased( + minFontSize = minFontSize, + maxFontSize = maxFontSize, + ), + maxLines = 1, + style = baseStyle.copy( + color = color, + fontWeight = fontWeight, + textAlign = textAlign, + lineHeight = minFontSize // Use tighter line height to match XML + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/CustomDotsIndicator.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/CustomDotsIndicator.kt new file mode 100644 index 000000000..b99472813 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/CustomDotsIndicator.kt @@ -0,0 +1,55 @@ +package net.opendasharchive.openarchive.features.onboarding.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme + +@Composable +fun CustomDotsIndicator( + totalDots: Int, + selectedIndex: Int, + modifier: Modifier = Modifier +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(totalDots) { index -> + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background( + if (index == selectedIndex) + MaterialTheme.colorScheme.onBackground + else + Color(0xFF666666) // c23_medium_grey equivalent + ) + ) + } + } +} + +@Preview(name = "Dots Indicator", showBackground = true) +@Composable +private fun CustomDotsIndicatorPreview() { + SaveAppTheme { + CustomDotsIndicator( + totalDots = 4, + selectedIndex = 1, + modifier = Modifier.padding(16.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/HtmlText.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/HtmlText.kt new file mode 100644 index 000000000..e717c9c0c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/HtmlText.kt @@ -0,0 +1,157 @@ +package net.opendasharchive.openarchive.features.onboarding.components + +import android.graphics.Typeface +import android.text.Spanned +import android.text.style.StyleSpan +import android.text.style.URLSpan +import android.text.style.UnderlineSpan +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import androidx.core.text.HtmlCompat + +@Composable +fun HtmlText( + textRes: Int, + modifier: Modifier = Modifier, + linkRes: Int? = null, + color: Color = MaterialTheme.colorScheme.onBackground, + linkColor: Color = MaterialTheme.colorScheme.onBackground, + fontSize: TextUnit = 16.sp, +) { + val context = LocalContext.current + val text = stringResource(textRes) + val linkText = linkRes?.let { stringResource(it) } + + if (linkText != null && text.contains("%1\$s")) { + // Format text with link, just like XML version does + val formattedText = text.format(linkText) + + // Use HtmlCompat.fromHtml() like XML version for perfect parity + val spanned = HtmlCompat.fromHtml( + formattedText, + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + + // Convert Spanned to AnnotatedString + val annotatedString = spannedToAnnotatedString(spanned, color, linkColor) + + // Use BasicText instead of deprecated ClickableText + BasicText( + text = annotatedString, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize, + fontWeight = FontWeight.Normal + ), + modifier = modifier + ) + } else { + Text( + text = text, + color = color, + fontSize = fontSize, + modifier = modifier, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Normal) + ) + } +} + +// Convert Android Spanned (from HtmlCompat.fromHtml) to Compose AnnotatedString +// This ensures perfect parity with XML version which also uses HtmlCompat.fromHtml +private fun spannedToAnnotatedString( + spanned: Spanned, + color: Color, + linkColor: Color +): AnnotatedString { + return buildAnnotatedString { + val text = spanned.toString() + append(text) + + // Apply default color first so link/span styles can override it + addStyle( + SpanStyle(color = color), + start = 0, + end = text.length + ) + + // Get all spans from the Spanned text + val spans = spanned.getSpans(0, spanned.length, Any::class.java) + + for (span in spans) { + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + + when (span) { + // Handle URL spans (links) + is URLSpan -> { + addLink( + LinkAnnotation.Url( + url = span.url, + styles = TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline + ) + ) + ), + start = start, + end = end + ) + } + // Handle bold text + is StyleSpan -> { + when (span.style) { + Typeface.BOLD -> { + addStyle( + SpanStyle(fontWeight = FontWeight.Bold), + start = start, + end = end + ) + } + Typeface.ITALIC -> { + addStyle( + SpanStyle(fontStyle = FontStyle.Italic), + start = start, + end = end + ) + } + Typeface.BOLD_ITALIC -> { + addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic + ), + start = start, + end = end + ) + } + } + } + // Handle underline + is UnderlineSpan -> { + addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start = start, + end = end + ) + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/OnboardingSlide.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/OnboardingSlide.kt new file mode 100644 index 000000000..ac88aeb5e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/OnboardingSlide.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.features.onboarding.components + +// Data class for onboarding slides +data class OnboardingSlide( + val titleRes: Int, + val textRes: Int, + val linkRes: Int? = null, + val imageRes: Int +) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/StyledTitleText.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/StyledTitleText.kt new file mode 100644 index 000000000..44e85ab4e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/components/StyledTitleText.kt @@ -0,0 +1,70 @@ +package net.opendasharchive.openarchive.features.onboarding.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.sp + +@Composable +fun StyledTitleText( + text: String, + modifier: Modifier = Modifier +) { + // Use theme typography to get Montserrat font family + val baseStyle = MaterialTheme.typography.displayLarge + + // Match the horizontal LinearLayout with weightSum="1000" + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + // Spacer with weight 65 of 1000 + Spacer(modifier = Modifier.weight(65f)) + + // Text with weight 870 of 1000 + val styledText = buildAnnotatedString { + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.tertiary)) { + append(text.firstOrNull()?.toString() ?: "") + } + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onBackground)) { + append(text.drop(1)) + } + } + + BasicText( + text = styledText, + modifier = Modifier + .weight(870f) + .fillMaxHeight() + .wrapContentSize(Alignment.CenterStart) + .graphicsLayer { + scaleX = 1.15f + scaleY = 1.15f + }, + style = baseStyle.copy( + fontSize = 60.sp, + fontWeight = FontWeight.Black, // textFontWeight="900" + lineHeight = 60.sp // Tighter line height to match XML + ), + autoSize = TextAutoSize.StepBased( + minFontSize = 30.sp, + maxFontSize = 60.sp + ), + maxLines = 1 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/C2paScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/C2paScreen.kt new file mode 100644 index 000000000..cac538106 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/C2paScreen.kt @@ -0,0 +1,248 @@ +package net.opendasharchive.openarchive.features.settings + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.LaunchedEffect +import androidx.core.content.ContextCompat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextDecoration +import net.opendasharchive.openarchive.features.onboarding.components.HtmlText +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.features.core.ComposeAppBar +import net.opendasharchive.openarchive.features.settings.passcode.components.DefaultScaffold +import net.opendasharchive.openarchive.util.Prefs + +@Composable +fun C2paScreen( + onNavigateBack: () -> Unit +) { + SaveAppTheme { + DefaultScaffold( + topAppBar = { + ComposeAppBar( + title = stringResource(R.string.c2pa_content_authenticity), + onNavigateBack = { + onNavigateBack() + } + ) + }, + ) { + C2paScreenContent() + } + } +} + +@Composable +fun C2paScreenContent() { + val context = LocalContext.current + + var useC2pa by remember { + mutableStateOf(Prefs.useC2pa) + } + + val msgAutoDisabled = stringResource(R.string.c2pa_auto_disabled) + val msgEnabled = stringResource(R.string.c2pa_enabled) + val msgLocationDenied = stringResource(R.string.c2pa_location_permission_denied) + + // Check if location permission is granted + fun hasLocationPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + // Check permission when screen is shown + LaunchedEffect(Unit) { + if (useC2pa && !hasLocationPermission()) { + // C2PA is enabled but permission is missing - auto-disable + useC2pa = false + Prefs.useC2pa = false + Toast.makeText(context, msgAutoDisabled, Toast.LENGTH_LONG).show() + AppLogger.w("[C2PA] Auto-disabled due to missing location permission") + } + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + val locationGranted = results[Manifest.permission.ACCESS_FINE_LOCATION] == true + val phoneStateGranted = results[Manifest.permission.READ_PHONE_STATE] == true + + if (locationGranted) { + // Location is required; phone state is optional (enables cell tower data) + useC2pa = true + Prefs.useC2pa = true + if (phoneStateGranted) { + AppLogger.d("[C2PA] Enabled with location + cell tower data") + } else { + AppLogger.d("[C2PA] Enabled with location only (cell tower data unavailable)") + } + Toast.makeText(context, msgEnabled, Toast.LENGTH_SHORT).show() + } else { + // Location is required — disable C2PA if denied + useC2pa = false + Prefs.useC2pa = false + Toast.makeText(context, msgLocationDenied, Toast.LENGTH_LONG).show() + AppLogger.w("[C2PA] Disabled due to location permission denial") + } + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.prefs_use_c2pa_title), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(R.string.prefs_use_c2pa_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = useC2pa, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = MaterialTheme.colorScheme.tertiary, + ), + onCheckedChange = { enabled -> + if (enabled) { + if (hasLocationPermission()) { + // Location already granted — enable immediately, then also + // request phone state if not yet granted (for cell tower data) + useC2pa = true + Prefs.useC2pa = true + AppLogger.d("[C2PA] Enabled (location already granted)") + if (ContextCompat.checkSelfPermission( + context, Manifest.permission.READ_PHONE_STATE + ) != PackageManager.PERMISSION_GRANTED + ) { + permissionLauncher.launch( + arrayOf(Manifest.permission.READ_PHONE_STATE) + ) + } + } else { + // Request location (required) + phone state (optional) together + AppLogger.d("[C2PA] Requesting location + phone state permissions") + permissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.READ_PHONE_STATE + ) + ) + } + } else { + useC2pa = false + Prefs.useC2pa = false + AppLogger.d("[C2PA] Disabled by user") + } + } + ) + } + } + + item { + Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + HtmlText( + textRes = R.string.prefs_use_c2pa_description, + linkRes = R.string.c2pa_learn_more_url, + fontSize = 11.sp, + linkColor = MaterialTheme.colorScheme.tertiary + ) + } + } + + item { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp) + ) { + Card( + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_info_outline), + tint = MaterialTheme.colorScheme.error, + contentDescription = null + ) + Text( + text = AnnotatedString.fromHtml( + stringResource(R.string.c2pa_warning_text), + linkStyles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontStyle = FontStyle.Italic, + color = Color.Blue + ) + ) + ), + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun C2paScreenPreview() { + DefaultScaffoldPreview { + C2paScreenContent() + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/ConsentActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/ConsentActivity.kt deleted file mode 100644 index 69b112609..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/ConsentActivity.kt +++ /dev/null @@ -1,59 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.os.Bundle -import android.view.MenuItem -import androidx.activity.addCallback -import net.opendasharchive.openarchive.CleanInsightsManager -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityConsentBinding -import net.opendasharchive.openarchive.features.core.BaseActivity - -class ConsentActivity: BaseActivity() { - - private lateinit var mBinding: ActivityConsentBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivityConsentBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setupToolbar(getString(R.string.health_checks)) - - mBinding.explainer.text = getString( - R.string.by_allowing_health_checks_you_give_permission_for_the_app_to_securely_send_health_check_data_to_the_s_team, - getString(R.string.app_name)) - - mBinding.cancelButton.setOnClickListener { - finishDeny() - } - - mBinding.okButton.setOnClickListener { - finish() - - CleanInsightsManager.grant() - } - - onBackPressedDispatcher.addCallback { - finishDeny() - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - finishDeny() - - return true - } - } - - return super.onOptionsItemSelected(item) - } - - private fun finishDeny() { - finish() - - CleanInsightsManager.deny() - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/CreativeCommonsLicenseManager.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/CreativeCommonsLicenseManager.kt index f7572b98a..ffb4bb3b0 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/CreativeCommonsLicenseManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/CreativeCommonsLicenseManager.kt @@ -1,10 +1,5 @@ package net.opendasharchive.openarchive.features.settings -import net.opendasharchive.openarchive.databinding.ContentCcBinding -import net.opendasharchive.openarchive.util.extensions.openBrowser -import net.opendasharchive.openarchive.util.extensions.styleAsLink -import net.opendasharchive.openarchive.util.extensions.toggle - object CreativeCommonsLicenseManager { private const val CC_DOMAIN = "creativecommons.org" @@ -55,151 +50,4 @@ object CreativeCommonsLicenseManager { return String.format(CC_LICENSE_URL_FORMAT, CC_DOMAIN, license) } - - fun initialize( - binding: ContentCcBinding, - currentLicense: String? = null, - enabled: Boolean = true, - update: ((license: String?) -> Unit)? = null - ) { - configureInitialState(binding, currentLicense, enabled) - - with(binding) { - swCcEnabled.setOnCheckedChangeListener { _, isChecked -> - setShowLicenseOptions(binding, isChecked) - if (!isChecked) { - // When main CC is disabled, reset ALL license options - swCc0Enabled.isChecked = false - swAllowRemix.isChecked = false - swRequireShareAlike.isChecked = false - swAllowCommercial.isChecked = false - } - val license = getSelectedLicenseUrl(binding) - update?.invoke(license) - } - - swCc0Enabled.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - // When CC0 is enabled, disable other options - swAllowRemix.isChecked = false - swRequireShareAlike.isChecked = false - swAllowCommercial.isChecked = false - } else { - // When CC0 is disabled, re-enable other switches - swAllowRemix.isEnabled = enabled - swAllowCommercial.isEnabled = enabled - } - val license = getSelectedLicenseUrl(binding) - update?.invoke(license) - } - - swAllowRemix.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - swCc0Enabled.isChecked = false // Disable CC0 when other options are enabled - } - swRequireShareAlike.isEnabled = isChecked - val license = getSelectedLicenseUrl(binding) - update?.invoke(license) - } - - swRequireShareAlike.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - swCc0Enabled.isChecked = false // Disable CC0 when other options are enabled - } - val license = getSelectedLicenseUrl(binding) - update?.invoke(license) - } - - swAllowCommercial.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - swCc0Enabled.isChecked = false // Disable CC0 when other options are enabled - } - val license = getSelectedLicenseUrl(binding) - update?.invoke(license) - } - - tvLicenseUrl.setOnClickListener { - it?.context?.openBrowser(tvLicenseUrl.text.toString()) - } - - btLearnMore.styleAsLink() - btLearnMore.setOnClickListener { - it?.context?.openBrowser("https://creativecommons.org/about/cclicenses/") - } - } - } - - private fun configureInitialState( - binding: ContentCcBinding, - currentLicense: String?, - enabled: Boolean = true - ) { - val isCc0 = currentLicense?.contains("publicdomain/zero", true) ?: false - val isCC = currentLicense?.contains("creativecommons.org/licenses", true) ?: false - val isActive = isCc0 || isCC - - with(binding) { - swCcEnabled.isChecked = isActive - setShowLicenseOptions(this, isActive) - - if (isCc0) { - // CC0 license detected - swCc0Enabled.isChecked = true - swAllowRemix.isChecked = false - swRequireShareAlike.isChecked = false - swAllowCommercial.isChecked = false - } else if (isCC && currentLicense != null) { - // Regular CC license detected - swCc0Enabled.isChecked = false - swAllowRemix.isChecked = !(currentLicense.contains("-nd", true)) - swRequireShareAlike.isChecked = !(currentLicense.contains("-nd", true)) && currentLicense.contains("-sa", true) - swAllowCommercial.isChecked = !(currentLicense.contains("-nc", true)) - } else { - // No license - swCc0Enabled.isChecked = false - swAllowRemix.isChecked = false // Changed from true to fix auto-enable bug - swRequireShareAlike.isChecked = false - swAllowCommercial.isChecked = false - } - - swRequireShareAlike.isEnabled = swAllowRemix.isChecked - tvLicenseUrl.text = currentLicense - tvLicenseUrl.styleAsLink() - - // Set enabled states - swCcEnabled.isEnabled = enabled - swCc0Enabled.isEnabled = enabled - swAllowRemix.isEnabled = enabled - swRequireShareAlike.isEnabled = isActive && enabled && swAllowRemix.isChecked - swAllowCommercial.isEnabled = enabled - } - } - - fun getSelectedLicenseUrl(cc: ContentCcBinding): String? { - val license = generateLicenseUrl( - ccEnabled = cc.swCcEnabled.isChecked, - allowRemix = cc.swAllowRemix.isChecked, - requireShareAlike = cc.swRequireShareAlike.isChecked, - allowCommercial = cc.swAllowCommercial.isChecked, - cc0Enabled = cc.swCc0Enabled.isChecked - ) - - // Auto-disable ShareAlike when Remix is disabled (preserve existing behavior) - if (!cc.swAllowRemix.isChecked) { - cc.swRequireShareAlike.isChecked = false - } - - cc.tvLicenseUrl.text = license - cc.tvLicenseUrl.styleAsLink() - - return license - } - - private fun setShowLicenseOptions(binding: ContentCcBinding, isVisible: Boolean) { - binding.rowCc0.toggle(isVisible) - binding.rowAllowRemix.toggle(isVisible) - binding.rowShareAlike.toggle(isVisible) - binding.rowCommercialUse.toggle(isVisible) - binding.tvLicenseUrl.toggle(isVisible) - } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailFragment.kt deleted file mode 100644 index be436e4c8..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailFragment.kt +++ /dev/null @@ -1,133 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentFolderDetailBinding -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.UiImage -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog - -class FolderDetailFragment : BaseFragment() { - - private val args: FolderDetailFragmentArgs by navArgs() - - private lateinit var mProject: Project - private lateinit var mBinding: FragmentFolderDetailBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mBinding = FragmentFolderDetailBinding.inflate(inflater, container, false) - return mBinding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // Get arguments from Navigation Component - mProject = Project.getById(args.currentProjectId)!! - - setupEditorListeners() - setupButtonListeners() - setupLicenseManager() - updateUi() - } - - private fun setupEditorListeners() { - mBinding.folderName.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE) { - val newName = mBinding.folderName.text.toString() - - if (newName.isNotBlank()) { - mProject.description = newName - mProject.save() - - mBinding.folderName.hint = newName - } - } - - false - } - } - - private fun setupButtonListeners() { - - mBinding.btRemove.setOnClickListener { - showDeleteFolderConfirmDialog() - } - - mBinding.btArchive.setOnClickListener { - unArchiveProject() - } - } - - private fun setupLicenseManager() { - CreativeCommonsLicenseManager.initialize(mBinding.cc, null) { - mProject.licenseUrl = it - mProject.save() - } - } - - private fun showDeleteFolderConfirmDialog() { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Error - icon = UiImage.DrawableResource(R.drawable.ic_trash) - title = UiText.StringResource(R.string.remove_from_app) - message = UiText.StringResource(R.string.action_remove_project) - destructiveButton { - text = UiText.StringResource(R.string.lbl_remove) - action = { - mProject.delete() - - findNavController().popBackStack() - } - } - neutralButton { - text = UiText.StringResource(R.string.lbl_Cancel) - action = { - dialogManager.dismissDialog() - } - } - } - } - - private fun unArchiveProject() { - mProject.isArchived = false - mProject.save() - - findNavController().popBackStack() - } - - private fun updateUi() { - - mBinding.folderName.isEnabled = !mProject.isArchived - mBinding.folderName.hint = mProject.description - mBinding.folderName.setText(mProject.description) - - mBinding.btArchive.setText(if (mProject.isArchived) - R.string.action_unarchive_project else - R.string.action_archive_project) - - val global = mProject.space?.license != null - - if (global) { - mBinding.cc.tvCcLabel.setText(R.string.set_the_same_creative_commons_license_for_all_folders_on_this_server) - } - - CreativeCommonsLicenseManager.initialize(mBinding.cc, mProject.licenseUrl, !mProject.isArchived && !global) - } - - override fun getToolbarTitle(): String = "Edit Folder" - override fun shouldShowBackButton() = true -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailScreen.kt new file mode 100644 index 000000000..a02f687be --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailScreen.kt @@ -0,0 +1,144 @@ +package net.opendasharchive.openarchive.features.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomTextField + +@Composable +fun FolderDetailScreen( + viewModel: FolderDetailViewModel, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + FolderDetailScreenContent( + state = state, + onAction = viewModel::onAction + ) +} + +@Composable +fun FolderDetailScreenContent( + state: FolderDetailState, + onAction: (FolderDetailAction) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(48.dp)) + + // Label + Text( + text = stringResource(R.string.folder_name), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = MontserratFontFamily, + color = colorResource(R.color.colorOnBackground), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Folder name text field + CustomTextField( + value = state.folderName, + onValueChange = { onAction(FolderDetailAction.UpdateFolderName(it)) }, + placeholder = stringResource(R.string.folder_name), + modifier = Modifier.fillMaxWidth(), + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + enabled = !state.isArchived + ) + + Spacer(modifier = Modifier.height(48.dp)) + + // Buttons container centered + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Archive/Unarchive button + TextButton( + onClick = { + if (state.isArchived) { + onAction(FolderDetailAction.UnarchiveProject) + } else { + onAction(FolderDetailAction.ArchiveProject) + } + }, + modifier = Modifier.wrapContentWidth() + ) { + Text( + text = if (state.isArchived) { + stringResource(R.string.action_unarchive_project) + } else { + stringResource(R.string.action_archive_project) + }, + fontSize = 18.sp, + fontFamily = MontserratFontFamily, + color = colorResource(R.color.colorOnPrimaryContainer) + ) + } + + // Remove from app button + Text( + text = stringResource(R.string.remove_from_app), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = MontserratFontFamily, + color = colorResource(R.color.red_bg), + modifier = Modifier + .wrapContentWidth() + .clickable { onAction(FolderDetailAction.ShowRemoveDialog) } + .padding(16.dp) + ) + } + } +} + +@PreviewLightDark +@Composable +private fun FolderDetailScreenPreview() { + SaveAppTheme { + FolderDetailScreenContent( + state = FolderDetailState( + projectId = 1L, + folderName = "My Folder", + isArchived = true, + ), + onAction = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailViewModel.kt new file mode 100644 index 000000000..e1586319e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailViewModel.kt @@ -0,0 +1,132 @@ +package net.opendasharchive.openarchive.features.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.UiImage +import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.features.main.ui.Navigator + +data class FolderDetailState( + val projectId: Long = -1L, + val folderName: String = "", + val isArchived: Boolean = false +) + +sealed interface FolderDetailAction { + data class UpdateFolderName(val name: String) : FolderDetailAction + data object ArchiveProject : FolderDetailAction + data object UnarchiveProject : FolderDetailAction + data object ShowRemoveDialog : FolderDetailAction +} + +class FolderDetailViewModel( + private val route: AppRoute.FolderDetailRoute, + private val navigator: Navigator, + private val projectRepository: ProjectRepository, + private val dialogManager: DialogStateManager +) : ViewModel() { + + private val projectId: Long = route.currentProjectId + private var archive: Archive? = null + + private val _uiState = MutableStateFlow(FolderDetailState(projectId = projectId)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + if (projectId != -1L) { + loadProject() + } + } + + private fun loadProject() { + viewModelScope.launch { + archive = projectRepository.getProject(projectId) + archive?.let { arc -> + _uiState.update { + it.copy( + folderName = arc.description ?: "", + isArchived = arc.isArchived + ) + } + } + } + } + + fun onAction(action: FolderDetailAction) { + when (action) { + is FolderDetailAction.UpdateFolderName -> { + val name = action.name.trim() + if (name.isNotBlank()) { + archive?.let { + val updated = it.copy(description = name) + viewModelScope.launch { + projectRepository.addProject(updated) + archive = updated + _uiState.update { state -> state.copy(folderName = name) } + } + } + } + } + + is FolderDetailAction.ArchiveProject -> { + archive?.let { + viewModelScope.launch { + projectRepository.archiveProject(it.id, isArchived = true) + navigator.navigateBack() + } + } + } + + is FolderDetailAction.UnarchiveProject -> { + archive?.let { + viewModelScope.launch { + projectRepository.archiveProject(it.id, isArchived = false) + navigator.navigateBack() + } + } + } + + is FolderDetailAction.ShowRemoveDialog -> { + showRemoveConfirmDialog() + } + + } + } + + private fun removeProject() { + archive?.let { + viewModelScope.launch { + projectRepository.deleteProject(it.id) + navigator.navigateBack() + } + } + } + + private fun showRemoveConfirmDialog() { + dialogManager.showDialog { + type = DialogType.Error + title = UiText.Resource(R.string.remove_from_app) + message = UiText.Resource(R.string.action_remove_project) + icon = UiImage.DrawableResource(R.drawable.ic_trash) + destructiveButton { + text = UiText.Resource(R.string.lbl_remove) + action = { removeProject() } + } + neutralButton { + text = UiText.Resource(R.string.lbl_Cancel) + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersFragment.kt deleted file mode 100644 index ef70129b5..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersFragment.kt +++ /dev/null @@ -1,147 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.core.view.MenuProvider -import androidx.lifecycle.Lifecycle -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import net.opendasharchive.openarchive.FolderAdapter -import net.opendasharchive.openarchive.FolderAdapterListener -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentFoldersBinding -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.util.extensions.toggle - -class FoldersFragment : BaseFragment(), FolderAdapterListener, MenuProvider { - - companion object Companion { - const val EXTRA_SHOW_ARCHIVED = "show_archived" - const val EXTRA_SELECTED_SPACE_ID = "selected_space_id" - const val EXTRA_SELECTED_PROJECT_ID = "SELECTED_PROJECT_ID" - } - - private lateinit var mBinding: FragmentFoldersBinding - private lateinit var mAdapter: FolderAdapter - - private var mArchived = true - private var mSelectedSpaceId = -1L - private var mSelectedProjectId: Long = -1L - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mBinding = FragmentFoldersBinding.inflate(inflater, container, false) - return mBinding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // Get arguments from Navigation component - mArchived = arguments?.getBoolean("show_archived", false) ?: false - mSelectedSpaceId = arguments?.getLong("selected_space_id", -1L) ?: -1L - mSelectedProjectId = arguments?.getLong("selected_project_id", -1L) ?: -1L - - activity?.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - - setupRecyclerView() - setupButtons() - } - - private fun setupRecyclerView() { - mAdapter = FolderAdapter(context = requireContext(), listener = this, isArchived = mArchived) - mBinding.rvProjects.layoutManager = LinearLayoutManager(requireContext()) - mBinding.rvProjects.adapter = mAdapter - } - - private fun setupButtons() { - mBinding.btViewArchived.apply { - toggle(!mArchived) - setOnClickListener { - // Navigation logic should be handled by parent activity/fragment - // For now, we'll keep the intent approach but this should be replaced with proper navigation - val i = Intent(requireContext(), FoldersFragment::class.java) - i.putExtra(EXTRA_SHOW_ARCHIVED, true) - startActivity(i) - } - } - } - - override fun onResume() { - super.onResume() - refreshProjects() - activity?.invalidateOptionsMenu() - } - - private fun refreshProjects() { - val projects = if (mArchived) { - Space.current?.archivedProjects - } else { - Space.current?.projects?.filter { !it.isArchived } - } ?: emptyList() - - mAdapter.update(projects) - - if (projects.isEmpty()) { - mBinding.rvProjects.visibility = View.GONE - mBinding.tvNoFolders.visibility = View.VISIBLE - } else { - mBinding.rvProjects.visibility = View.VISIBLE - mBinding.tvNoFolders.visibility = View.GONE - } - } - - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_folder_list, menu) - } - - override fun onPrepareMenu(menu: Menu) { - val archivedCount = Space.get(mSelectedSpaceId)?.archivedProjects?.size ?: 0 - menu.findItem(R.id.action_archived_folders)?.isVisible = (!mArchived && archivedCount > 0) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_archived_folders -> { - navigateToArchivedFolders() - true - } - else -> false - } - } - - private fun navigateToArchivedFolders() { - val intent = Intent(requireContext(), FoldersFragment::class.java).apply { - putExtra(EXTRA_SHOW_ARCHIVED, true) - putExtra(EXTRA_SELECTED_SPACE_ID, mSelectedSpaceId) - putExtra(EXTRA_SELECTED_PROJECT_ID, mSelectedProjectId) - } - startActivity(intent) - } - - - override fun projectClicked(project: Project) { - val resultIntent = Intent() - resultIntent.putExtra("SELECTED_FOLDER_ID", project.id) - requireActivity().setResult(android.app.Activity.RESULT_OK, resultIntent) - - // Navigate using Navigation Component with Safe Args - val action = FoldersFragmentDirections.actionFragmentFoldersToFragmentFolderDetail(currentProjectId = project.id) - findNavController().navigate(action) - } - - override fun getToolbarTitle(): String = getString(if (mArchived) R.string.archived_folders else R.string.folders) - override fun shouldShowBackButton() = true -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersScreen.kt new file mode 100644 index 000000000..e19ebd3d3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersScreen.kt @@ -0,0 +1,196 @@ +package net.opendasharchive.openarchive.features.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions + +@Composable +fun FoldersScreen( + viewModel: FoldersViewModel, + onNavigateToFolderDetail: (Long) -> Unit = {}, + onNavigateToArchivedFolders: (Long) -> Unit = {} +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is FoldersEvent.NavigateToFolderDetail -> { + onNavigateToFolderDetail(event.projectId) + } + is FoldersEvent.NavigateToArchivedFolders -> { + onNavigateToArchivedFolders(event.spaceId) + } + } + } + } + + FoldersScreenContent( + state = state, + onAction = viewModel::onAction + ) +} + +@Composable +fun FoldersScreenContent( + state: FoldersState, + onAction: (FoldersAction) -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + if (state.folders.isEmpty()) { + // Empty state + Text( + text = stringResource(R.string.lbl_no_archived_folders), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurface + ), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .padding(32.dp) + ) + } else { + // Folder list + LazyColumn( + modifier = Modifier + .fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + bottom = if (state.showArchivedMenuItem) { + // Button height + button padding + navigation bar insets + 80.dp + } else { + // Just navigation bar insets when no button + 0.dp + } + ) + ) { + items(state.folders) { archive -> + FolderItem( + archive = archive, + onClick = { onAction(FoldersAction.FolderClicked(archive)) } + ) + } + + // Add bottom spacer for navigation bar + item { + Spacer( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + ) + } + } + } + + // View Archived button at bottom + if (state.showArchivedMenuItem) { + Button( + onClick = { onAction(FoldersAction.ViewArchivedClicked) }, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 24.dp, vertical = 16.dp) + .heightIn(ThemeDimensions.touchable), + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = colorResource(R.color.black) + ) + ) { + Text( + stringResource(R.string.view_archived_folders), + style = MaterialTheme.typography.titleLarge + ) + } + } + } +} + +@Composable +fun FolderItem( + archive: Archive, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + .heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_folder_new), + contentDescription = null, + tint = colorResource(R.color.colorOnBackground), + modifier = Modifier.size(24.dp) + ) + + Text( + text = archive.description ?: "", + style = MaterialTheme.typography.titleLarge, + color = colorResource(R.color.colorOnBackground), + modifier = Modifier.weight(1f).padding(horizontal = 10.dp) + ) + } +} + +@PreviewLightDark +@Composable +private fun FoldersScreenPreview() { + SaveAppTheme { + FoldersScreenContent( + state = FoldersState( + folders = listOf( + Archive(id = 1, description = "Folder 1", vaultId = 1L), + Archive(id = 2, description = "Folder 2", vaultId = 1L), + Archive(id = 3, description = "Very Long Folder Name That Should Wrap", vaultId = 1L) + ), + isArchived = true, + showArchivedMenuItem = true, + ), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersViewModel.kt new file mode 100644 index 000000000..922f805f5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersViewModel.kt @@ -0,0 +1,89 @@ +package net.opendasharchive.openarchive.features.settings + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.features.main.ui.Navigator +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.* +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute + +data class FoldersState( + val folders: List = emptyList(), + val isArchived: Boolean = false, + val showArchivedMenuItem: Boolean = false, + val archivedCount: Int = 0 +) + +sealed interface FoldersAction { + data class FolderClicked(val archive: Archive) : FoldersAction + data object ViewArchivedClicked : FoldersAction +} + +sealed interface FoldersEvent { + data class NavigateToFolderDetail(val projectId: Long) : FoldersEvent + data class NavigateToArchivedFolders(val spaceId: Long) : FoldersEvent +} + +class FoldersViewModel( + private val route: AppRoute.FolderListRoute, + private val navigator: Navigator, + private val projectRepository: ProjectRepository, + private val spaceRepository: SpaceRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(FoldersState(isArchived = route.showArchived)) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + init { + observeData() + } + + private fun observeData() { + val spaceFlow = route.spaceId?.let { spaceRepository.observeSpace(it) } + ?: spaceRepository.observeCurrentSpace() + + combine( + spaceFlow, + _uiState.map { it.isArchived }.distinctUntilChanged() + ) { space, isArchived -> + space to isArchived + }.flatMapLatest { (space, isArchived) -> + if (space == null) { + kotlinx.coroutines.flow.flowOf(emptyList() to 0) + } else { + combine( + projectRepository.observeProjects(space.id, isArchived), + projectRepository.observeProjects(space.id, true) // For archived count + ) { folders, archivedProjects -> + folders to archivedProjects.size + } + } + }.onEach { (folders, archivedCount) -> + _uiState.update { + it.copy( + folders = folders, + archivedCount = archivedCount, + showArchivedMenuItem = !_uiState.value.isArchived && archivedCount > 0 + ) + } + }.launchIn(viewModelScope) + } + + fun onAction(action: FoldersAction) { + when (action) { + is FoldersAction.FolderClicked -> navigator.navigateTo(AppRoute.FolderDetailRoute(action.archive.id)) + + is FoldersAction.ViewArchivedClicked -> navigator.navigateTo(AppRoute.FolderListRoute(showArchived = true, spaceId = route.spaceId)) + } + } + +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/ProofModeScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/ProofModeScreen.kt deleted file mode 100644 index 5d21d775a..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/ProofModeScreen.kt +++ /dev/null @@ -1,199 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.content.Intent -import android.net.Uri -import android.provider.Settings -import android.text.Spanned -import android.text.style.URLSpan -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.fromHtml -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.text.HtmlCompat -import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.switchPreference -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.features.core.ComposeAppBar -import net.opendasharchive.openarchive.features.settings.passcode.components.DefaultScaffold - -@Composable -fun ProofModeScreen( - onNavigateBack: () -> Unit -) { - - SaveAppTheme { - - - DefaultScaffold( - topAppBar = { - ComposeAppBar( - title = stringResource(R.string.proofmode), - onNavigationAction = { - onNavigateBack() - } - ) - }, - - ) { - - ProofModeScreenContent() - } - } -} - -@Composable -fun ProofModeScreenContent() { - val context = LocalContext.current - val uriHandler = LocalUriHandler.current - - - val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission() - ) { isGranted -> - if (!isGranted) { - Toast.makeText(context, "Please allow all permissions", Toast.LENGTH_LONG).show() - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - val uri = Uri.fromParts("package", context.packageName, null) - intent.data = uri - context.startActivity(intent) - } - } - - val useProofModeKeyEncryption = remember { mutableStateOf(false) } - - val spannedText: Spanned = HtmlCompat.fromHtml( - stringResource( - R.string.prefs_use_proofmode_description, - "https://proofmode.org/" - ), HtmlCompat.FROM_HTML_MODE_COMPACT - ) - - // AnnotatedString Builder - val annotatedString = buildAnnotatedString { - append(spannedText.toString()) - spannedText.getSpans(0, spannedText.length, URLSpan::class.java) - .forEach { urlSpan -> - val start = spannedText.getSpanStart(urlSpan) - val end = spannedText.getSpanEnd(urlSpan) - addStringAnnotation( - tag = "URL", - annotation = urlSpan.url, - start = start, - end = end - ) - addStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.tertiary, - textDecoration = TextDecoration.Underline - ), - start = start, - end = end - ) - } - } - - ProvidePreferenceLocals { - val useProofModeKey = stringResource(R.string.pref_key_use_proof_mode) - - LazyColumn(modifier = Modifier.fillMaxSize()) { - - - switchPreference( - key = useProofModeKey, - defaultValue = false, - enabled = { - true - }, - rememberState = { - useProofModeKeyEncryption - }, - title = { Text(stringResource(R.string.prefs_use_proofmode_title)) }, - summary = { Text(stringResource(R.string.prefs_use_proofmode_summary)) } - ) - - item { - Box(modifier = Modifier.padding(horizontal = 16.dp)) { - Text(annotatedString, fontSize = 11.sp) - } - } - - item { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp) - ) { - - Card( - shape = RoundedCornerShape(8.dp) - ) { - - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Icons.Outlined.Info, - tint = MaterialTheme.colorScheme.error, - contentDescription = null - ) - Text( - text = AnnotatedString.fromHtml( - stringResource(R.string.proof_mode_warning_text), - linkStyles = TextLinkStyles( - style = SpanStyle( - textDecoration = TextDecoration.Underline, - fontStyle = FontStyle.Italic, - color = Color.Blue - ) - ) - ), - ) - } - } - } - } - } - } -} - -@Preview -@Composable -private fun ProofModeScreenPreview() { - DefaultScaffoldPreview { - ProofModeScreenContent() - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/ProofModeSettingsActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/ProofModeSettingsActivity.kt deleted file mode 100644 index a6d3652c8..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/ProofModeSettingsActivity.kt +++ /dev/null @@ -1,269 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.Manifest -import android.app.Activity -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.provider.Settings -import android.text.Spanned -import android.text.method.LinkMovementMethod -import android.view.MenuItem -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat -import androidx.core.text.HtmlCompat -import androidx.fragment.app.FragmentActivity -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat -import com.permissionx.guolindev.PermissionX -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivitySettingsContainerBinding -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.util.Hbks -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.ProofModeHelper -import org.witness.proofmode.crypto.pgp.PgpUtils -import timber.log.Timber -import java.io.IOException -import java.util.UUID -import javax.crypto.SecretKey - -class ProofModeSettingsActivity : BaseActivity() { - - class Fragment : PreferenceFragmentCompat() { - - private val enrollBiometrics = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - findPreference(Prefs.USE_PROOFMODE_KEY_ENCRYPTION)?.let { - MainScope().launch { - enableProofModeKeyEncryption(it) - } - } - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.prefs_proof_mode, rootKey) - - val proofModeSwitch = findPreference(Prefs.USE_PROOFMODE) - - // Check if permission is granted - val hasPermission = ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED - && ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED - - if (!hasPermission) { - proofModeSwitch?.isChecked = false // Uncheck if permission not granted - Prefs.putBoolean(Prefs.USE_PROOFMODE, false) - Toast.makeText(requireContext(), getString(R.string.phone_permission_required), Toast.LENGTH_LONG).show() - } else { - proofModeSwitch?.isChecked = Prefs.getBoolean(Prefs.USE_PROOFMODE, false) - } - - getPrefByKey(R.string.pref_key_use_proof_mode)?.setOnPreferenceChangeListener { preference, newValue -> - if (newValue as Boolean) { - PermissionX.init(this) - .permissions( Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) - .onExplainRequestReason { _, _ -> - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - val uri = Uri.fromParts("package", activity?.packageName, null) - intent.data = uri - activity?.startActivity(intent) - } - .request { allGranted, _, _ -> - if (!allGranted) { - (preference as? SwitchPreferenceCompat)?.isChecked = false - Toast.makeText( - activity, - "Please allow all permissions", - Toast.LENGTH_LONG - ).show() - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - val uri = Uri.fromParts("package", activity?.packageName, null) - intent.data = uri - activity?.startActivity(intent) - } else { - (preference as? SwitchPreferenceCompat)?.isChecked = true - } - } - } - - true - } - - val pkePreference = - findPreference(Prefs.USE_PROOFMODE_KEY_ENCRYPTION) - val activity = activity - val availability = Hbks.deviceAvailablity(requireContext()) - - if (activity != null && availability !is Hbks.Availability.Unavailable) { - pkePreference?.isSingleLineTitle = false - - pkePreference?.setTitle( - when (Hbks.biometryType(activity)) { - Hbks.BiometryType.StrongBiometry -> R.string.prefs_proofmode_key_encryption_title_biometrics - - Hbks.BiometryType.DeviceCredential -> R.string.prefs_proofmode_key_encryption_title_passcode - - else -> R.string.prefs_proofmode_key_encryption_title_all - } - ) - - pkePreference?.setOnPreferenceChangeListener { _, newValue -> - if (newValue as Boolean) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && availability is Hbks.Availability.Enroll) { - enrollBiometrics.launch(Hbks.enrollIntent(availability.type)) - } else { - enableProofModeKeyEncryption(pkePreference) - } - } else { - if (Prefs.proofModeEncryptedPassphrase != null) { - Prefs.proofModeEncryptedPassphrase = null - - Hbks.removeKey() - - ProofModeHelper.restartApp(activity) - } - } - - true - } - } else { - pkePreference?.isVisible = false - } - } - - private fun enableProofModeKeyEncryption(pkePreference: SwitchPreferenceCompat) { - - val key = Hbks.loadKey() ?: Hbks.createKey() - - if (key != null && Prefs.proofModeEncryptedPassphrase == null) { - createPassphrase(key, activity) { - if (it != null) { - ProofModeHelper.removePgpKey(requireContext()) - - // We need to kill the app and restart, - // since the ProofMode singleton loads the passphrase - // in its singleton constructor. Urgh. - ProofModeHelper.restartApp(requireActivity()) - } else { - Hbks.removeKey() - - pkePreference.isChecked = false - } - } - } else { - // What?? shouldn't happen if enrolled with a PIN or Fingerprint - } - } - - - private fun getPrefByKey(key: Int): T? { - return findPreference(getString(key)) - } - } - - private lateinit var mBinding: ActivitySettingsContainerBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivitySettingsContainerBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setupToolbar(getString(R.string.proofmode)) - - - supportFragmentManager - .beginTransaction() - .replace(mBinding.container.id, Fragment()) - .commit() - -// setContent { - -// } - - - val learnModeInfo = - getString(R.string.prefs_use_proofmode_description, getString(R.string.intro_link_verify)) - - - val spannedText: Spanned = - HtmlCompat.fromHtml(learnModeInfo, HtmlCompat.FROM_HTML_MODE_COMPACT) - - mBinding.proofModeLearnMode.text = spannedText - - mBinding.proofModeLearnMode.movementMethod = - LinkMovementMethod.getInstance() // Enable link clicks - - mBinding.infoCardText.text = HtmlCompat.fromHtml( - getString(R.string.proof_mode_warning_text), - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - - return super.onOptionsItemSelected(item) - } - - companion object { - - private fun shareKey(activity: Activity) { - try { - val mPgpUtils = PgpUtils.getInstance(activity, null) - val pubKey = mPgpUtils.publicKeyString - - if (pubKey.isNotEmpty()) { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "text/plain" - intent.putExtra(Intent.EXTRA_TEXT, pubKey) - activity.startActivity(intent) - } - } catch (ioe: IOException) { - Timber.d("error publishing key") - } - } - - private fun createPassphrase( - key: SecretKey, - activity: FragmentActivity?, - completed: (passphrase: String?) -> Unit - ) { - val passphrase = UUID.randomUUID().toString() - - Hbks.encrypt(passphrase, key, activity) { ciphertext, _ -> - if (ciphertext == null) { - return@encrypt completed(null) - } - - Prefs.proofModeEncryptedPassphrase = ciphertext - - Hbks.decrypt( - Prefs.proofModeEncryptedPassphrase, - key, - activity - ) { decrpytedPassphrase, _ -> - if (decrpytedPassphrase == null || decrpytedPassphrase != passphrase) { - Prefs.proofModeEncryptedPassphrase = null - - return@decrypt completed(null) - } - - completed(passphrase) - } - } - } - } -} - - diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt deleted file mode 100644 index e1f5e5bcc..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt +++ /dev/null @@ -1,286 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.lifecycle.lifecycleScope -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.analytics.api.AnalyticsManager -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity -import net.opendasharchive.openarchive.features.onboarding.StartDestination -import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository -import net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupActivity -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.Theme -import net.opendasharchive.openarchive.util.extensions.getVersionName -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.activityViewModel - -class SettingsFragment : PreferenceFragmentCompat() { - - private val passcodeRepository by inject() - private val analyticsManager: AnalyticsManager by inject() - private val dialogManager: DialogStateManager by activityViewModel() - - private var passcodePreference: SwitchPreferenceCompat? = null - - private val activityResultLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - val passcodeEnabled = result.data?.getBooleanExtra("passcode_enabled", false) ?: false - passcodePreference?.isChecked = passcodeEnabled - } else { - passcodePreference?.isChecked = false - } - } - -// override fun onCreateView( -// inflater: LayoutInflater, -// container: ViewGroup?, -// savedInstanceState: Bundle? -// ): View? { -// return ComposeView(requireContext()).apply { -// // Dispose of the Composition when the view's LifecycleOwner -// // is destroyed -// setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) -// setContent { -// Theme { -// SettingsScreen() -// } -// } -// } -// } - - override fun onCreatePreferences( - savedInstanceState: Bundle?, - rootKey: String? - ) { - setPreferencesFromResource(R.xml.prefs_general, rootKey) - - - passcodePreference = findPreference(Prefs.PASSCODE_ENABLED) - - passcodePreference?.setOnPreferenceChangeListener { _, newValue -> - val enabled = newValue as Boolean - if (enabled) { - // Launch PasscodeSetupActivity - val intent = Intent(context, PasscodeSetupActivity::class.java) - activityResultLauncher.launch(intent) - } else { - // Show confirmation dialog - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - title = UiText.StringResource(R.string.disable_passcode_dialog_title) - message = UiText.StringResource(R.string.disable_passcode_dialog_msg) - positiveButton { - text = UiText.StringResource(R.string.answer_yes) - action = { - passcodeRepository.clearPasscode() - passcodePreference?.isChecked = false - - // Update the FLAG_SECURE dynamically - (activity as? BaseActivity)?.updateScreenshotPrevention() - } - } - neutralButton { - action = { - passcodePreference?.isChecked = true - } - } - } - } - // Return false to avoid the preference updating immediately - false - } - - findPreference(Prefs.PROHIBIT_SCREENSHOTS)?.setOnPreferenceChangeListener { _, newValue -> - val enabled = newValue as Boolean - Prefs.prohibitScreenshots = enabled - - // Add breadcrumb for crash analysis - AppLogger.breadcrumb("Feature Toggled", "screenshot_prevention: $enabled") - - // Track feature toggle - lifecycleScope.launch { - analyticsManager.trackFeatureToggled("screenshot_prevention", enabled) - } - - if (activity is BaseActivity) { - // make sure this gets settings change gets applied instantly - // (all other activities rely on the hook in BaseActivity.onResume()) - (activity as BaseActivity).updateScreenshotPrevention() - } - - true - } - - getPrefByKey(R.string.pref_media_servers)?.setOnPreferenceClickListener { - val intent = Intent(context, SpaceSetupActivity::class.java) - intent.putExtra(SpaceSetupActivity.LABEL_START_DESTINATION, StartDestination.SPACE_LIST.name) - startActivity(intent) - true - } - - getPrefByKey(R.string.pref_media_folders)?.setOnPreferenceClickListener { - val intent = Intent(context, SpaceSetupActivity::class.java) - intent.putExtra(SpaceSetupActivity.LABEL_START_DESTINATION, StartDestination.ARCHIVED_FOLDER_LIST.name) - intent.putExtra(FoldersFragment.EXTRA_SHOW_ARCHIVED, true) - startActivity(intent) - true - } - - getPrefByKey(R.string.pref_key_proof_mode)?.setOnPreferenceClickListener { - startActivity(Intent(context, ProofModeSettingsActivity::class.java)) - true - } - - findPreference(Prefs.USE_TOR)?.setOnPreferenceChangeListener { _, newValue -> - val enabled = newValue as Boolean - Prefs.useTor = enabled - - // Add breadcrumb for crash analysis - AppLogger.breadcrumb("Feature Toggled", "tor: $enabled") - - // Track feature toggle - lifecycleScope.launch { - analyticsManager.trackFeatureToggled("tor", enabled) - } - - //torViewModel.updateTorServiceState() - true - } - - getPrefByKey(R.string.pref_key_use_tor)?.apply { - isEnabled = true - - setOnPreferenceClickListener { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Info - iconColor = dialogManager.requireResourceProvider().getColor(R.color.colorTertiary) - title = UiText.StringResource(R.string.tor_disabled_title) - message = UiText.StringResource(R.string.tor_disabled_message) - positiveButton { - text = UiText.StringResource(R.string.tor_download_btn_label) - action = { - // Launch the Tor download activity - val intent = Intent(Intent.ACTION_VIEW, Prefs.TOR_DOWNLOAD_URL) - startActivity(intent) - } - } - neutralButton { - text = UiText.StringResource(android.R.string.cancel) - } - } - true - } - - setOnPreferenceChangeListener { _, newValue -> - false - } - } - - findPreference(Prefs.THEME)?.setOnPreferenceChangeListener { _, newValue -> - Theme.set(Theme.get(newValue as? String)) - true - } - - // Retrieve the switch preference - val darkModeSwitch = getPrefByKey(R.string.pref_key_use_dark_mode) - - // Get the saved dark mode preference - val isDarkModeEnabled = Prefs.getBoolean(getString(R.string.pref_key_use_dark_mode), false) - - // Set the switch state based on the saved preference - darkModeSwitch?.isChecked = isDarkModeEnabled - - getPrefByKey(R.string.pref_key_use_dark_mode)?.setOnPreferenceChangeListener { pref, newValue -> - val useDarkMode = newValue as Boolean - val theme = if (useDarkMode) Theme.DARK else Theme.LIGHT - Theme.set(theme) - // Save the preference - Prefs.putBoolean(getString(R.string.pref_key_use_dark_mode), useDarkMode) - - // Add breadcrumb for crash analysis - AppLogger.breadcrumb("Feature Toggled", "dark_mode: $useDarkMode") - - // Track feature toggle - lifecycleScope.launch { - analyticsManager.trackFeatureToggled("dark_mode", useDarkMode) - } - - true - } - - findPreference(Prefs.UPLOAD_WIFI_ONLY)?.setOnPreferenceChangeListener { _, newValue -> - val enabled = newValue as Boolean - Prefs.uploadWifiOnly = enabled - - // Add breadcrumb for crash analysis - AppLogger.breadcrumb("Feature Toggled", "wifi_only_upload: $enabled") - - // Track feature toggle - lifecycleScope.launch { - analyticsManager.trackFeatureToggled("wifi_only_upload", enabled) - } - - val intent = - Intent(Prefs.UPLOAD_WIFI_ONLY).apply { putExtra("value", enabled) } - // Replace with shared ViewModel + LiveData - // LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(intent) - true - } - - val packageManager = requireActivity().packageManager - val versionText = packageManager.getVersionName(requireActivity().packageName) - - getPrefByKey(R.string.pref_key_app_version)?.summary = versionText - } - - private fun getPrefByKey(key: Int): T? { - return findPreference(getString(key)) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val savedScrollY = Prefs.getInt("settings_scroll_position", 0) - scrollTo(savedScrollY) - } - - override fun onResume() { - super.onResume() - val savedScrollY = Prefs.getInt("settings_scroll_position", 0) - scrollTo(savedScrollY) - } - - private fun scrollTo(savedScrollY: Int) { - // Post to ensure RecyclerView is fully laid out with items - listView.post { - val currentScrollY = listView.computeVerticalScrollOffset() - val scrollDelta = savedScrollY - currentScrollY - AppLogger.i("SettingsFragment - scrolling from $currentScrollY to $savedScrollY (delta: $scrollDelta)") - listView.scrollBy(0, scrollDelta) - } - } - - override fun onPause() { - super.onPause() - - // Save current scroll position to Prefs - val scrollY = listView.computeVerticalScrollOffset() - AppLogger.i("SettingsFragment onPause - saving scroll position: $scrollY") - Prefs.putInt("settings_scroll_position", scrollY) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsScreen.kt index aa2002d30..a23377463 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsScreen.kt @@ -1,133 +1,696 @@ package net.opendasharchive.openarchive.features.settings +import android.Manifest +import android.app.Activity import android.content.Context import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController -import kotlinx.serialization.Serializable +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.preference.PreferenceManager +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import me.zhanghai.compose.preference.MapPreferences +import me.zhanghai.compose.preference.Preference +import me.zhanghai.compose.preference.PreferenceTheme import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.listPreference import me.zhanghai.compose.preference.preference import me.zhanghai.compose.preference.preferenceCategory -import me.zhanghai.compose.preference.switchPreference +import me.zhanghai.compose.preference.preferenceTheme +import me.zhanghai.compose.preference.rememberPreferenceState +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.SaveTextStyles +import net.opendasharchive.openarchive.features.core.BaseComposeActivity +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository +import net.opendasharchive.openarchive.features.settings.passcode.passcode_entry.PasscodeEntryActivity +import net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupActivity +import net.opendasharchive.openarchive.services.tor.TorServiceManager +import net.opendasharchive.openarchive.services.tor.TorStatus +import net.opendasharchive.openarchive.util.Prefs +import net.opendasharchive.openarchive.util.Theme +import net.opendasharchive.openarchive.util.extensions.getVersionName +import org.koin.compose.koinInject @Composable fun SettingsScreen( - onNavigateToCache: () -> Unit = {} + onNavigateToSpaceList: () -> Unit = {}, + onNavigateToArchivedFolders: () -> Unit = {}, + onNavigateToCache: () -> Unit = {}, + onNavigateToC2pa: () -> Unit = {}, ) { - + val dialogManager: DialogStateManager = koinInject() val context = LocalContext.current - ProvidePreferenceLocals { - LazyColumn(modifier = Modifier.fillMaxSize()) { - // Secure Category - preferenceCategory(title = { Text("Secure") }, key = "secure") - - switchPreference( - key = "pref_app_passcode", - defaultValue = false, - title = { Text("Lock app with passcode") }, - summary = { Text("6 digit passcode") }) - - // Archive Category - preferenceCategory(title = { Text("Archive") }, key = "archive") - preference( - key = "pref_media_servers", - title = { Text("Media Servers") }, - summary = { Text("Add or remove media servers") }) - preference( - key = "pref_media_folders", - title = { Text("Media Folders") }, - summary = { Text("Add or remove media folders") }) - preference( - key = "pref_media_cache", - title = { Text("Media Cache") }, - summary = { Text("View media cache") }, - onClick = { - onNavigateToCache() + if (LocalInspectionMode.current) { + PreviewSettingsScreen() + return + } + + val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } + val preferenceFlow = remember(sharedPreferences) { createFilteredPreferenceFlow(sharedPreferences) } + val analyticsManager: AnalyticsManager = koinInject() + val passcodeRepository: PasscodeRepository = koinInject() + val torServiceManager: TorServiceManager = koinInject() + + val coroutineScope = rememberCoroutineScope() + val appVersion = remember { context.packageManager.getVersionName(context.packageName) } + + val torStatus by torServiceManager.torStatus.collectAsStateWithLifecycle() + + // Trigger Tor verification when the service reports it is bootstrapped (TorStatus.On). + val isTorBootstrapped = torStatus is TorStatus.On + LaunchedEffect(isTorBootstrapped) { + if (isTorBootstrapped) { + torServiceManager.verifyTorConnection() + } + } + + // Derive toggle appearance from the actual Tor status. + val torChecked = torStatus is TorStatus.Verified + val torInteractive = torStatus !is TorStatus.Starting && torStatus !is TorStatus.On + + val torSummary = when (val s = torStatus) { + is TorStatus.Starting, is TorStatus.On -> + stringResource(R.string.tor_status_connecting) + is TorStatus.Verified -> { + val info = s.info + if (info.exitCountry != null) + stringResource(R.string.tor_status_verified_with_country, info.exitIp, info.exitCountry) + else + stringResource(R.string.tor_status_verified, info.exitIp) + } + is TorStatus.Error -> + stringResource(R.string.tor_status_error, s.message) + else -> stringResource(R.string.prefs_use_tor_summary) + } + + // Launcher for notification permission required by the Tor foreground service (Android 13+). + val notificationPermLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { _ -> + // Start Tor whether the user granted or denied – same behaviour as the old fragment. + Prefs.useTor = true + AppLogger.breadcrumb("Feature Toggled", "tor: true") + coroutineScope.launch { analyticsManager.trackFeatureToggled("tor", true) } + torServiceManager.start() + } + + fun startTor() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = ContextCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + notificationPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + return + } + } + Prefs.useTor = true + AppLogger.breadcrumb("Feature Toggled", "tor: true") + coroutineScope.launch { analyticsManager.trackFeatureToggled("tor", true) } + torServiceManager.start() + } + + fun stopTor() { + Prefs.useTor = false + AppLogger.breadcrumb("Feature Toggled", "tor: false") + coroutineScope.launch { analyticsManager.trackFeatureToggled("tor", false) } + torServiceManager.stop() + } + + ProvidePreferenceLocals(flow = preferenceFlow, theme = savePreferenceTheme()) { + + val settingStrings = SettingsStrings( + titleSecure = stringResource(R.string.pref_title_secure), + titleArchive = stringResource(R.string.pref_title_archive), + titleVerify = stringResource(R.string.intro_header_verify), + titleEncrypt = stringResource(R.string.intro_header_encrypt), + titleGeneral = stringResource(R.string.general), + titlePasscode = stringResource(R.string.passcode_lock_app), + titleWifiOnly = stringResource(R.string.only_upload_media_when_you_are_connected_to_wi_fi), + titleMediaServers = stringResource(R.string.pref_title_media_servers), + summaryMediaServers = stringResource(R.string.pref_summary_media_servers), + titleMediaFolders = stringResource(R.string.pref_title_media_folders), + summaryMediaFolders = stringResource(R.string.pref_summary_media_folders), + titleC2pa = stringResource(R.string.c2pa_content_authenticity), + summaryC2pa = stringResource(R.string.prefs_use_c2pa_summary), + titleTor = stringResource(R.string.prefs_use_tor_title), + titleDarkMode = stringResource(R.string.pref_title_dark_mode), + titleLanguage = stringResource(R.string.pref_title_language), + summaryLanguage = stringResource(R.string.pref_summary_language), + titleAbout = stringResource(R.string.save_by_open_archive), + summaryAbout = stringResource(R.string.discover_the_save_app), + titlePrivacy = stringResource(R.string.pref_title_privacy_policy), + summaryPrivacy = stringResource(R.string.pref_summary_privacy_policy), + titleVersion = stringResource(R.string.pref_title_version), + ) + + val passcodeState = rememberPreferenceState(key = Prefs.PASSCODE_ENABLED, defaultValue = Prefs.passcodeEnabled) + val wifiOnlyState = rememberPreferenceState(key = Prefs.UPLOAD_WIFI_ONLY, defaultValue = Prefs.uploadWifiOnly) + val darkModeKey = stringResource(R.string.pref_key_use_dark_mode) + val darkModeState = rememberPreferenceState(key = darkModeKey, defaultValue = Prefs.getBoolean(darkModeKey, false)) + + val passcodeLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val passcodeEnabled = + result.resultCode == Activity.RESULT_OK && + (result.data?.getBooleanExtra(PasscodeSetupActivity.EXTRA_PASSCODE_ENABLED, false) ?: false) + passcodeState.value = passcodeEnabled + (context as? BaseComposeActivity)?.updateScreenshotPrevention() + } + + // Launched when the user wants to DISABLE the passcode — requires verifying current passcode first + val passcodeVerifyLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + passcodeRepository.clearPasscode() + passcodeState.value = false + (context as? BaseComposeActivity)?.updateScreenshotPrevention() + } else { + // Verification failed or cancelled — revert toggle + passcodeState.value = true } + } + + SettingsScreenContent( + strings = settingStrings, + appVersion = appVersion, + passcodeState = passcodeState, + wifiOnlyState = wifiOnlyState, + torChecked = torChecked, + torInteractive = torInteractive, + torSummary = torSummary, + darkModeState = darkModeState, + onPasscodeToggle = { newValue -> + if (newValue) { + passcodeLauncher.launch(Intent(context, PasscodeSetupActivity::class.java)) + } else { + passcodeState.value = true + passcodeVerifyLauncher.launch( + Intent(context, PasscodeEntryActivity::class.java).apply { + putExtra(PasscodeEntryActivity.EXTRA_VERIFY_MODE, true) + } + ) + } + }, + onWifiOnlyToggle = { newValue -> + wifiOnlyState.value = newValue + Prefs.uploadWifiOnly = newValue + AppLogger.breadcrumb("Feature Toggled", "wifi_only_upload: $newValue") + coroutineScope.launch { analyticsManager.trackFeatureToggled("wifi_only_upload", newValue) } + }, + onTorToggle = { enable -> + if (enable) startTor() else stopTor() + }, + onDarkModeToggle = { enabled -> + darkModeState.value = enabled + Theme.set(if (enabled) Theme.DARK else Theme.LIGHT) + Prefs.putBoolean(darkModeKey, enabled) + AppLogger.breadcrumb("Feature Toggled", "dark_mode: $enabled") + coroutineScope.launch { analyticsManager.trackFeatureToggled("dark_mode", enabled) } + }, + onLanguageClick = { openAppLanguageSettings(context) }, + onMediaServersClick = onNavigateToSpaceList, + onMediaFoldersClick = onNavigateToArchivedFolders, + onC2paClick = onNavigateToC2pa, + onAboutClick = { openUrl(context, "https://open-archive.org/save") }, + onPrivacyClick = { openUrl(context, "https://open-archive.org/privacy") }, + onNavigateToCache = onNavigateToCache, + ) + } +} + +@Composable +private fun SettingsScreenContent( + strings: SettingsStrings, + appVersion: String, + passcodeState: MutableState, + wifiOnlyState: MutableState, + torChecked: Boolean, + torInteractive: Boolean, + torSummary: String, + darkModeState: MutableState, + onPasscodeToggle: (Boolean) -> Unit, + onWifiOnlyToggle: (Boolean) -> Unit, + onTorToggle: (Boolean) -> Unit, + onDarkModeToggle: (Boolean) -> Unit, + onLanguageClick: () -> Unit, + onMediaServersClick: () -> Unit, + onMediaFoldersClick: () -> Unit, + onC2paClick: () -> Unit, + onAboutClick: () -> Unit, + onPrivacyClick: () -> Unit, + onNavigateToCache: () -> Unit, +) { + val rowModifier = Modifier.fillMaxWidth() + + LazyColumn(modifier = Modifier.fillMaxSize()) { + + // ── Secure ──────────────────────────────────────────────────────────── + preferenceCategory(key = "category_secure", title = { PreferenceCategoryTitle(text = strings.titleSecure) }) + item(key = "passcode") { + SwitchPreference( + state = passcodeState, + title = { PreferenceTitle(text = strings.titlePasscode, maxLines = 2) }, + modifier = rowModifier, + onToggle = onPasscodeToggle, ) + } + sectionDivider("divider_secure") - // Verify Category - preferenceCategory(title = { Text("Verify") }, key = "verify") - preference( - key = "proof_mode", title = { Text("Proof Mode") }) - - // Encrypt Category - preferenceCategory(title = { Text("Encrypt") }, key = "encrypt") - switchPreference( - key = "use_tor", - defaultValue = false, - title = { Text("Use Tor") }, - summary = { Text("Enable Tor for encryption") }) - - // General Category - preferenceCategory(title = { Text("General") }, key = "general") - switchPreference( - key = "upload_wifi_only", - defaultValue = false, - title = { Text("Upload over Wi-Fi only") }, - summary = { Text("Only upload media when connected to Wi-Fi") }) - listPreference( - key = "theme", - title = { Text("Theme") }, - summary = { Text("Choose app theme") }, - values = listOf( - "light" to "Light", "dark" to "Dark", "system" to "System Default" - ), - defaultValue = "system" + // ── Archive ─────────────────────────────────────────────────────────── + preferenceCategory(key = "category_archive", title = { PreferenceCategoryTitle(text = strings.titleArchive) }) + item(key = "wifi_only") { + SwitchPreference( + state = wifiOnlyState, + title = { PreferenceTitle(text = strings.titleWifiOnly, maxLines = 2) }, + modifier = rowModifier, + onToggle = onWifiOnlyToggle, ) + } + preference( + key = "media_servers", + title = { PreferenceTitle(text = strings.titleMediaServers) }, + summary = { PreferenceSummary(text = strings.summaryMediaServers) }, + modifier = rowModifier, + onClick = onMediaServersClick, + ) + preference( + key = "media_folders", + title = { PreferenceTitle(text = strings.titleMediaFolders) }, + summary = { PreferenceSummary(text = strings.summaryMediaFolders) }, + modifier = rowModifier, + onClick = onMediaFoldersClick, + ) + sectionDivider("divider_archive") + + // ── Verify ──────────────────────────────────────────────────────────── + preferenceCategory(key = "category_verify", title = { PreferenceCategoryTitle(text = strings.titleVerify) }) + preference( + key = "c2pa_settings", + title = { PreferenceTitle(text = strings.titleC2pa) }, + summary = { PreferenceSummary(text = strings.summaryC2pa) }, + modifier = rowModifier, + onClick = onC2paClick, + ) + sectionDivider("divider_verify") - // About Category - preferenceCategory(title = { Text("About") }, key = "about") - preference( - key = "about_app", - title = { Text("Save by Open Archive") }, - summary = { Text("Tap to view about Save App") }, - onClick = { - // Handle URL intent - openUrl(context, "https://open-archive.org/save") - }) - preference( - key = "privacy_policy", - title = { Text("Terms & Privacy Policy") }, - summary = { Text("Tap to view our Terms & Privacy Policy") }, - onClick = { - // Handle URL intent - openUrl(context, "https://open-archive.org/privacy") - }) - preference( - key = "app_version", - title = { Text("Version") }, - summary = { Text("0.7.2.4783") }, - enabled = false + // ── Encrypt ─────────────────────────────────────────────────────────── + preferenceCategory(key = "category_encrypt", title = { PreferenceCategoryTitle(text = strings.titleEncrypt) }) + item(key = "tor") { + TorSwitchPreference( + checked = torChecked, + enabled = torInteractive, + title = { PreferenceTitle(text = strings.titleTor, maxLines = 2) }, + summary = { PreferenceSummary(text = torSummary) }, + modifier = rowModifier, + onToggle = onTorToggle, ) } + sectionDivider("divider_encrypt") + + // ── General ─────────────────────────────────────────────────────────── + preferenceCategory(key = "category_general", title = { PreferenceCategoryTitle(text = strings.titleGeneral) }) + item(key = "dark_mode") { + SwitchPreference( + state = darkModeState, + title = { PreferenceTitle(text = strings.titleDarkMode) }, + modifier = rowModifier, + onToggle = onDarkModeToggle, + ) + } + preference( + key = "language", + title = { PreferenceTitle(text = strings.titleLanguage) }, + summary = { PreferenceSummary(text = strings.summaryLanguage) }, + modifier = rowModifier, + onClick = onLanguageClick, + ) + preference( + key = "about", + title = { PreferenceTitle(text = strings.titleAbout) }, + summary = { PreferenceSummary(text = strings.summaryAbout) }, + modifier = rowModifier, + onClick = onAboutClick, + ) + preference( + key = "privacy", + title = { PreferenceTitle(text = strings.titlePrivacy) }, + summary = { PreferenceSummary(text = strings.summaryPrivacy) }, + modifier = rowModifier, + onClick = onPrivacyClick, + ) + preference( + key = "version", + title = { PreferenceTitle(text = strings.titleVersion) }, + summary = { PreferenceSummary(text = appVersion) }, + modifier = rowModifier, + enabled = false, + ) } } -// Helper function for opening URLs +// ── Data ────────────────────────────────────────────────────────────────────── + +private data class SettingsStrings( + val titleSecure: String, + val titleArchive: String, + val titleVerify: String, + val titleEncrypt: String, + val titleGeneral: String, + val titlePasscode: String, + val titleWifiOnly: String, + val titleMediaServers: String, + val summaryMediaServers: String, + val titleMediaFolders: String, + val summaryMediaFolders: String, + val titleC2pa: String, + val summaryC2pa: String, + val titleTor: String, + val titleDarkMode: String, + val titleLanguage: String, + val summaryLanguage: String, + val titleAbout: String, + val summaryAbout: String, + val titlePrivacy: String, + val summaryPrivacy: String, + val titleVersion: String, +) + +// ── Composables ─────────────────────────────────────────────────────────────── + +/** + * A Switch preference whose checked/enabled state is driven by external values rather than + * a MutableState. Used for the Tor toggle which derives its state from TorServiceManager. + */ +@Composable +private fun TorSwitchPreference( + checked: Boolean, + enabled: Boolean, + title: @Composable () -> Unit, + summary: @Composable (() -> Unit)? = null, + modifier: Modifier = Modifier.fillMaxWidth(), + onToggle: (Boolean) -> Unit, +) { + Preference( + title = title, + summary = summary, + enabled = enabled, + onClick = { onToggle(!checked) }, + modifier = modifier, + widgetContainer = { + val theme = me.zhanghai.compose.preference.LocalPreferenceTheme.current + Switch( + checked = checked, + onCheckedChange = { onToggle(it) }, + enabled = enabled, + modifier = Modifier.padding(start = theme.horizontalSpacing, end = 24.dp), + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = MaterialTheme.colorScheme.tertiary, + ) + ) + } + ) +} + +@Composable +private fun PreferenceCategoryTitle(text: String) { + Text( + text = text, + maxLines = 1, + style = SaveTextStyles.titleLarge, + color = colorResource(id = R.color.colorTertiary), + ) +} + +@Composable +fun PreferenceTitle(text: String, maxLines: Int = 1) { + Text( + text = text, + maxLines = maxLines, + style = SaveTextStyles.bodyLarge, + color = colorResource(id = R.color.colorOnBackground), + ) +} + +@Composable +private fun PreferenceSummary(text: String) { + Text( + text = text, + maxLines = 2, + style = SaveTextStyles.bodySmallEmphasis, + color = colorResource(id = R.color.colorOnSurfaceVariant), + ) +} + +@Composable +private fun SettingsDivider() { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 0.5.dp, + color = colorResource(id = R.color.colorDivider).copy(alpha = 0.5f), + ) +} + +private fun LazyListScope.sectionDivider(key: String) { + item(key = key) { SettingsDivider() } +} + +@Composable +fun SwitchPreference( + state: MutableState, + title: @Composable () -> Unit, + summary: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + modifier: Modifier = Modifier.fillMaxWidth(), + onToggle: (Boolean) -> Unit = { newValue -> state.value = newValue }, +) { + val value by state + Preference( + title = title, + summary = summary, + enabled = enabled, + onClick = { onToggle(!value) }, + modifier = modifier, + widgetContainer = { + val theme = me.zhanghai.compose.preference.LocalPreferenceTheme.current + Switch( + checked = value, + onCheckedChange = { onToggle(it) }, + enabled = enabled, + modifier = Modifier.padding(start = theme.horizontalSpacing, end = 24.dp), + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = MaterialTheme.colorScheme.tertiary, + ) + ) + } + ) +} + +@Composable +fun savePreferenceTheme(): PreferenceTheme { + val categoryColor = colorResource(id = R.color.colorTertiary) + val titleColor = colorResource(id = R.color.colorOnBackground) + val summaryColor = colorResource(id = R.color.colorOnSurfaceVariant) + return preferenceTheme( + categoryPadding = PaddingValues(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 8.dp), + categoryColor = categoryColor, + categoryTextStyle = SaveTextStyles.titleLarge, + padding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalSpacing = 16.dp, + verticalSpacing = 16.dp, + iconContainerMinWidth = 0.dp, + titleColor = titleColor, + titleTextStyle = SaveTextStyles.bodyLarge, + summaryColor = summaryColor, + summaryTextStyle = SaveTextStyles.bodySmallEmphasis, + ) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── private fun openUrl(context: Context, url: String) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) +} + +private fun openAppLanguageSettings(context: Context) { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + } context.startActivity(intent) } +// ── Preference flow ─────────────────────────────────────────────────────────── + +@OptIn(DelicateCoroutinesApi::class) +private fun createFilteredPreferenceFlow( + sharedPreferences: SharedPreferences, +): MutableStateFlow { + val initialPreferences = sharedPreferences.toSupportedPreferences() + return MutableStateFlow(initialPreferences).also { flow -> + GlobalScope.launch(Dispatchers.Main.immediate) { + flow.drop(1).collect { prefs -> + sharedPreferences.edit { + for ((key, value) in prefs.asMap()) { + when (value) { + is Boolean -> putBoolean(key, value) + is Int -> putInt(key, value) + is Float -> putFloat(key, value) + is String -> putString(key, value) + is Set<*> -> + @Suppress("UNCHECKED_CAST") + putStringSet(key, value.filterIsInstance().toSet()) + } + } + } + } + } + } +} + +private fun SharedPreferences.toSupportedPreferences(): me.zhanghai.compose.preference.Preferences { + val entries = try { + all ?: emptyMap() + } catch (_: Exception) { + emptyMap() + } + return MapPreferences( + entries.mapNotNull { (key, value) -> + when (value) { + is Boolean, is Int, is Float, is String -> key to value + is Set<*> -> key to value.filterIsInstance().toSet() + else -> null + } + }.toMap() + ) +} + +private inline fun SharedPreferences.edit(action: SharedPreferences.Editor.() -> Unit) { + edit().apply { + action() + apply() + } +} + +// ── Preview ─────────────────────────────────────────────────────────────────── @Preview @Composable private fun SettingsScreenPreview() { DefaultScaffoldPreview { + PreviewSettingsScreen() + } +} - SettingsScreen() +@Composable +private fun PreviewSettingsScreen() { + val previewFlow = remember { + MutableStateFlow( + MapPreferences( + mapOf( + Prefs.PASSCODE_ENABLED to false, + Prefs.UPLOAD_WIFI_ONLY to true, + Prefs.USE_TOR to false, + "pref_key_use_dark_mode" to false, + ) + ) + ) } -} \ No newline at end of file + ProvidePreferenceLocals(flow = previewFlow, theme = savePreferenceTheme()) { + SettingsScreenContent( + strings = SettingsStrings( + titleSecure = "Secure", + titleArchive = "Archive", + titleVerify = "Verify", + titleEncrypt = "Encrypt", + titleGeneral = "General", + titlePasscode = "Lock app with passcode", + titleWifiOnly = "Only upload media when you are connected to Wi-Fi", + titleMediaServers = "Media Servers", + summaryMediaServers = "Add or remove media servers", + titleMediaFolders = "Media Folders", + summaryMediaFolders = "Manage your archived folders", + titleC2pa = "C2PA Content Authenticity", + summaryC2pa = "Generate cryptographic content credentials", + titleTor = "Turn on Onion Routing", + titleDarkMode = "Switch to dark mode", + titleLanguage = "App language", + summaryLanguage = "Change the language for this app", + titleAbout = "Save by Open Archive", + summaryAbout = "Discover the Save app", + titlePrivacy = "Terms & Privacy Policy", + summaryPrivacy = "Tap to view our Terms & Privacy Policy", + titleVersion = "Version", + ), + appVersion = "0.0.0", + passcodeState = rememberPreferenceState(key = Prefs.PASSCODE_ENABLED, defaultValue = false), + wifiOnlyState = rememberPreferenceState(key = Prefs.UPLOAD_WIFI_ONLY, defaultValue = true), + torChecked = false, + torInteractive = true, + torSummary = "Transfer via the Tor Network only", + darkModeState = rememberPreferenceState(key = "pref_key_use_dark_mode", defaultValue = false), + onPasscodeToggle = {}, + onWifiOnlyToggle = {}, + onTorToggle = {}, + onDarkModeToggle = {}, + onLanguageClick = {}, + onMediaServersClick = {}, + onMediaFoldersClick = {}, + onC2paClick = {}, + onAboutClick = {}, + onPrivacyClick = {}, + onNavigateToCache = {}, + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupFragment.kt deleted file mode 100644 index b2ce32696..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupFragment.kt +++ /dev/null @@ -1,63 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.content.Intent -import androidx.fragment.compose.content -import androidx.navigation.fragment.findNavController -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.settings.passcode.AppConfig -import net.opendasharchive.openarchive.features.spaces.SpaceSetupScreen -import org.koin.android.ext.android.inject -import net.opendasharchive.openarchive.services.snowbird.SnowbirdActivity - -class SpaceSetupFragment : BaseFragment() { - - private val appConfig by inject() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = content { - - // Prepare click lambdas that use the fragment’s business logic. - val onWebDavClick = { - findNavController().navigate(R.id.action_fragment_space_setup_to_fragment_web_dav) - } - - // Only enable Internet Archive if not already present - val isInternetArchiveAllowed = !Space.has(Space.Type.INTERNET_ARCHIVE) - val onInternetArchiveClick = { - val action = SpaceSetupFragmentDirections.actionFragmentSpaceSetupToInternetArchiveLogin() - findNavController().navigate(action) - } - - // Show/hide Snowbird based on config - val isDwebEnabled = appConfig.isDwebEnabled - val onDwebClicked = { - val intent = Intent(requireContext(), SnowbirdActivity::class.java) - startActivity(intent) - } - - SaveAppTheme { - SpaceSetupScreen( - onWebDavClick = onWebDavClick, - isInternetArchiveAllowed = isInternetArchiveAllowed, - onInternetArchiveClick = onInternetArchiveClick, - isDwebEnabled = isDwebEnabled, - onDwebClicked = onDwebClicked - ) - } - - } - - override fun getToolbarTitle() = getString(R.string.space_setup_title) - override fun getToolbarSubtitle(): String? = null - override fun shouldShowBackButton() = true -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessFragment.kt deleted file mode 100644 index e2ce15d1b..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessFragment.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.WindowInsetsCompat -import androidx.navigation.fragment.navArgs -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentSpaceSetupSuccessBinding -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.main.MainActivity -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets - -class SpaceSetupSuccessFragment : BaseFragment() { - - private lateinit var binding: FragmentSpaceSetupSuccessBinding - private val args: SpaceSetupSuccessFragmentArgs by navArgs() - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentSpaceSetupSuccessBinding.inflate(inflater) - - binding.mainContainer.applyEdgeToEdgeInsets( - typeMask = WindowInsetsCompat.Type.navigationBars() - ) { insets -> - bottomMargin = insets.bottom - } - - binding.buttonBar.applyEdgeToEdgeInsets( - typeMask = WindowInsetsCompat.Type.navigationBars() - ) { insets -> - bottomMargin = insets.bottom - } - - if (args.message.isNotEmpty()) { - binding.successMessage.text = args.message - } - - binding.btAuthenticate.setOnClickListener { _ -> - val intent = Intent(requireActivity(), MainActivity::class.java) - intent.flags = - Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK // Clears backstack - startActivity(intent) - } - - return binding.root - } - - override fun getToolbarTitle() = getString(R.string.space_setup_success_title) - override fun shouldShowBackButton() = false -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessScreen.kt new file mode 100644 index 000000000..9dc88616e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessScreen.kt @@ -0,0 +1,194 @@ +package net.opendasharchive.openarchive.features.settings + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.presentation.components.PrimaryButton +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.asString +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator + +@Composable +fun SpaceSetupSuccessScreen( + viewModel: SpaceSetupSuccessViewModel , + onNavigateBack: () -> Unit = {} +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + when (event) { + is SpaceSetupSuccessEvent.SendResultBack -> { + onNavigateBack() + } + } + } + } + + SpaceSetupSuccessContent( + state = state, + onAction = viewModel::onAction + ) +} + +@Composable +fun SpaceSetupSuccessContent( + state: SpaceSetupSuccessState, + onAction: (SpaceSetupSuccessAction) -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + // Scrollable content + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Success message at top + Text( + text = state.message.asString(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 36.dp, vertical = 48.dp), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + + // Center illustration - takes available space + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + .weight(2f), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.hands_mobile_updated), + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth + ) + } + + // Spacer for button height + padding + navigation bar + Spacer(modifier = Modifier.weight(1f)) + } + + // Button bar at bottom - overlaid on top + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.Center + ) { + PrimaryButton( + text = stringResource(R.string.action_done), + onClick = { onAction(SpaceSetupSuccessAction.Done) }, + modifier = Modifier + .fillMaxWidth(0.6f) + .height(48.dp) + ) + } + } +} + +@Preview(showBackground = true, name = "Space Setup Success - Light") +@Preview( + showBackground = true, + name = "Space Setup Success - Dark", + uiMode = Configuration.UI_MODE_NIGHT_YES +) +@Composable +fun SpaceSetupSuccessWebDavPreview() { + SaveAppTheme { + SpaceSetupSuccessContent( + state = SpaceSetupSuccessState( + message = UiText.Resource(R.string.you_have_successfully_connected_to_a_private_server), + spaceType = VaultType.PRIVATE_SERVER + ), + onAction = {} + ) + } +} + +class SpaceSetupSuccessViewModel( + route: AppRoute.SpaceSetupSuccessRoute, + private val navigator: Navigator, +) : ViewModel() { + + private val _uiState = MutableStateFlow( + SpaceSetupSuccessState( + spaceType = route.spaceType, + message = when(route.spaceType) { + VaultType.PRIVATE_SERVER -> UiText.Resource(R.string.you_have_successfully_connected_to_a_private_server) + VaultType.INTERNET_ARCHIVE -> UiText.Resource(R.string.you_have_successfully_connected_to_the_internet_archive) + VaultType.DWEB_STORAGE -> UiText.Resource(R.string.you_have_successfully_created_dweb) + }, + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + fun onAction(action: SpaceSetupSuccessAction) { + when (action) { + is SpaceSetupSuccessAction.Done -> onDone() + } + } + + private fun onDone() = viewModelScope.launch { + + //TODO: Navigate back with result + _uiEvent.send(SpaceSetupSuccessEvent.SendResultBack) + + navigator.navigateAndClear(AppRoute.HomeRoute) + if (uiState.value.spaceType == VaultType.DWEB_STORAGE) { + navigator.navigateTo(AppRoute.SnowbirdDashboardRoute) + } + } +} + +data class SpaceSetupSuccessState( + val message: UiText, + val spaceType: VaultType, +) + +sealed interface SpaceSetupSuccessAction { + data object Done : SpaceSetupSuccessAction +} + +sealed interface SpaceSetupSuccessEvent { + data object SendResultBack : SpaceSetupSuccessEvent +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseFragment.kt deleted file mode 100644 index 194b0594d..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseFragment.kt +++ /dev/null @@ -1,149 +0,0 @@ -package net.opendasharchive.openarchive.features.settings.license - -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.core.view.WindowInsetsCompat -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentSetupLicenseBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets - -class SetupLicenseFragment : BaseFragment() { - - - private val args: SetupLicenseFragmentArgs by navArgs() - - private lateinit var binding: FragmentSetupLicenseBinding - - - private lateinit var mSpace: Space - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - binding = FragmentSetupLicenseBinding.inflate(layoutInflater) - - binding.buttonBar.applyEdgeToEdgeInsets( - typeMask = WindowInsetsCompat.Type.navigationBars() - ) { insets -> - bottomMargin = insets.bottom - } - - mSpace = Space.get(args.spaceId) ?: Space(Space.Type.WEBDAV) - - if (args.isEditing) { - // Editing means hide subtitle, bottom bar buttons - binding.buttonBar.visibility = View.GONE - binding.descriptionText.visibility = View.GONE - } else { - binding.btCancel.visibility = View.GONE - } - - if (args.spaceType == Space.Type.INTERNET_ARCHIVE) { - binding.serverNameLayout.visibility = View.GONE - binding.descriptionText.text = getString(R.string.choose_license) - } else { - binding.serverNameLayout.visibility = View.VISIBLE - binding.descriptionText.text = getString(R.string.name_your_server) - } - - binding.btNext.setOnClickListener { - when (args.spaceType) { - Space.Type.WEBDAV -> { - val message = - getString(R.string.you_have_successfully_connected_to_a_private_server) - val action = - SetupLicenseFragmentDirections.actionFragmentSetupLicenseToFragmentSpaceSetupSuccess( - message, - spaceType = Space.Type.WEBDAV, - ) - findNavController().navigate(action) - } - - Space.Type.INTERNET_ARCHIVE -> { - val message = - getString(R.string.you_have_successfully_connected_to_the_internet_archive) - val action = - SetupLicenseFragmentDirections.actionFragmentSetupLicenseToFragmentSpaceSetupSuccess( - message, - spaceType = Space.Type.INTERNET_ARCHIVE, - ) - findNavController().navigate(action) - } - - else -> Unit - } - - } - - binding.btCancel.setOnClickListener { - findNavController().popBackStack() - } - - binding.cc.tvCcLabel.setText(R.string.set_creative_commons_license_for_all_folders_on_this_server) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - if (args.isEditing) { - // Editing means hide subtitle, bottom bar buttons - binding.name.setText(mSpace.name) - } - - binding.name.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { - // Do nothing - } - - override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { - // Do nothing - } - - override fun afterTextChanged(name: Editable?) { - if (name == null) return - - mSpace.name = name.toString() - mSpace.save() - //binding.name.clearFocus() - } - }) - - CreativeCommonsLicenseManager.initialize(binding.cc, Space.current?.license) { - val space = Space.current ?: return@initialize - - space.license = it - space.save() - } - - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - // do nothing - } - }) - } - - override fun getToolbarTitle() = - if (args.spaceType == Space.Type.INTERNET_ARCHIVE) getString(R.string.internet_archive) else getString( - R.string.private_server - ) - - override fun getToolbarSubtitle(): String? = null - override fun shouldShowBackButton() = false -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseScreen.kt index 941f8f728..3477e424b 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseScreen.kt @@ -1,118 +1,123 @@ package net.opendasharchive.openarchive.features.settings.license -import androidx.compose.foundation.layout.* +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.opendasharchive.openarchive.R -import androidx.compose.ui.res.colorResource -import androidx.compose.runtime.Immutable -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.presentation.components.PrimaryButton +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager -import net.opendasharchive.openarchive.services.webdav.CreativeCommonsLicenseContent -import net.opendasharchive.openarchive.services.webdav.LicenseCallbacks -import net.opendasharchive.openarchive.services.webdav.LicenseState +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomTextField +import net.opendasharchive.openarchive.services.common.license.CreativeCommonsLicenseContent +import net.opendasharchive.openarchive.services.common.license.LicenseCallbacks +import net.opendasharchive.openarchive.services.common.license.LicenseState -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SetupLicenseScreen( - onNext: () -> Unit = {}, - onCancel: () -> Unit = {}, - viewModel: SetupLicenseViewModel = viewModel() + viewModel: SetupLicenseViewModel, ) { val state by viewModel.uiState.collectAsState() - LaunchedEffect(Unit) { - viewModel.events.collect { event -> - when (event) { - is SetupLicenseEvent.NavigateNext -> onNext() - is SetupLicenseEvent.NavigateBack -> onCancel() - } - } + // Disable back button for Internet Archive setup + BackHandler(enabled = state.spaceType == VaultType.INTERNET_ARCHIVE) { + // Do nothing - back button is disabled } SetupLicenseScreenContent( - state = state, + state = state, onAction = viewModel::onAction ) } -@OptIn(ExperimentalMaterial3Api::class) + @Composable -fun SetupLicenseScreenContent( +private fun SetupLicenseScreenContent( state: SetupLicenseState, onAction: (SetupLicenseAction) -> Unit ) { + val focusManager = LocalFocusManager.current - - - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - .padding(top = 48.dp, bottom = 16.dp) - ) { - // Content section + Box(modifier = Modifier.fillMaxSize()) { + // Scrollable content Column( modifier = Modifier - .fillMaxWidth() - .weight(1f), + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + focusManager.clearFocus() + } + .padding(horizontal = 16.dp) + .padding(top = 48.dp, bottom = 100.dp), // ensure Learn More link clears Next button horizontalAlignment = Alignment.CenterHorizontally ) { // Description text (hidden in edit mode) - if (!state.isEditing) { - Text( - text = stringResource(R.string.name_your_server), - modifier = Modifier.padding(24.dp), - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface - ) + + val descriptionText = when (state.spaceType) { + VaultType.INTERNET_ARCHIVE -> stringResource(R.string.choose_license) + else -> stringResource(R.string.name_your_server) } - // Server name input - OutlinedTextField( - value = state.serverName, - onValueChange = { onAction(SetupLicenseAction.UpdateServerName(it)) }, - label = { Text(stringResource(R.string.server_name_optional)) }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.tertiary, - focusedLabelColor = MaterialTheme.colorScheme.tertiary - ) + Text( + text = descriptionText, + modifier = Modifier.padding(24.dp), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface ) + + // Server name input (hidden for Internet Archive) + if (state.spaceType != VaultType.INTERNET_ARCHIVE) { + CustomTextField( + value = state.serverName, + onValueChange = { onAction(SetupLicenseAction.UpdateServerName(it)) }, + placeholder = stringResource(R.string.server_name_optional), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + onImeAction = { focusManager.clearFocus() } + ) + } + // Creative Commons License Section CreativeCommonsLicenseContent( licenseState = LicenseState( @@ -123,25 +128,29 @@ fun SetupLicenseScreenContent( cc0Enabled = state.cc0Enabled, licenseUrl = state.licenseUrl ), - licenseCallbacks = object : - LicenseCallbacks { + licenseCallbacks = object : LicenseCallbacks { override fun onCcEnabledChange(enabled: Boolean) { + focusManager.clearFocus() onAction(SetupLicenseAction.UpdateCcEnabled(enabled)) } override fun onAllowRemixChange(allowed: Boolean) { + focusManager.clearFocus() onAction(SetupLicenseAction.UpdateAllowRemix(allowed)) } override fun onRequireShareAlikeChange(required: Boolean) { + focusManager.clearFocus() onAction(SetupLicenseAction.UpdateRequireShareAlike(required)) } override fun onAllowCommercialChange(allowed: Boolean) { + focusManager.clearFocus() onAction(SetupLicenseAction.UpdateAllowCommercial(allowed)) } override fun onCc0EnabledChange(enabled: Boolean) { + focusManager.clearFocus() onAction(SetupLicenseAction.UpdateCc0Enabled(enabled)) } }, @@ -149,289 +158,38 @@ fun SetupLicenseScreenContent( ) } - // Button bar (hidden in edit mode) - if (!state.isEditing) { - Row( + // Button bar at bottom - overlaid on top + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.Center + ) { + PrimaryButton( + text = stringResource(R.string.action_next), + onClick = { onAction(SetupLicenseAction.Next) }, modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Cancel button (invisible by default as per original XML) - OutlinedButton( - onClick = { onAction(SetupLicenseAction.Cancel) }, - modifier = Modifier - .weight(1f) - .height(48.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { - Text(stringResource(R.string.back)) - } - - // Next button - Button( - onClick = { onAction(SetupLicenseAction.Next) }, - modifier = Modifier - .weight(1f) - .height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = colorResource(R.color.colorTertiary) - ) - ) { - Text( - text = stringResource(R.string.action_next), - fontWeight = FontWeight.Medium - ) - } - } + .fillMaxWidth(0.6f) + .height(48.dp) + ) } } } -@Preview(showBackground = true) +@PreviewLight @Composable -fun WebDavSetupLicenseScreenPreview() { +private fun WebDavSetupLicenseScreenPreview() { SaveAppTheme { SetupLicenseScreenContent( state = SetupLicenseState( - ccEnabled = true + ccEnabled = true, + spaceId = 1, + spaceType = VaultType.PRIVATE_SERVER ), onAction = {} ) } } -class SetupLicenseViewModel( - savedStateHandle: SavedStateHandle -) : ViewModel() { - - private val spaceId: Long = savedStateHandle.get("spaceId") ?: -1L - private val isEditing: Boolean = savedStateHandle.get("isEditing") ?: false - - private val _uiState = MutableStateFlow(SetupLicenseState(spaceId = spaceId, isEditing = isEditing)) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _events = Channel() - val events = _events.receiveAsFlow() - - private var space: Space? = null - - init { - loadSpace() - } - - fun onAction(action: SetupLicenseAction) { - when (action) { - is SetupLicenseAction.UpdateServerName -> { - _uiState.update { it.copy(serverName = action.serverName) } - updateSpace { space -> - space.name = action.serverName - space.save() - } - } - - is SetupLicenseAction.Next -> { - viewModelScope.launch { - _events.send(SetupLicenseEvent.NavigateNext) - } - } - - is SetupLicenseAction.Cancel -> { - viewModelScope.launch { - _events.send(SetupLicenseEvent.NavigateBack) - } - } - - is SetupLicenseAction.UpdateCcEnabled -> { - _uiState.update { currentState -> - if (action.enabled) { - // When CC is enabled, start fresh with no options selected - currentState.copy( - ccEnabled = true, - cc0Enabled = false, - allowRemix = false, - requireShareAlike = false, - allowCommercial = false, - licenseUrl = null - ) - } else { - // When CC is disabled, reset all other CC options - currentState.copy( - ccEnabled = false, - allowRemix = false, - requireShareAlike = false, - allowCommercial = false, - cc0Enabled = false, - licenseUrl = null - ) - } - } - generateAndUpdateLicense() - } - - is SetupLicenseAction.UpdateAllowRemix -> { - _uiState.update { currentState -> - currentState.copy( - allowRemix = action.allowed, - cc0Enabled = if (action.allowed) false else currentState.cc0Enabled, // Disable CC0 if remix is enabled - requireShareAlike = if (!action.allowed) false else currentState.requireShareAlike // Auto-disable ShareAlike when Remix is disabled - ) - } - generateAndUpdateLicense() - } - - is SetupLicenseAction.UpdateRequireShareAlike -> { - _uiState.update { currentState -> - currentState.copy( - requireShareAlike = action.required, - cc0Enabled = if (action.required) false else currentState.cc0Enabled // Disable CC0 if share alike is enabled - ) - } - generateAndUpdateLicense() - } - - is SetupLicenseAction.UpdateAllowCommercial -> { - _uiState.update { currentState -> - currentState.copy( - allowCommercial = action.allowed, - cc0Enabled = if (action.allowed) false else currentState.cc0Enabled // Disable CC0 if commercial is enabled - ) - } - generateAndUpdateLicense() - } - - is SetupLicenseAction.UpdateCc0Enabled -> { - _uiState.update { currentState -> - if (action.enabled) { - // When CC0 is enabled, disable CC and reset all other options - currentState.copy( - cc0Enabled = true, - ccEnabled = false, - allowRemix = false, - requireShareAlike = false, - allowCommercial = false - ) - } else { - currentState.copy(cc0Enabled = false) - } - } - generateAndUpdateLicense() - } - } - } - - private fun loadSpace() { - space = if (spaceId == -1L) { - Space(Space.Type.WEBDAV) - } else { - Space.get(spaceId) ?: Space(Space.Type.WEBDAV) - } - - space?.let { currentSpace -> - val licenseState = initializeLicenseState(currentSpace.license) - _uiState.update { currentState -> - currentState.copy( - serverName = currentSpace.name.orEmpty(), - ccEnabled = licenseState.ccEnabled, - allowRemix = licenseState.allowRemix, - requireShareAlike = licenseState.requireShareAlike, - allowCommercial = licenseState.allowCommercial, - cc0Enabled = licenseState.cc0Enabled, - licenseUrl = licenseState.licenseUrl - ) - } - } - } - - private fun updateSpace(action: (Space) -> Unit) { - space?.let(action) - } - - private fun initializeLicenseState(currentLicense: String?): SetupLicenseState { - val isCc0 = currentLicense?.contains("publicdomain/zero", true) ?: false - val isCC = currentLicense?.contains("creativecommons.org/licenses", true) ?: false - - return if (isCc0) { - // CC0 license detected - SetupLicenseState( - ccEnabled = true, - cc0Enabled = true, - allowRemix = false, - allowCommercial = false, - requireShareAlike = false, - licenseUrl = currentLicense - ) - } else if (isCC && currentLicense != null) { - // Regular CC license detected - SetupLicenseState( - ccEnabled = true, - cc0Enabled = false, - allowRemix = !(currentLicense.contains("-nd", true)), - allowCommercial = !(currentLicense.contains("-nc", true)), - requireShareAlike = !(currentLicense.contains("-nd", true)) && currentLicense.contains("-sa", true), - licenseUrl = currentLicense - ) - } else { - // No license - SetupLicenseState( - ccEnabled = false, - cc0Enabled = false, - allowRemix = false, // Changed from true to fix auto-enable bug - allowCommercial = false, - requireShareAlike = false, - licenseUrl = null - ) - } - } - - private fun generateAndUpdateLicense() { - val currentState = _uiState.value - val newLicense = CreativeCommonsLicenseManager.generateLicenseUrl( - ccEnabled = currentState.ccEnabled, - allowRemix = currentState.allowRemix, - requireShareAlike = currentState.requireShareAlike, - allowCommercial = currentState.allowCommercial, - cc0Enabled = currentState.cc0Enabled - ) - - _uiState.update { it.copy(licenseUrl = newLicense) } - updateSpace { space -> - space.license = newLicense - space.save() - } - } -} - -@Immutable -data class SetupLicenseState( - val serverName: String = "", - val spaceId: Long = -1L, - val isEditing: Boolean = false, - // Creative Commons License state - val ccEnabled: Boolean = false, - val allowRemix: Boolean = false, - val requireShareAlike: Boolean = false, - val allowCommercial: Boolean = false, - val cc0Enabled: Boolean = false, - val licenseUrl: String? = null, - val isLoading: Boolean = false -) - -sealed interface SetupLicenseAction { - data class UpdateServerName(val serverName: String) : SetupLicenseAction - data object Next : SetupLicenseAction - data object Cancel : SetupLicenseAction - // Creative Commons License actions - data class UpdateCcEnabled(val enabled: Boolean) : SetupLicenseAction - data class UpdateAllowRemix(val allowed: Boolean) : SetupLicenseAction - data class UpdateRequireShareAlike(val required: Boolean) : SetupLicenseAction - data class UpdateAllowCommercial(val allowed: Boolean) : SetupLicenseAction - data class UpdateCc0Enabled(val enabled: Boolean) : SetupLicenseAction -} - -sealed interface SetupLicenseEvent { - data object NavigateNext : SetupLicenseEvent - data object NavigateBack : SetupLicenseEvent -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseViewModel.kt new file mode 100644 index 000000000..fb3b33bb1 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseViewModel.kt @@ -0,0 +1,256 @@ +package net.opendasharchive.openarchive.features.settings.license + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager + +class SetupLicenseViewModel( + private val route: AppRoute.SetupLicenseRoute, + private val navigator: Navigator, + private val spaceRepository: SpaceRepository, +) : ViewModel() { + + + private val _uiState = MutableStateFlow( + SetupLicenseState( + spaceId = route.spaceId, + spaceType = route.spaceType + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadSpace() + } + + fun onAction(action: SetupLicenseAction) { + + when (action) { + + is SetupLicenseAction.UpdateServerName -> { + _uiState.update { it.copy(serverName = action.serverName) } + } + + is SetupLicenseAction.Next -> onNext() + + is SetupLicenseAction.UpdateCcEnabled -> { + _uiState.update { currentState -> + if (action.enabled) { + // When CC is enabled, start fresh with no options selected + currentState.copy( + ccEnabled = true, + cc0Enabled = false, + allowRemix = false, + requireShareAlike = false, + allowCommercial = false, + licenseUrl = null + ) + } else { + // When CC is disabled, reset all other CC options + currentState.copy( + ccEnabled = false, + allowRemix = false, + requireShareAlike = false, + allowCommercial = false, + cc0Enabled = false, + licenseUrl = null + ) + } + } + generateAndUpdateLicense() + } + + is SetupLicenseAction.UpdateAllowRemix -> { + _uiState.update { currentState -> + currentState.copy( + allowRemix = action.allowed, + cc0Enabled = if (action.allowed) false else currentState.cc0Enabled, // Disable CC0 if remix is enabled + requireShareAlike = if (!action.allowed) false else currentState.requireShareAlike // Auto-disable ShareAlike when Remix is disabled + ) + } + generateAndUpdateLicense() + } + + is SetupLicenseAction.UpdateRequireShareAlike -> { + _uiState.update { currentState -> + currentState.copy( + requireShareAlike = action.required, + cc0Enabled = if (action.required) false else currentState.cc0Enabled // Disable CC0 if share alike is enabled + ) + } + generateAndUpdateLicense() + } + + is SetupLicenseAction.UpdateAllowCommercial -> { + _uiState.update { currentState -> + currentState.copy( + allowCommercial = action.allowed, + cc0Enabled = if (action.allowed) false else currentState.cc0Enabled // Disable CC0 if commercial is enabled + ) + } + generateAndUpdateLicense() + } + + is SetupLicenseAction.UpdateCc0Enabled -> { + _uiState.update { currentState -> + if (action.enabled) { + // When CC0 is enabled, disable CC and reset all other options + currentState.copy( + cc0Enabled = true, + allowRemix = false, + requireShareAlike = false, + allowCommercial = false + ) + } else { + currentState.copy(cc0Enabled = false) + } + } + generateAndUpdateLicense() + } + } + } + + private fun loadSpace() = viewModelScope.launch { + // Since we come directly from WebDavScreen/InternetArchiveLoginScreen where + // Space.current is set, we can use it directly. This ensures we're updating + // the SAME space that was just authenticated, not creating a new one. + val currentState = uiState.value + + val vault = spaceRepository.getSpaceById(route.spaceId) ?: error("Space not found") + + val licenseState = + initializeLicenseState(state = currentState, currentLicense = vault.licenseUrl) + _uiState.update { currentState -> + currentState.copy( + vault = vault, + serverName = vault.name, + ccEnabled = licenseState.ccEnabled, + allowRemix = licenseState.allowRemix, + requireShareAlike = licenseState.requireShareAlike, + allowCommercial = licenseState.allowCommercial, + cc0Enabled = licenseState.cc0Enabled, + licenseUrl = licenseState.licenseUrl + ) + } + } + + private fun onNext() = viewModelScope.launch { + val currentState = uiState.value + val vault = currentState.vault ?: return@launch + // Save all changes (name + license) when user taps Next + + // Only save nickname for WebDAV/private servers, not for Internet Archive + var updatedVault = if (currentState.spaceType != VaultType.INTERNET_ARCHIVE) { + vault.copy(name = currentState.serverName) + } else { + vault + } + + updatedVault = updatedVault.copy(licenseUrl = currentState.licenseUrl) + + AppLogger.d("Updating space - ID: ${updatedVault.id}, type: ${currentState.spaceType}, name: '${updatedVault.name}', license: '${updatedVault.licenseUrl}'") + + // Save updates to existing space + spaceRepository.updateSpace(route.spaceId, updatedVault) + + AppLogger.d("Space saved successfully - ID: ${updatedVault.id}") + + navigator.navigateTo(AppRoute.SpaceSetupSuccessRoute(currentState.spaceType)) + } + + private fun initializeLicenseState( + state: SetupLicenseState, + currentLicense: String? + ): SetupLicenseState { + val isCc0 = currentLicense?.contains("publicdomain/zero", true) ?: false + val isCC = currentLicense?.contains("creativecommons.org/licenses", true) ?: false + + return if (isCc0) { + // CC0 license detected + state.copy( + ccEnabled = true, + cc0Enabled = true, + allowRemix = false, + allowCommercial = false, + requireShareAlike = false, + licenseUrl = currentLicense + ) + } else if (isCC) { + // Regular CC license detected + state.copy( + ccEnabled = true, + cc0Enabled = false, + allowRemix = !(currentLicense.contains("-nd", true)), + allowCommercial = !(currentLicense.contains("-nc", true)), + requireShareAlike = !(currentLicense.contains( + "-nd", + true + )) && currentLicense.contains("-sa", true), + licenseUrl = currentLicense + ) + } else { + // No license + state.copy( + ccEnabled = false, + cc0Enabled = false, + allowRemix = false, // Changed from true to fix auto-enable bug + allowCommercial = false, + requireShareAlike = false, + licenseUrl = null + ) + } + } + + private fun generateAndUpdateLicense() { + val currentState = _uiState.value + val newLicense = CreativeCommonsLicenseManager.generateLicenseUrl( + ccEnabled = currentState.ccEnabled, + allowRemix = currentState.allowRemix, + requireShareAlike = currentState.requireShareAlike, + allowCommercial = currentState.allowCommercial, + cc0Enabled = currentState.cc0Enabled + ) + + _uiState.update { it.copy(licenseUrl = newLicense) } + } +} + +@Immutable +data class SetupLicenseState( + val spaceId: Long, + val vault: Vault? = null, + val serverName: String = "", + val spaceType: VaultType, + // Creative Commons License state + val ccEnabled: Boolean = false, + val allowRemix: Boolean = false, + val requireShareAlike: Boolean = false, + val allowCommercial: Boolean = false, + val cc0Enabled: Boolean = false, + val licenseUrl: String? = null, + val isLoading: Boolean = false +) + +sealed interface SetupLicenseAction { + data class UpdateServerName(val serverName: String) : SetupLicenseAction + data object Next : SetupLicenseAction + + // Creative Commons License actions + data class UpdateCcEnabled(val enabled: Boolean) : SetupLicenseAction + data class UpdateAllowRemix(val allowed: Boolean) : SetupLicenseAction + data class UpdateRequireShareAlike(val required: Boolean) : SetupLicenseAction + data class UpdateAllowCommercial(val allowed: Boolean) : SetupLicenseAction + data class UpdateCc0Enabled(val enabled: Boolean) : SetupLicenseAction +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/BiometricAuthenticator.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/BiometricAuthenticator.kt deleted file mode 100644 index 54abcefe2..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/BiometricAuthenticator.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.opendasharchive.openarchive.features.settings.passcode - -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat -import net.opendasharchive.openarchive.features.core.BaseActivity - -class BiometricAuthenticator( - private val activity: BaseActivity, private val config: AppConfig -) { - - private val biometricManager = BiometricManager.from(activity) - private var biometricPrompt: BiometricPrompt? = null - - fun isBiometricAvailable(): Boolean { - return config.biometricAuthEnabled && biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS - } - - fun authenticate( - onSuccess: () -> Unit, - onFailure: (errorMessage: String) -> Unit - ) { - val executor = ContextCompat.getMainExecutor(activity) - - biometricPrompt = BiometricPrompt( - activity, - executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - onSuccess() - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - onFailure("Biometric authentication failed: $errString") - } - - override fun onAuthenticationFailed() { - onFailure("Biometric authentication failed") - } - } - ) - - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Biometric Authentication") - .setSubtitle("Use your fingerprint or face to unlock") - .setNegativeButtonText("Use Passcode") - .build() - - biometricPrompt?.authenticate(promptInfo) - } - - fun cancelAuthentication() { - biometricPrompt?.cancelAuthentication() - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/HapticManager.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/HapticManager.kt index edf3e8e8e..f8f911a33 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/HapticManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/HapticManager.kt @@ -2,6 +2,7 @@ package net.opendasharchive.openarchive.features.settings.passcode import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import net.opendasharchive.openarchive.core.config.AppConfig enum class AppHapticFeedbackType { KeyPress, @@ -11,25 +12,13 @@ enum class AppHapticFeedbackType { class HapticManager( private val appConfig: AppConfig ) { - private var hapticFeedback: HapticFeedback? = null - - fun init(hapticFeedback: HapticFeedback) { - this.hapticFeedback = hapticFeedback - } - - fun performHapticFeedback(type: AppHapticFeedbackType) { + fun perform(haptic: HapticFeedback, type: AppHapticFeedbackType) { if (!appConfig.enableHapticFeedback) return - hapticFeedback?.let { - // Using Compose HapticFeedback - when (type) { - AppHapticFeedbackType.KeyPress -> it.performHapticFeedback(HapticFeedbackType.LongPress) - AppHapticFeedbackType.Error -> it.performHapticFeedback(HapticFeedbackType.LongPress) - } + val composeType = when (type) { + AppHapticFeedbackType.KeyPress -> HapticFeedbackType.TextHandleMove + AppHapticFeedbackType.Error -> HapticFeedbackType.LongPress } + haptic.performHapticFeedback(composeType) } - - fun clear() { - hapticFeedback = null - } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/PasscodeGate.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/PasscodeGate.kt new file mode 100644 index 000000000..ac0041d02 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/PasscodeGate.kt @@ -0,0 +1,57 @@ +package net.opendasharchive.openarchive.features.settings.passcode + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import net.opendasharchive.openarchive.util.Prefs + +class PasscodeGate( + private val flowState: PasscodeFlowState +) : DefaultLifecycleObserver { + + /** + * Tracks whether the user has successfully authenticated in the current process session. + * Cleared on onStop so the app re-locks whenever it is fully backgrounded. + */ + private var isAuthenticated = false + + private val _locked = MutableStateFlow(Prefs.passcodeEnabled) + val locked: StateFlow = _locked.asStateFlow() + + override fun onStart(owner: LifecycleOwner) { + _locked.value = shouldLockNow() + } + + // Intentionally no onPause override — do NOT re-lock mid-session when the app loses focus + // briefly (e.g. system dialog, notification shade). Only re-lock on full background (onStop). + + override fun onStop(owner: LifecycleOwner) { + if (Prefs.passcodeEnabled) { + isAuthenticated = false + _locked.value = true + } + } + + /** Call this once the user has successfully entered their passcode. */ + fun unlock() { + isAuthenticated = true + _locked.value = false + } + + private fun shouldLockNow(): Boolean { + if (!Prefs.passcodeEnabled) return false + if (flowState.isPasscodeFlowActive.value) return false + return !isAuthenticated + } +} + +class PasscodeFlowState { + private val _isPasscodeFlowActive = MutableStateFlow(false) + val isPasscodeFlowActive = _isPasscodeFlowActive.asStateFlow() + + fun setActive(active: Boolean) { + _isPasscodeFlowActive.value = active + } +} 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 3e181787f..aae93516c 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 @@ -3,14 +3,21 @@ package net.opendasharchive.openarchive.features.settings.passcode import android.content.Context import android.content.SharedPreferences import android.util.Base64 -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey +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 class PasscodeRepository( - context: Context, + private val prefs: SharedPreferences, private val config: AppConfig, - private val hashingStrategy: HashingStrategy + private val hashingStrategy: HashingStrategy, + private val aead: PrefAead, ) { companion object { @@ -19,22 +26,23 @@ class PasscodeRepository( private const val KEY_PASSCODE_SALT = "passcode_salt" } - private val encryptedPrefs: SharedPreferences + suspend fun verifyPasscode(passcode: String): Boolean = withContext(Dispatchers.Default) { + val (storedHash, storedSalt) = withContext(Dispatchers.IO) { getPasscodeHashAndSalt() } + if (storedHash == null || storedSalt == null) return@withContext false - init { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() + val computed = hashingStrategy.hash(passcode, storedSalt) + constantTimeEquals(computed, storedHash) + } - encryptedPrefs = EncryptedSharedPreferences.create( - context, - SECURE_PREF_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) + private fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + var result = 0 + for (i in a.indices) result = result or (a[i].toInt() xor b[i].toInt()) + return result == 0 } + private fun aadFor(key: String) = key.toByteArray(Charsets.UTF_8) + suspend fun generateSalt(): ByteArray { return hashingStrategy.generateSalt() } @@ -44,20 +52,28 @@ class PasscodeRepository( } fun storePasscodeHashAndSalt(hash: ByteArray, salt: ByteArray) { - with(encryptedPrefs.edit()) { - putString(KEY_PASSCODE_HASH, Base64.encodeToString(hash, Base64.DEFAULT)) - putString(KEY_PASSCODE_SALT, Base64.encodeToString(salt, Base64.DEFAULT)) + val encHash = aead.encrypt(hash, aadFor(KEY_PASSCODE_HASH)) + val encSalt = aead.encrypt(salt, aadFor(KEY_PASSCODE_SALT)) + + with(prefs.edit()) { + putString(KEY_PASSCODE_HASH, Base64.encodeToString(encHash, Base64.NO_WRAP)) + putString(KEY_PASSCODE_SALT, Base64.encodeToString(encSalt, Base64.NO_WRAP)) apply() } setPasscodeEnabled(true) } fun getPasscodeHashAndSalt(): Pair { - val hashBase64 = encryptedPrefs.getString(KEY_PASSCODE_HASH, null) - val saltBase64 = encryptedPrefs.getString(KEY_PASSCODE_SALT, null) - val passcodeHash = hashBase64?.let { Base64.decode(it, Base64.DEFAULT) } - val passcodeSalt = saltBase64?.let { Base64.decode(it, Base64.DEFAULT) } - return Pair(passcodeHash, passcodeSalt) + val hashB64 = prefs.getString(KEY_PASSCODE_HASH, null) ?: return Pair(null, null) + val saltB64 = prefs.getString(KEY_PASSCODE_SALT, null) ?: return Pair(null, null) + + val hashCipher = Base64.decode(hashB64, Base64.NO_WRAP) + val saltCipher = Base64.decode(saltB64, Base64.NO_WRAP) + + val hash = aead.decrypt(hashCipher, aadFor(KEY_PASSCODE_HASH)) + val salt = aead.decrypt(saltCipher, aadFor(KEY_PASSCODE_SALT)) + + return Pair(hash, salt) } fun setPasscodeEnabled(enabled: Boolean) { @@ -69,7 +85,7 @@ class PasscodeRepository( } fun clearPasscode() { - with(encryptedPrefs.edit()) { + with(prefs.edit()) { remove(KEY_PASSCODE_HASH) remove(KEY_PASSCODE_SALT) apply() @@ -114,4 +130,25 @@ class PasscodeRepository( Prefs.putInt(PasscodeManager.KEY_FAILED_ATTEMPTS, 0) Prefs.putLong(PasscodeManager.KEY_LOCKOUT_TIME, 0L) } -} \ No newline at end of file +} + +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) + } + + 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/components/DefaultScaffold.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/components/DefaultScaffold.kt index 827ca7b9d..0cdc1ad62 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/components/DefaultScaffold.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/components/DefaultScaffold.kt @@ -1,6 +1,7 @@ package net.opendasharchive.openarchive.features.settings.passcode.components import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold @@ -10,36 +11,61 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow +import net.opendasharchive.openarchive.features.core.ComposeAppBar +import net.opendasharchive.openarchive.features.core.UiText object MessageManager { - private val _messageChannel = Channel(Channel.BUFFERED) + private val _messageChannel = Channel(Channel.BUFFERED) val messageFlow = _messageChannel.receiveAsFlow() - suspend fun showMessage(message: String) { + suspend fun showMessage(message: UiText) { _messageChannel.send(message) } } @Composable fun DefaultScaffold( - modifier: Modifier = Modifier, + title: String, + onNavigateBack: () -> Unit = {}, + showNavigationIcon: Boolean = true, + actions: @Composable (RowScope.() -> Unit) = {}, + content: @Composable () -> Unit, +) { + + DefaultScaffold( + topAppBar = { + ComposeAppBar( + title = title, + actions = actions, + onNavigateBack = onNavigateBack, + showNavigationIcon = showNavigationIcon + ) + }, + content = content + ) +} + +@Composable +fun DefaultScaffold( topAppBar: (@Composable () -> Unit)? = null, content: @Composable () -> Unit ) { + val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(Unit) { MessageManager.messageFlow.collectLatest { message -> - snackbarHostState.showSnackbar(message) + snackbarHostState.showSnackbar(message.asString(context)) } } Scaffold( - modifier = modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), topBar = { topAppBar?.invoke() }, @@ -52,4 +78,4 @@ fun DefaultScaffold( } } ) -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/components/NumericKeypad.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/components/NumericKeypad.kt index 9b56aed2d..5f4d2cefd 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/components/NumericKeypad.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/components/NumericKeypad.kt @@ -1,12 +1,10 @@ package net.opendasharchive.openarchive.features.settings.passcode.components -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.spring +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,29 +17,23 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Backspace import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.input.pointer.pointerInput +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import net.opendasharchive.openarchive.features.settings.passcode.AppHapticFeedbackType -import net.opendasharchive.openarchive.features.settings.passcode.HapticManager -import org.koin.compose.koinInject +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark private val keys = listOf( "1", "2", "3", @@ -55,7 +47,8 @@ fun NumericKeypad( isEnabled: Boolean = true, onNumberClick: (String) -> Unit, onDeleteClick: () -> Unit, - onSubmitClick: () -> Unit + onSubmitClick: () -> Unit, + onHaptic: () -> Unit = {}, ) { Box( @@ -85,7 +78,8 @@ fun NumericKeypad( "submit" -> onSubmitClick() else -> onNumberClick(label) } - } + }, + onHaptic = onHaptic ) } else { Spacer(modifier = Modifier.size(72.dp)) @@ -96,7 +90,7 @@ fun NumericKeypad( } } -@Preview +@PreviewLightDark @Composable private fun NumericKeypadPreview() { @@ -133,62 +127,52 @@ private fun NumberButton( label: String, enabled: Boolean = true, onClick: () -> Unit, - hapticManager: HapticManager = koinInject() + onHaptic: () -> Unit = {}, ) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() + val pressedColor = when (label) { + "delete" -> colorResource(R.color.red_bg).copy(alpha = 0.5f) + "submit" -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.6f) + } + val restingColor = when (label) { + "delete" -> colorResource(R.color.red_bg).copy(alpha = 0.3f) + "submit" -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.5f) + else -> Color.Transparent + } + val tertiaryColor = MaterialTheme.colorScheme.tertiary - // Determine background color based on button type and pressed state - val backgroundColor by animateColorAsState( - targetValue = when { - isPressed -> when (label) { - "delete" -> colorResource(R.color.red_bg).copy(alpha = 0.5f) - "submit" -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f) - else -> MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - } + // pressAlpha drives the fill: 1f = fully pressed color, 0f = resting color + val pressAlpha = remember { Animatable(0f) } - else -> when (label) { - "delete" -> colorResource(R.color.red_bg).copy(alpha = 0.3f) - "submit" -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.5f) - else -> Color.Transparent - } - }, - animationSpec = spring(), - label = "" - ) - - // Determine background color based on button type and pressed state - val borderColor by animateColorAsState( - targetValue = when { - isPressed -> when (label) { - "delete" -> Color.Transparent - "submit" -> Color.Transparent - else -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.5f) - } + // Use a scope so we can cancel the fade-out if a new press arrives mid-animation + val scope = androidx.compose.runtime.rememberCoroutineScope() - else -> when (label) { - "delete" -> Color.Transparent - "submit" -> Color.Transparent - else -> MaterialTheme.colorScheme.tertiary - } - }, - animationSpec = spring(), - label = "" - ) + val backgroundColor = androidx.compose.ui.graphics.lerp(restingColor, pressedColor, pressAlpha.value) + val borderColor = if (label == "delete" || label == "submit") { + Color.Transparent + } else { + tertiaryColor.copy(alpha = 1f - pressAlpha.value) + } Box( modifier = Modifier .background(color = backgroundColor, shape = CircleShape) - .clickable( - interactionSource = interactionSource, - indication = null, - enabled = enabled, - onClick = { - hapticManager?.performHapticFeedback(AppHapticFeedbackType.KeyPress) - onClick() - } - ) + .pointerInput(enabled) { + detectTapGestures( + onPress = { _ -> + if (!enabled) return@detectTapGestures + onHaptic() + scope.launch { pressAlpha.snapTo(1f) } + tryAwaitRelease() + scope.launch { + delay(60) + pressAlpha.animateTo(0f, animationSpec = tween(200)) + } + }, + onTap = { if (enabled) onClick() } + ) + } .border(width = 2.dp, color = borderColor, shape = CircleShape) .size(72.dp), contentAlignment = Alignment.Center @@ -209,8 +193,7 @@ private fun NumberButton( else -> Text( text = label, - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleLarge.copy( color = MaterialTheme.colorScheme.onBackground ) ) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryActivity.kt index 9b093b75d..6ce269419 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryActivity.kt @@ -6,31 +6,37 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.settings.passcode.HapticManager +import net.opendasharchive.openarchive.features.core.BaseComposeActivity import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository import net.opendasharchive.openarchive.features.settings.passcode.components.DefaultScaffold +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.android.ext.android.inject -class PasscodeEntryActivity : BaseActivity() { +class PasscodeEntryActivity : BaseComposeActivity() { + private val viewModel: PasscodeEntryViewModel by viewModel() private val repository: PasscodeRepository by inject() - private val hapticManager: HapticManager by inject() + + /** When true, the activity returns RESULT_OK on success (used for in-app verification). */ + private val isVerifyMode: Boolean + get() = intent.getBooleanExtra(EXTRA_VERIFY_MODE, false) private val onBackPressedCallback = object : OnBackPressedCallback(enabled = true) { override fun handleOnBackPressed() { - // Do nothing to prevent back navigation - moveTaskToBack(true) + if (isVerifyMode) { + setResult(RESULT_CANCELED) + finish() + } else { + moveTaskToBack(true) + } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Set up the OnBackPressedCallback onBackPressedDispatcher.addCallback(onBackPressedCallback) - // Check if passcode is locked if (repository.isLockedOut()) { Toast.makeText( @@ -38,7 +44,12 @@ class PasscodeEntryActivity : BaseActivity() { getString(R.string.multiple_failed_attempts_message), Toast.LENGTH_LONG ).show() - finishAndRemoveTask() + if (isVerifyMode) { + setResult(RESULT_CANCELED) + finish() + } else { + finishAndRemoveTask() + } return } @@ -46,11 +57,30 @@ class PasscodeEntryActivity : BaseActivity() { SaveAppTheme { DefaultScaffold { PasscodeEntryScreen( - onPasscodeSuccess = { - finish() + viewModel = viewModel, + onSuccess = { + if (isVerifyMode) { + setResult(RESULT_OK) + finish() + } else { + finish() + } + }, + onLockedOut = { + if (isVerifyMode) { + setResult(RESULT_CANCELED) + finish() + } else { + finishAndRemoveTask() + } }, onExit = { - finishAffinity() + if (isVerifyMode) { + setResult(RESULT_CANCELED) + finish() + } else { + moveTaskToBack(true) + } } ) } @@ -58,8 +88,8 @@ class PasscodeEntryActivity : BaseActivity() { } } - override fun onDestroy() { - super.onDestroy() - hapticManager.clear() // Clear the reference to prevent leaks + companion object { + /** Pass as an extra to request verification-only mode (returns RESULT_OK on success). */ + const val EXTRA_VERIFY_MODE = "extra_verify_mode" } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryScreen.kt index f7e2495ca..3f92b06f9 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -14,7 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -28,67 +27,55 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.collectLatest import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.DefaultEmptyScaffoldPreview -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.UiTextArg import net.opendasharchive.openarchive.features.settings.passcode.AppHapticFeedbackType import net.opendasharchive.openarchive.features.settings.passcode.HapticManager import net.opendasharchive.openarchive.features.settings.passcode.components.MessageManager import net.opendasharchive.openarchive.features.settings.passcode.components.NumericKeypad import net.opendasharchive.openarchive.features.settings.passcode.components.PasscodeDots -import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @Composable fun PasscodeEntryScreen( - onPasscodeSuccess: () -> Unit, + viewModel: PasscodeEntryViewModel, + hapticManager: HapticManager = koinInject(), + onSuccess: () -> Unit, + onLockedOut: () -> Unit, onExit: () -> Unit, - viewModel: PasscodeEntryViewModel = koinViewModel(), - hapticManager: HapticManager = koinInject() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - - val hapticFeedback = LocalHapticFeedback.current - LaunchedEffect(Unit) { - hapticManager.init(hapticFeedback) + BackHandler(enabled = true) { + onExit() } // Function to handle passcode entry LaunchedEffect(Unit) { viewModel.uiEvent.collectLatest { event -> when (event) { - PasscodeEntryUiEvent.Success -> onPasscodeSuccess() - - PasscodeEntryUiEvent.PasscodeNotSet -> { - MessageManager.showMessage(context.getString(R.string.passcode_not_set)) - } is PasscodeEntryUiEvent.IncorrectPasscode -> { - hapticManager.performHapticFeedback(AppHapticFeedbackType.Error) + hapticManager.perform(hapticFeedback, AppHapticFeedbackType.Error) event.remainingAttempts?.let { - val message = context.getString(R.string.passcode_remaining_attempts, it)//"Incorrect passcode. $it attempts remaining." + val message = UiText.Resource(R.string.passcode_remaining_attempts, + listOf(UiTextArg.Num(it)))//"Incorrect passcode. $it attempts remaining." MessageManager.showMessage(message) } } - - PasscodeEntryUiEvent.LockedOut -> { - MessageManager.showMessage(context.getString(R.string.passcode_too_many_failed_attempts)) - onExit() - } + PasscodeEntryUiEvent.Success -> onSuccess() + PasscodeEntryUiEvent.LockedOut -> onLockedOut() } } } @@ -96,7 +83,7 @@ fun PasscodeEntryScreen( PasscodeEntryScreenContent( state = state, onAction = viewModel::onAction, - onExit = onExit, + onHaptic = { hapticManager.perform(hapticFeedback, AppHapticFeedbackType.Error) } ) } @@ -105,7 +92,7 @@ fun PasscodeEntryScreen( fun PasscodeEntryScreenContent( state: PasscodeEntryScreenState, onAction: (PasscodeEntryScreenAction) -> Unit, - onExit: () -> Unit, + onHaptic: () -> Unit, ) { Column( @@ -172,6 +159,9 @@ fun PasscodeEntryScreenContent( onAction(PasscodeEntryScreenAction.OnBackspaceClick) }, onSubmitClick = { + onAction(PasscodeEntryScreenAction.OnSubmit) + }, + onHaptic = { } ) @@ -234,8 +224,8 @@ private fun PasscodeEntryScreenPreview() { passcodeLength = 6 ), onAction = {}, - onExit = {}, + onHaptic = {} ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryViewModel.kt index 0cb3f2a70..fa1749596 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryViewModel.kt @@ -2,6 +2,7 @@ package net.opendasharchive.openarchive.features.settings.passcode.passcode_entr import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -10,8 +11,12 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.features.settings.passcode.AppConfig +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.core.config.AppConfig import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository +import net.opendasharchive.openarchive.features.settings.passcode.components.MessageManager class PasscodeEntryViewModel( private val repository: PasscodeRepository, @@ -31,6 +36,15 @@ class PasscodeEntryViewModel( private val _uiEvent = Channel() val uiEvent = _uiEvent.receiveAsFlow() + init { + if (repository.isLockedOut()) { + viewModelScope.launch { + MessageManager.showMessage(UiText.Resource(R.string.passcode_too_many_failed_attempts)) + _uiEvent.send(PasscodeEntryUiEvent.LockedOut) + } + } + } + // val passcodeLength: Int // get() = config.passcodeLength @@ -55,7 +69,7 @@ class PasscodeEntryViewModel( else state.copy(passcode = state.passcode + number) } - if (uiState.value.passcode.length == config.passcodeLength) { + if (config.autoVerifyPasscode && uiState.value.passcode.length == config.passcodeLength) { // _isCheckingPasscode.value = true _uiState.update { it.copy(isProcessing = true) } checkPasscode() @@ -73,51 +87,58 @@ class PasscodeEntryViewModel( } private fun onSubmit() { - + if (uiState.value.passcode.length == config.passcodeLength && !uiState.value.isProcessing) { + _uiState.update { it.copy(isProcessing = true) } + checkPasscode() + } } private fun checkPasscode() = viewModelScope.launch { val currentState = uiState.value val currentPasscode = currentState.passcode + delay(200) - val (passcodeHash, passcodeSalt) = repository.getPasscodeHashAndSalt() - if (passcodeHash != null && passcodeSalt != null) { - val hash = repository.hashPasscode(currentPasscode, passcodeSalt) - if (hash.contentEquals(passcodeHash)) { - repository.resetFailedAttempts() - _uiEvent.send(PasscodeEntryUiEvent.Success) + val hasPasscodeSet = withContext(Dispatchers.IO) { + repository.getPasscodeHashAndSalt().let { it.first != null && it.second != null } + } + + if (!hasPasscodeSet) { + MessageManager.showMessage(UiText.Resource(R.string.passcode_not_set)) + shake() + resetUi() + return@launch + } + + val ok = repository.verifyPasscode(currentPasscode) + + if (ok) { + repository.resetFailedAttempts() + _uiEvent.send(PasscodeEntryUiEvent.Success) + } else { + repository.recordFailedAttempt() + + if (repository.isLockedOut()) { + MessageManager.showMessage(UiText.Resource(R.string.passcode_too_many_failed_attempts)) + _uiEvent.send(PasscodeEntryUiEvent.LockedOut) } else { - repository.recordFailedAttempt() - val remainingAttempts: Int? = if (config.maxRetryLimitEnabled) { - repository.getRemainingAttempts() - } else null - - if (repository.isLockedOut()) { - _uiEvent.send(PasscodeEntryUiEvent.LockedOut) - } else { - _uiEvent.send(PasscodeEntryUiEvent.IncorrectPasscode(remainingAttempts)) - } - _uiState.update { it.copy(shouldShake = true) } - delay(500) - _uiState.update { it.copy(shouldShake = false) } + val remainingAttempts = if (config.maxRetryLimitEnabled) repository.getRemainingAttempts() else null + _uiEvent.send(PasscodeEntryUiEvent.IncorrectPasscode(remainingAttempts)) } - } else { - _uiEvent.send(PasscodeEntryUiEvent.PasscodeNotSet) - _uiState.update { it.copy(shouldShake = true) } - delay(500) - _uiState.update { it.copy(shouldShake = false) } + shake() } - _uiState.update { - it.copy( - passcode = "", - isProcessing = false - ) - } -// _passcode.value = "" -// _isCheckingPasscode.value = false + resetUi() + } + + private suspend fun shake() { + _uiState.update { it.copy(shouldShake = true) } + delay(500) + _uiState.update { it.copy(shouldShake = false) } + } + private fun resetUi() { + _uiState.update { it.copy(passcode = "", isProcessing = false) } } } @@ -135,8 +156,7 @@ sealed class PasscodeEntryScreenAction { } sealed class PasscodeEntryUiEvent { - data object Success : PasscodeEntryUiEvent() data class IncorrectPasscode(val remainingAttempts: Int? = null) : PasscodeEntryUiEvent() - data object PasscodeNotSet : PasscodeEntryUiEvent() + data object Success : PasscodeEntryUiEvent() data object LockedOut : PasscodeEntryUiEvent() -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupActivity.kt index 506d5dabb..14fe6eebe 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupActivity.kt @@ -9,14 +9,15 @@ import androidx.activity.compose.setContent import androidx.compose.ui.res.stringResource import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.features.core.BaseActivity +import net.opendasharchive.openarchive.features.core.BaseComposeActivity import net.opendasharchive.openarchive.features.core.ComposeAppBar import net.opendasharchive.openarchive.features.settings.passcode.HapticManager import net.opendasharchive.openarchive.features.settings.passcode.components.DefaultScaffold import org.koin.android.ext.android.inject -class PasscodeSetupActivity : BaseActivity() { +class PasscodeSetupActivity : BaseComposeActivity() { + private val viewModel: PasscodeSetupViewModel by inject() private val hapticManager: HapticManager by inject() companion object { @@ -32,7 +33,7 @@ class PasscodeSetupActivity : BaseActivity() { topAppBar = { ComposeAppBar( title = stringResource(R.string.passcode_lock_app), - onNavigationAction = { + onNavigateBack = { setResult(RESULT_CANCELED) finish() } @@ -47,6 +48,7 @@ class PasscodeSetupActivity : BaseActivity() { } PasscodeSetupScreen( + viewModel = viewModel, onPasscodeSet = { // Passcode successfully set setResult(RESULT_OK, Intent().apply { @@ -75,8 +77,4 @@ class PasscodeSetupActivity : BaseActivity() { return super.onOptionsItemSelected(item) } - override fun onDestroy() { - super.onDestroy() - hapticManager.clear() // Clear the reference to prevent leaks - } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupScreen.kt index 00c0aa191..975cd9ca0 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -30,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.collectLatest import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.settings.passcode.AppHapticFeedbackType import net.opendasharchive.openarchive.features.settings.passcode.HapticManager import net.opendasharchive.openarchive.features.settings.passcode.components.MessageManager @@ -40,32 +42,25 @@ import org.koin.compose.koinInject @Composable fun PasscodeSetupScreen( + viewModel: PasscodeSetupViewModel, + hapticManager: HapticManager = koinInject(), onPasscodeSet: () -> Unit, onCancel: () -> Unit, - viewModel: PasscodeSetupViewModel = koinViewModel(), - hapticManager: HapticManager = koinInject() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - val hapticFeedback = LocalHapticFeedback.current - LaunchedEffect(Unit) { - hapticManager.init(hapticFeedback) - } - // Function to handle UI events LaunchedEffect(Unit) { viewModel.uiEvent.collectLatest { event -> when (event) { - PasscodeSetupUiEvent.PasscodeSet -> onPasscodeSet() PasscodeSetupUiEvent.PasscodeDoNotMatch -> { - hapticManager.performHapticFeedback(AppHapticFeedbackType.Error) - MessageManager.showMessage(context.getString(R.string.passcode_do_not_match)) + hapticManager.perform(hapticFeedback, AppHapticFeedbackType.Error) + MessageManager.showMessage(UiText.Resource(R.string.passcode_do_not_match)) } - + PasscodeSetupUiEvent.PasscodeSet -> onPasscodeSet() PasscodeSetupUiEvent.PasscodeCancelled -> onCancel() } } @@ -75,13 +70,17 @@ fun PasscodeSetupScreen( PasscodeSetupScreenContent( state = uiState, onAction = viewModel::onAction, + onHaptic = { + hapticManager.perform(hapticFeedback, AppHapticFeedbackType.Error) + } ) } @Composable private fun PasscodeSetupScreenContent( state: PasscodeSetupUiState, - onAction: (PasscodeSetupUiAction) -> Unit + onAction: (PasscodeSetupUiAction) -> Unit, + onHaptic: () -> Unit = {} ) { @@ -154,7 +153,9 @@ private fun PasscodeSetupScreenContent( }, onSubmitClick = { onAction(PasscodeSetupUiAction.OnSubmit) - } + }, + onHaptic = onHaptic + ) Spacer(modifier = Modifier.height(64.dp)) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupViewModel.kt index 1af8b2ae7..7c1f66176 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_setup/PasscodeSetupViewModel.kt @@ -10,7 +10,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.features.settings.passcode.AppConfig +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.core.config.AppConfig import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository class PasscodeSetupViewModel( @@ -29,7 +31,9 @@ class PasscodeSetupViewModel( when (action) { is PasscodeSetupUiAction.OnNumberClick -> onNumberClick(action.number) PasscodeSetupUiAction.OnBackspaceClick -> onBackspaceClick() - PasscodeSetupUiAction.OnCancel -> onCancel() + PasscodeSetupUiAction.OnCancel -> viewModelScope.launch { + _uiEvent.send(PasscodeSetupUiEvent.PasscodeCancelled) + } PasscodeSetupUiAction.OnSubmit -> onSubmit() } } @@ -130,10 +134,6 @@ class PasscodeSetupViewModel( ) } } - - private fun onCancel() = viewModelScope.launch { - _uiEvent.send(PasscodeSetupUiEvent.PasscodeCancelled) - } } data class PasscodeSetupUiState( @@ -154,6 +154,6 @@ sealed class PasscodeSetupUiAction { sealed class PasscodeSetupUiEvent { data object PasscodeSet : PasscodeSetupUiEvent() - data object PasscodeDoNotMatch : PasscodeSetupUiEvent() data object PasscodeCancelled : PasscodeSetupUiEvent() + data object PasscodeDoNotMatch : PasscodeSetupUiEvent() } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/ServerOptionItem.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/ServerOptionItem.kt index 7c3c8bc5b..d6ce78384 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/ServerOptionItem.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/ServerOptionItem.kt @@ -1,6 +1,5 @@ package net.opendasharchive.openarchive.features.spaces -import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable @@ -15,8 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -29,8 +26,7 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.opendasharchive.openarchive.R diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt deleted file mode 100644 index 8d8cc5903..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt +++ /dev/null @@ -1,87 +0,0 @@ -package net.opendasharchive.openarchive.features.spaces - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.LaunchedEffect -import androidx.navigation.fragment.findNavController -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.databinding.FragmentSpaceListBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseFragment -import org.koin.compose.viewmodel.koinViewModel - -class SpaceListFragment : BaseFragment() { - - private lateinit var binding: FragmentSpaceListBinding - - companion object { - const val EXTRA_DATA_SPACE = "space_id" - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - binding = FragmentSpaceListBinding.inflate(inflater) - - - binding.composeViewSpaceList.setContent { - - val viewModel: SpaceListViewModel = koinViewModel() - - SaveAppTheme { - - // Calling refresh here will update state & trigger recomposition - LaunchedEffect(Unit) { - viewModel.refreshSpaces() - } - - SpaceListScreen( - onSpaceClicked = { space -> - startSpaceAuthActivity(space.id) - }, - onAddServerClicked = { - val action = - SpaceListFragmentDirections.actionFragmentSpaceListToFragmentSpaceSetup() - findNavController().navigate(action) - } - ) - } - - } - - return binding.root - } - - override fun getToolbarTitle() = getString(R.string.pref_title_media_servers) - - private fun startSpaceAuthActivity(spaceId: Long?) { - val space = Space.get(spaceId ?: return) ?: return - - when (space.tType) { - Space.Type.INTERNET_ARCHIVE -> { - val action = SpaceListFragmentDirections.actionFragmentSpaceListToInternetArchiveDetails(space.id) - findNavController().navigate(action) - } - - Space.Type.WEBDAV -> { - val action = - SpaceListFragmentDirections.actionFragmentSpaceListToFragmentWebDav(spaceId) - findNavController().navigate(action) - } - - - Space.Type.RAVEN -> { - // Do nothing - } - } - - - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListScreen.kt index 907f090af..c175a9a6f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListScreen.kt @@ -1,7 +1,6 @@ package net.opendasharchive.openarchive.features.spaces import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,7 +14,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -29,65 +27,44 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.mappers.toDomain import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily -import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction +import net.opendasharchive.openarchive.db.sugar.dummySpaceList import net.opendasharchive.openarchive.features.main.ui.components.SpaceIcon -import net.opendasharchive.openarchive.features.main.ui.components.dummySpaceList -import net.opendasharchive.openarchive.util.NetworkUtils -import org.koin.androidx.compose.koinViewModel -class SpaceListViewModel() : ViewModel() { - - private val _spaceList = MutableStateFlow>(emptyList()) - val spaceList: StateFlow> = _spaceList - - fun refreshSpaces() { - _spaceList.value = Space.getAll().asSequence().toList() - } -} - @Composable fun SpaceListScreen( - onSpaceClicked: (Space) -> Unit, - onAddServerClicked: () -> Unit = {}, - viewModel: SpaceListViewModel = koinViewModel() + viewModel: SpaceListViewModel, ) { - val spaceList by viewModel.spaceList.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() // This will get called again when the screen resumes (see Fragment below) LaunchedEffect(Unit) { - viewModel.refreshSpaces() + viewModel.onAction(SpaceListAction.RefreshSpaces) } - Box(modifier = Modifier.fillMaxSize()) { - SpaceListScreenContent( - spaceList = spaceList, - onSpaceClicked = onSpaceClicked, - onAddServerClicked = onAddServerClicked - ) - } + + SpaceListScreenContent( + state = uiState, + onAction = viewModel::onAction, + ) + } @Composable fun SpaceListScreenContent( - onSpaceClicked: (Space) -> Unit, - onAddServerClicked: () -> Unit, - spaceList: List = emptyList() + state: SpaceListState, + onAction: (SpaceListAction) -> Unit, ) { Box(modifier = Modifier.fillMaxSize()) { - if (spaceList.isEmpty()) { + if (state.spaceList.isEmpty()) { // Empty state with centered message Box( modifier = Modifier.fillMaxSize(), @@ -110,11 +87,11 @@ fun SpaceListScreenContent( .padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp) ) { - spaceList.forEach { space -> + state.spaceList.forEach { space -> SpaceListItem( space = space, onClick = { - onSpaceClicked(space) + onAction(SpaceListAction.NavigateToSpace(space.id, space.type)) } ) } @@ -123,7 +100,9 @@ fun SpaceListScreenContent( // Add Server button at bottom center (visible in both states) Button( - onClick = onAddServerClicked, + onClick = { + onAction(SpaceListAction.AddNewSpace) + }, modifier = Modifier .heightIn(ThemeDimensions.touchable) .align(Alignment.BottomCenter) @@ -148,44 +127,44 @@ fun SpaceListScreenContent( } -/** + /** Button( - modifier = Modifier - .padding(8.dp) - .heightIn(ThemeDimensions.touchable) - .weight(1f), - enabled = !state.isBusy && state.isValid, - shape = RoundedCornerShape(ThemeDimensions.roundedCorner), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - disabledContainerColor = colorResource(R.color.grey_50), - disabledContentColor = colorResource(R.color.black), - ), - onClick = { - if (NetworkUtils.isNetworkAvailable(context)) { - onAction(InternetArchiveLoginAction.Login) - } else { - Toast.makeText(context, R.string.error_no_internet, Toast.LENGTH_LONG) - .show() - } - }, + modifier = Modifier + .padding(8.dp) + .heightIn(ThemeDimensions.touchable) + .weight(1f), + enabled = !state.isBusy && state.isValid, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + disabledContainerColor = colorResource(R.color.grey_50), + disabledContentColor = colorResource(R.color.black), + ), + onClick = { + if (NetworkUtils.isNetworkAvailable(context)) { + onAction(InternetArchiveLoginAction.Login) + } else { + Toast.makeText(context, R.string.error_no_internet, Toast.LENGTH_LONG) + .show() + } + }, ) { - if (state.isBusy) { - CircularProgressIndicator(color = ThemeColors.material.primary) - } else { - Text( - stringResource(R.string.next), - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold) - ) - } + if (state.isBusy) { + CircularProgressIndicator(color = ThemeColors.material.primary) + } else { + Text( + stringResource(R.string.next), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold) + ) + } } - **/ + **/ } } @Composable fun SpaceListItem( - space: Space, + space: Vault, onClick: () -> Unit ) { Row( @@ -197,7 +176,7 @@ fun SpaceListItem( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { SpaceIcon( - type = space.tType, + type = space.type, modifier = Modifier.size(42.dp) ) @@ -215,7 +194,7 @@ fun SpaceListItem( ) Text( - text = space.tType.friendlyName, + text = space.type.friendlyName, style = MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp, @@ -233,9 +212,10 @@ private fun SpaceListScreenPreview() { DefaultScaffoldPreview { SpaceListScreenContent( - spaceList = dummySpaceList, - onSpaceClicked = {}, - onAddServerClicked = {} + state = SpaceListState( + spaceList = dummySpaceList.map { it.toDomain() }, + ), + onAction = {} ) } } @@ -247,9 +227,10 @@ private fun SpaceListEmptyScreenPreview() { DefaultScaffoldPreview { SpaceListScreenContent( - spaceList = emptyList(), - onSpaceClicked = {}, - onAddServerClicked = {} + state = SpaceListState( + spaceList = emptyList(), + ), + onAction = {} ) } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListViewModel.kt new file mode 100644 index 000000000..fbe8acf06 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListViewModel.kt @@ -0,0 +1,64 @@ +package net.opendasharchive.openarchive.features.spaces + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator + +class SpaceListViewModel( + private val navigator: Navigator, + private val spaceRepository: SpaceRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SpaceListState(emptyList())) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refreshSpaces() + } + + fun onAction(action: SpaceListAction) { + when (action) { + is SpaceListAction.NavigateToSpace -> { + navigateToSpaceDetail(spaceId = action.spaceId, spaceType = action.spaceType) + } + + is SpaceListAction.AddNewSpace -> { + navigator.navigateTo(AppRoute.SpaceSetupRoute) + } + + SpaceListAction.RefreshSpaces -> refreshSpaces() + } + } + + private fun refreshSpaces() = viewModelScope.launch { + val spaceList = spaceRepository.getSpaces() + _uiState.update { it.copy(spaceList = spaceList) } + } + + private fun navigateToSpaceDetail(spaceId: Long, spaceType: VaultType) { + when (spaceType) { + VaultType.PRIVATE_SERVER -> navigator.navigateTo(AppRoute.WebDavDetailRoute(spaceId)) + VaultType.INTERNET_ARCHIVE -> navigator.navigateTo(AppRoute.IADetailRoute(spaceId)) + VaultType.DWEB_STORAGE -> navigator.navigateTo(AppRoute.SnowbirdDashboardRoute) + } + } +} + +data class SpaceListState( + val spaceList: List, +) + +sealed interface SpaceListAction { + data class NavigateToSpace(val spaceId: Long, val spaceType: VaultType) : SpaceListAction + data object AddNewSpace : SpaceListAction + data object RefreshSpaces : SpaceListAction +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt index e38159954..3f597049e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark + @Composable fun SpaceSetupScreen( @@ -27,7 +29,7 @@ fun SpaceSetupScreen( isInternetArchiveAllowed: Boolean, onInternetArchiveClick: () -> Unit, isDwebEnabled: Boolean, - onDwebClicked: () -> Unit + onDwebClicked: () -> Unit, ) { // Use a scrollable Column to mimic ScrollView + LinearLayout Column( @@ -50,9 +52,14 @@ fun SpaceSetupScreen( color = MaterialTheme.colorScheme.onBackground ) ) + Spacer(modifier = Modifier.height(12.dp)) - val description = if (isDwebEnabled) stringResource(R.string.to_get_started_more_hint_dweb) else stringResource(R.string.to_get_started_more_hint) + val description = if (isDwebEnabled) { + stringResource(R.string.to_get_started_more_hint_dweb) + } else { + stringResource(R.string.to_get_started_more_hint) + } Text( text = description, style = MaterialTheme.typography.bodyMedium.copy( @@ -94,11 +101,11 @@ fun SpaceSetupScreen( onClick = onDwebClicked ) } + } } -@Preview -@Preview(uiMode = UI_MODE_NIGHT_YES) +@PreviewLightDark @Composable private fun SpaceSetupScreenPreview() { DefaultScaffoldPreview { diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupViewModel.kt new file mode 100644 index 000000000..82c467df8 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupViewModel.kt @@ -0,0 +1,77 @@ +package net.opendasharchive.openarchive.features.spaces + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.core.config.AppConfig +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.util.Prefs + +data class SpaceSetupState( + val isInternetArchiveAllowed: Boolean = false, + val isDwebEnabled: Boolean = false, +) + +sealed interface SpaceSetupAction { + data object WebDavClicked : SpaceSetupAction + data object InternetArchiveClicked : SpaceSetupAction + data object DwebClicked : SpaceSetupAction +} + +class SpaceSetupViewModel( + private val appConfig: AppConfig, + private val navigator: Navigator, + private val spaceRepository: SpaceRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SpaceSetupState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadInitialState() + } + + private fun loadInitialState() { + viewModelScope.launch { + val hasInternetArchive = spaceRepository.getSpaces().any { it.type == VaultType.INTERNET_ARCHIVE } + _uiState.update { + it.copy( + isInternetArchiveAllowed = !hasInternetArchive, + isDwebEnabled = appConfig.isDwebEnabled, + ) + } + } + } + + fun onAction(action: SpaceSetupAction) { + when (action) { + SpaceSetupAction.WebDavClicked -> { + viewModelScope.launch { + navigator.navigateTo(AppRoute.WebDavLoginRoute) + } + } + + SpaceSetupAction.InternetArchiveClicked -> { + viewModelScope.launch { + navigator.navigateTo(AppRoute.IALoginRoute) + } + } + + SpaceSetupAction.DwebClicked -> { + viewModelScope.launch { + navigator.navigateTo(AppRoute.SnowbirdDashboardRoute) + } + } + + } + } +} 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 e00a59cad..c63d4c3cd 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.res.Configuration import android.webkit.MimeTypeMap import com.google.common.net.UrlEscapers -import com.google.gson.GsonBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -14,29 +13,46 @@ import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent import net.opendasharchive.openarchive.analytics.api.AnalyticsManager import net.opendasharchive.openarchive.analytics.api.session.SessionTracker +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.domain.toMetadata import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.services.internetarchive.IaConduit -import net.opendasharchive.openarchive.services.webdav.WebDavConduit +import net.opendasharchive.openarchive.core.repositories.CollectionRepository +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.services.internetarchive.data.IaConduit +import net.opendasharchive.openarchive.services.webdav.data.WebDavConduit import net.opendasharchive.openarchive.upload.BroadcastManager +import net.opendasharchive.openarchive.upload.UploadEventBus +import net.opendasharchive.openarchive.util.C2paHelper import net.opendasharchive.openarchive.util.Prefs +import net.opendasharchive.openarchive.util.toJavaDate +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.opendasharchive.openarchive.util.DateUtils import okhttp3.HttpUrl import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.witness.proofmode.storage.DefaultStorageProvider +import org.koin.core.context.GlobalContext import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.text.SimpleDateFormat -import java.util.Date import java.util.Locale abstract class Conduit( - protected val mMedia: Media, + protected var mEvidence: Evidence, protected val mContext: Context ) : KoinComponent { + val id: Long get() = mEvidence.id + + protected val mediaRepository: MediaRepository by inject() + protected val collectionRepository: CollectionRepository by inject() + protected val projectRepository: ProjectRepository by inject() + protected val spaceRepository: SpaceRepository by inject() + protected val analyticsManager: AnalyticsManager by inject() protected val sessionTracker: SessionTracker by inject() protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -57,14 +73,15 @@ abstract class Conduit( private fun trackUploadStarted() { uploadStartTime = System.currentTimeMillis() - val backendType = mMedia.space?.tType?.friendlyName ?: "Unknown" - val fileSizeKB = mMedia.contentLength / 1024 - val fileType = getFileType(mMedia.mimeType) + scope.launch { + val vault = spaceRepository.getSpaceById(mEvidence.vaultId) + val backendType = vault?.type?.friendlyName ?: "Unknown" + val fileSizeKB = mEvidence.contentLength / 1024 + val fileType = getFileType(mEvidence.mimeType) - // Add breadcrumb for crash analysis - AppLogger.breadcrumb("Upload Started", "$fileType to $backendType (${fileSizeKB}KB)") + // Add breadcrumb for crash analysis + AppLogger.breadcrumb("Upload Started", "$fileType to $backendType (${fileSizeKB}KB)") - scope.launch { analyticsManager.trackUploadStarted( backendType = backendType, fileType = fileType, @@ -99,20 +116,29 @@ abstract class Conduit( mCancelled = true } - fun getProof(): Array { - if (!Prefs.useProofMode) return emptyArray() + /** + * Get C2PA manifest file for this media + * Returns the sidecar .c2pa.json file if C2PA is enabled and manifest exists + */ + fun getC2paManifest(): File? { + if (!Prefs.useC2pa) { + AppLogger.d("[C2PA] Disabled, skipping manifest retrieval") + return null + } + try { - // Here we are simply fetching the files. Don't generate proof here. This is only called during upload. - // Generating Proof here won't make sense because the file can be created well before it could be uploaded. - //var files = ProofMode.getProofDir(mContext, mMedia.mediaHashString).listFiles() ?: emptyArray() - var files = DefaultStorageProvider(mContext).getHashStorageDir(mMedia.mediaHashString)?.listFiles() ?: emptyArray() - return files - } catch (exception: FileNotFoundException) { - AppLogger.e(exception) - return emptyArray() - } catch (exception: SecurityException) { - AppLogger.e(exception) - return emptyArray() + val manifestFile = C2paHelper.getC2paFile(mContext, mEvidence.mediaHashString) + + if (manifestFile.exists()) { + AppLogger.d("[C2PA] Manifest found: ${manifestFile.absolutePath}") + return manifestFile + } else { + AppLogger.w("[C2PA] Manifest not found for ${mEvidence.mediaHashString}") + return null + } + } catch (exception: Exception) { + AppLogger.e("[C2PA] Error retrieving manifest", exception) + return null } } @@ -120,25 +146,31 @@ abstract class Conduit( * result is a site specific unique id that we can use to fetch the data, * build an embed tag, etc. for some sites this might be a URL */ - fun jobSucceeded() { - mMedia.progress = mMedia.contentLength - mMedia.sStatus = Media.Status.Uploaded - mMedia.save() - AppLogger.i("media item ${mMedia.id} is uploaded and saved") - - // Track successful upload analytics - val uploadDuration = (System.currentTimeMillis() - uploadStartTime) / 1000 - val fileSizeKB = mMedia.contentLength / 1024 - val backendType = mMedia.space?.tType?.friendlyName ?: "Unknown" - val fileType = getFileType(mMedia.mimeType) - - // Calculate upload speed - val uploadSpeedKBps = if (uploadDuration > 0) fileSizeKB / uploadDuration else 0 - - // Add breadcrumb for crash analysis - AppLogger.breadcrumb("Upload Completed", "$fileType (${uploadDuration}s, ${uploadSpeedKBps}KB/s)") + suspend fun jobSucceeded() { + mEvidence = mEvidence.copy( + progress = mEvidence.contentLength, + status = EvidenceStatus.UPLOADED, + uploadPercentage = 100 + ) + mediaRepository.updateEvidence(mEvidence) + AppLogger.i("media item ${mEvidence.id} is uploaded and saved") + + // Track successful upload analytics + val uploadDuration = (System.currentTimeMillis() - uploadStartTime) / 1000 + val fileSizeKB = mEvidence.contentLength / 1024 + val vault = spaceRepository.getSpaceById(mEvidence.vaultId) + val backendType = vault?.type?.friendlyName ?: "Unknown" + val fileType = getFileType(mEvidence.mimeType) + + // Calculate upload speed + val uploadSpeedKBps = if (uploadDuration > 0) fileSizeKB / uploadDuration else 0 + + // Add breadcrumb for crash analysis + AppLogger.breadcrumb( + "Upload Completed", + "$fileType (${uploadDuration}s, ${uploadSpeedKBps}KB/s)" + ) - scope.launch { analyticsManager.trackUploadCompleted( backendType = backendType, fileType = fileType, @@ -146,104 +178,128 @@ abstract class Conduit( durationSeconds = uploadDuration, uploadSpeedKBps = uploadSpeedKBps ) - } - // Track in session - sessionTracker.trackUploadCompleted() + // Track in session + sessionTracker.trackUploadCompleted() - BroadcastManager.postSuccess( - context = mContext, - collectionId = mMedia.collectionId, - mediaId = mMedia.id - ) + BroadcastManager.postSuccess( + context = mContext, + collectionId = mEvidence.submissionId, + mediaId = mEvidence.id + ) + UploadEventBus.emitChanged( + projectId = mEvidence.archiveId, + collectionId = mEvidence.submissionId, + mediaId = mEvidence.id, + progress = 100, + isUploaded = true + ) } - fun jobFailed(exception: Throwable) { + suspend fun jobFailed(exception: Throwable) { // If an upload was cancelled, track and return. if (mCancelled) { AppLogger.i("Upload cancelled", exception) // Add breadcrumb - val backendType = mMedia.space?.tType?.friendlyName ?: "Unknown" - val fileType = getFileType(mMedia.mimeType) + val vault = spaceRepository.getSpaceById(mEvidence.vaultId) + val backendType = vault?.type?.friendlyName ?: "Unknown" + val fileType = getFileType(mEvidence.mimeType) AppLogger.breadcrumb("Upload Cancelled", "$fileType to $backendType") // Track upload cancellation - scope.launch { - analyticsManager.trackEvent( - AnalyticsEvent.UploadCancelled( - backendType = backendType, - fileType = fileType, - reason = "user_cancelled" - ) + analyticsManager.trackEvent( + AnalyticsEvent.UploadCancelled( + backendType = backendType, + fileType = fileType, + reason = "user_cancelled" ) - } + ) return } - mMedia.statusMessage = - exception.localizedMessage ?: exception.message ?: exception.toString() - mMedia.sStatus = Media.Status.Error - mMedia.save() + mEvidence = mEvidence.copy( + statusMessage = exception.localizedMessage ?: exception.message ?: exception.toString(), + status = EvidenceStatus.ERROR + ) + mediaRepository.updateEvidence(mEvidence) AppLogger.e(exception) - // Track failed upload analytics (GDPR-compliant - no PII) - val backendType = mMedia.space?.tType?.friendlyName ?: "Unknown" - val fileType = getFileType(mMedia.mimeType) - val fileSizeKB = mMedia.contentLength / 1024 - - // Categorize error - val errorCategory = when (exception) { - is IOException -> "network" - is FileNotFoundException -> "file_not_found" - is SecurityException -> "permission" - else -> "unknown" - } + // Track failed upload analytics (GDPR-compliant - no PII) + val vault = spaceRepository.getSpaceById(mEvidence.vaultId) + val backendType = vault?.type?.friendlyName ?: "Unknown" + val fileType = getFileType(mEvidence.mimeType) + val fileSizeKB = mEvidence.contentLength / 1024 + + // Categorize error + val errorCategory = when (exception) { + is IOException -> "network" + is FileNotFoundException -> "file_not_found" + is SecurityException -> "permission" + else -> "unknown" + } - scope.launch { analyticsManager.trackUploadFailed( backendType = backendType, fileType = fileType, errorCategory = errorCategory, fileSizeKB = fileSizeKB ) - } - // Track in session - sessionTracker.trackUploadFailed() + // Track in session + sessionTracker.trackUploadFailed() - // Track error for drop-off analysis - scope.launch { + // Track error for drop-off analysis analyticsManager.trackError( errorCategory = errorCategory, screenName = "Upload", backendType = backendType ) - } - BroadcastManager.postChange( - context = mContext, - collectionId = mMedia.collectionId, - mediaId = mMedia.id - ) + BroadcastManager.postChange( + context = mContext, + collectionId = mEvidence.submissionId, + mediaId = mEvidence.id + ) + UploadEventBus.emitChanged( + projectId = mEvidence.archiveId, + collectionId = mEvidence.submissionId, + mediaId = mEvidence.id, + progress = -1, + isUploaded = false + ) + } + + /** + * Non-suspend version of jobFailed for use in callbacks. + */ + fun jobFailedAsync(exception: Throwable) { + scope.launch { jobFailed(exception) } } private var lastReportedProgress: Int? = null fun jobProgress(uploadedBytes: Long) { - mMedia.progress = uploadedBytes - val progress = if (uploadedBytes > 0) (uploadedBytes.toFloat() / mMedia.contentLength * 100).toInt() else 0 + mEvidence = mEvidence.copy(progress = uploadedBytes) + val progress = if (uploadedBytes > 0) (uploadedBytes.toFloat() / mEvidence.contentLength * 100).toInt() else 0 if (progress > (lastReportedProgress ?: 0) + 1) { lastReportedProgress = progress - AppLogger.i("Media Item ${mMedia.id} progress: $progress/100") + AppLogger.i("Media Item ${mEvidence.id} progress: $progress/100") BroadcastManager.postProgress( context = mContext, - collectionId = mMedia.collectionId, - mediaId = mMedia.id, + collectionId = mEvidence.submissionId, + mediaId = mEvidence.id, progress = progress, ) + UploadEventBus.emitChanged( + projectId = mEvidence.archiveId, + collectionId = mEvidence.submissionId, + mediaId = mEvidence.id, + progress = progress, + isUploaded = false + ) } } @@ -252,46 +308,76 @@ abstract class Conduit( * * reads some values from mMedia and copies them to some other fields of mMedia */ - protected fun sanitize() { - val length = mMedia.file.length() - if (length > 0) mMedia.contentLength = length + protected suspend fun sanitize() { + val length = mEvidence.file.length() + var updatedEvidence = mEvidence + if (length > 0) updatedEvidence = updatedEvidence.copy(contentLength = length) - val tags = mMedia.tagSet + val tags = updatedEvidence.tags.toMutableList() - if (mMedia.flag) { - tags.add(getFlagText()) + if (updatedEvidence.isFlagged) { + val flagText = getFlagText() + if (!tags.contains(flagText)) tags.add(flagText) } else { tags.remove(getFlagText()) } - mMedia.tagSet = tags + updatedEvidence = updatedEvidence.copy(tags = tags) + + // Update to the latest vault license. + val vault = spaceRepository.getSpaceById(mEvidence.vaultId) + updatedEvidence = updatedEvidence.copy(licenseUrl = vault?.licenseUrl) - // Update to the latest project license. - mMedia.licenseUrl = mMedia.project?.licenseUrl + mEvidence = updatedEvidence } - protected fun getPath(): List? { - val projectName = mMedia.project?.description ?: return null - val collectionName = - mDateFormat.format(mMedia.collection?.uploadDate ?: mMedia.createDate ?: Date()) + protected suspend fun getPath(): List? { + val project = projectRepository.getProject(mEvidence.archiveId) + val projectName = project?.description ?: return null + + // Use the submission bound to this evidence to keep folder grouping stable. + val submission = collectionRepository.getCollection(mEvidence.submissionId) + val collectionDate = + submission?.uploadDate ?: mEvidence.createdAt ?: DateUtils.nowDateTime + + val javaDate = collectionDate.toJavaDate() + val collectionName = mDateFormat.format(javaDate) val path = mutableListOf(projectName, collectionName) - if (mMedia.flag) { + if (mEvidence.isFlagged) { path.add(getFlagText()) } return path } - protected suspend fun createFolders(base: HttpUrl?, path: List) { + protected suspend fun createFolders(base: HttpUrl?, path: List, isFirstSegmentRemote: Boolean = false) { + try { + createFoldersInternal(base, path, isFirstSegmentRemote) + } catch (e: Exception) { + if (isFirstSegmentRemote) { + AppLogger.w("Remote folder optimization failed, retrying with full check", e) + createFoldersInternal(base, path, false) + } else { + throw e + } + } + } + + private suspend fun createFoldersInternal(base: HttpUrl?, path: List, isFirstSegmentRemote: Boolean) { val tmp = mutableListOf() - for (segment in path) { + for ((index, segment) in path.withIndex()) { tmp.add(segment) if (mCancelled) throw Exception("Cancelled") + if (index == 0 && isFirstSegmentRemote) { + AppLogger.i("Skipping remote folder check for first segment: $segment") + continue + } + val url = construct(base, tmp) createFolder(url) @@ -320,21 +406,19 @@ abstract class Conduit( } } - protected fun construct(path: List, file: String? = null): String { - return construct(null, path, file) - } - /** - * Generate JSON encoded string of metadata corresponding Media currently - * stored in `this.mMedia`. + * Generate JSON encoded string of metadata corresponding to Evidence currently + * stored in `this.mEvidence`. */ - protected fun getMetadata(): String { - val gson = GsonBuilder() - .setPrettyPrinting() - .excludeFieldsWithoutExposeAnnotation() - .create() + protected suspend fun getMetadata(): String { + val json = Json { + prettyPrint = true + encodeDefaults = true + } - return gson.toJson(this.mMedia, Media::class.java) + val vault = spaceRepository.getSpaceById(mEvidence.vaultId) + val metadata = mEvidence.toMetadata(licenseUrl = vault?.licenseUrl) + return json.encodeToString(metadata) } /** @@ -360,33 +444,34 @@ abstract class Conduit( */ const val CHUNK_FILESIZE_THRESHOLD = 10 * 1024 * 1024 - fun get(media: Media, context: Context): Conduit? { - return when (media.project?.space?.tType) { - Space.Type.INTERNET_ARCHIVE -> IaConduit(media, context) - - Space.Type.WEBDAV -> WebDavConduit(media, context) + suspend fun get(evidence: Evidence, context: Context): Conduit? { + val spaceRepository: SpaceRepository = GlobalContext.get().get() + val vault = spaceRepository.getSpaceById(evidence.vaultId) ?: return null + return when (vault.type) { + net.opendasharchive.openarchive.core.domain.VaultType.INTERNET_ARCHIVE -> IaConduit(evidence, context) + net.opendasharchive.openarchive.core.domain.VaultType.PRIVATE_SERVER -> WebDavConduit(evidence, context) else -> null } } - fun getUploadFileName(media: Media, escapeTitle: Boolean = false): String { - var ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(media.mimeType) + fun getUploadFileName(evidence: Evidence, escapeTitle: Boolean = false): String { + var ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(evidence.mimeType) if (ext.isNullOrEmpty()) { ext = when { - media.mimeType.startsWith("image") -> "jpg" + evidence.mimeType.startsWith("image") -> "jpg" - media.mimeType.startsWith("video") -> "mp4" + evidence.mimeType.startsWith("video") -> "mp4" - media.mimeType.startsWith("audio") -> "m4a" + evidence.mimeType.startsWith("audio") -> "m4a" else -> "txt" } } - var title = media.title + var title = evidence.title - if (title.isBlank()) title = media.mediaHashString + if (title.isBlank()) title = evidence.mediaHashString if (escapeTitle) { title = UrlEscapers.urlPathSegmentEscaper().escape(title) ?: title @@ -399,4 +484,4 @@ abstract class Conduit( return title } } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt index 1dc4be861..8677d2647 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt @@ -1,148 +1,150 @@ package net.opendasharchive.openarchive.services import android.content.Context -import android.content.Intent import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine -import info.guardianproject.netcipher.client.StrongBuilder -import info.guardianproject.netcipher.client.StrongBuilderBase -import info.guardianproject.netcipher.proxy.OrbotHelper -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.services.webdav.BasicAuthInterceptor +import net.opendasharchive.openarchive.services.tor.TorConstants +import net.opendasharchive.openarchive.services.tor.TorServiceManager +import net.opendasharchive.openarchive.services.common.auth.BasicAuthInterceptor import net.opendasharchive.openarchive.util.Prefs import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Protocol -import okhttp3.Request -import okhttp3.internal.platform.Platform +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.net.Authenticator +import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy +import java.util.UUID import java.util.concurrent.TimeUnit -import kotlin.coroutines.suspendCoroutine - -class SaveClient(context: Context) : StrongBuilderBase(context) { - - class OrbotException(message: String): Exception(message) - - private var okBuilder: OkHttpClient.Builder - - init { - val cacheInterceptor = Interceptor { chain -> - val request = chain.request().newBuilder().addHeader("Connection", "close").build() - chain.proceed(request) - } - - okBuilder = OkHttpClient.Builder() - .addInterceptor(cacheInterceptor) - .connectTimeout(40L, TimeUnit.SECONDS) - .writeTimeout(40L, TimeUnit.SECONDS) - .readTimeout(40L, TimeUnit.SECONDS) - .retryOnConnectionFailure(false) - .protocols(arrayListOf(Protocol.HTTP_1_1)) - } +import java.util.concurrent.atomic.AtomicReference + +/** + * Exception thrown when Tor is enabled but not yet ready. + */ +class TorNotReadyException(message: String) : Exception(message) + +/** + * Factory for creating OkHttpClient instances with optional Tor proxy support. + * + * When Tor is enabled in preferences, the client will route all traffic through + * the embedded Tor SOCKS5 proxy. The SOCKS port is dynamically allocated for + * security reasons. + * + * SECURITY: Uses IsolateSOCKSAuth for circuit isolation - each client gets + * a unique session ID which results in a separate Tor circuit. + */ +object SaveClient : KoinComponent { + + private val torServiceManager: TorServiceManager by inject() + + /** Thread-safe holder for current SOCKS auth session */ + private val currentSessionId = AtomicReference(null) /** - * OkHttp3 [does not support SOCKS proxies.](https://github.com/square/okhttp/issues/2315) + * SOCKS5 Authenticator for circuit isolation. * - * @return false + * When Tor is configured with IsolateSOCKSAuth, each unique username/password + * combination gets a separate Tor circuit. This prevents correlation of + * different requests through the same circuit. */ - override fun supportsSocksProxy(): Boolean { - return false + private val socksAuthenticator = object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + return if (requestorType == RequestorType.PROXY) { + val sessionId = currentSessionId.get() ?: return null + // Use session ID as both username and password + // Tor only cares that different values = different circuits + PasswordAuthentication(sessionId, sessionId.toCharArray()) + } else { + null + } + } + } + + init { + // Set the default authenticator for SOCKS proxy authentication + Authenticator.setDefault(socksAuthenticator) } /** - * {@inheritDoc} + * Generates a unique session ID for circuit isolation. + * Each session ID will result in a separate Tor circuit. */ - override fun build(status: Intent): OkHttpClient { - if (!status.hasExtra(OrbotHelper.EXTRA_STATUS)) { - status.putExtra(OrbotHelper.EXTRA_STATUS, OrbotHelper.STATUS_OFF) - } - - return applyTo(okBuilder, status).build() + private fun generateSessionId(): String { + return UUID.randomUUID().toString() } /** - * Adds NetCipher configuration to an existing OkHttpClient.Builder, - * in case you have additional configuration that you wish to - * perform. + * Creates an OkHttpClient configured for the current settings. * - * @param builder a new or partially-configured OkHttpClient.Builder - * @return the same builder + * @param context Application context + * @param user Optional username for basic auth + * @param password Optional password for basic auth + * @param isolateCircuit If true, generates a new session ID for circuit isolation + * @return Configured OkHttpClient + * @throws TorNotReadyException if Tor is enabled but not yet connected */ - private fun applyTo(builder: OkHttpClient.Builder, status: Intent?): OkHttpClient.Builder { - val factory = buildSocketFactory() - - if (factory != null) { - val trustManager = Platform.get().trustManager(factory) - - if (trustManager != null) { - builder.sslSocketFactory(factory, trustManager) - } + suspend fun get( + context: Context, + user: String = "", + password: String = "", + isolateCircuit: Boolean = true + ): OkHttpClient { + val cacheInterceptor = Interceptor { chain -> + val request = chain.request().newBuilder() + .addHeader("Connection", "close") + .build() + chain.proceed(request) } - return builder - .proxy(buildProxy(status)) - } - - @Throws(Exception::class) - override fun get(status: Intent, connection: OkHttpClient, url: String): String? { - val request: Request = Request.Builder().url(TOR_CHECK_URL).build() + val builder = OkHttpClient.Builder() + .addInterceptor(cacheInterceptor) + .connectTimeout(60L, TimeUnit.SECONDS) + .writeTimeout(60L, TimeUnit.SECONDS) + .readTimeout(60L, TimeUnit.SECONDS) + .retryOnConnectionFailure(false) + .protocols(arrayListOf(Protocol.HTTP_1_1)) - return connection.newCall(request).execute().body?.string() - } + // Add basic auth interceptor if credentials provided + if (user.isNotEmpty() || password.isNotEmpty()) { + builder.addInterceptor(BasicAuthInterceptor(user, password)) + } - companion object { - suspend fun get(context: Context, user: String = "", password: String = ""): OkHttpClient { + // Apply SOCKS5 proxy when Tor is enabled + if (Prefs.useTor) { + if (!torServiceManager.isReady()) { + throw TorNotReadyException("Tor is not yet connected. Please wait for Tor to connect.") + } - val strongBuilder = SaveClient(context) + val port = torServiceManager.socksPort.value - if (user.isNotEmpty() || password.isNotEmpty()) { - strongBuilder.okBuilder.addInterceptor(BasicAuthInterceptor(user, password)) + // Generate new session ID for circuit isolation + if (isolateCircuit) { + currentSessionId.set(generateSessionId()) } - return suspendCoroutine { - val callback = object : StrongBuilder.Callback { - override fun onConnected(connection: OkHttpClient?) { - val result = if (connection != null) { - Result.success(connection) - } - else { - Result.failure(OrbotException(context.getString(R.string.tor_connection_exception))) - } - - it.resumeWith(result) - } - - override fun onConnectionException(e: java.lang.Exception?) { - it.resumeWith(Result.failure(e ?: OrbotException(context.getString(R.string.tor_connection_exception)))) - } - - override fun onTimeout() { - it.resumeWith(Result.failure(OrbotException(context.getString(R.string.tor_connection_timeout)))) - } - - override fun onInvalid() { - it.resumeWith(Result.failure(OrbotException(context.getString(R.string.tor_connection_invalid)))) - } - } - - if (Prefs.useTor) { - if (!OrbotHelper.requestStartTor(context)) { - callback.onInvalid() - } - else { - strongBuilder.build(callback) - } - } - else { - callback.onConnected(strongBuilder.build(Intent())) - } - } + builder.proxy( + Proxy( + Proxy.Type.SOCKS, + InetSocketAddress(TorConstants.SOCKS5_PROXY_ADDRESS, port) + ) + ) } - suspend fun getSardine(context: Context, space: Space): OkHttpSardine { - val sardine = OkHttpSardine(get(context)) - sardine.setCredentials(space.username, space.password) + return builder.build() + } - return sardine - } + /** + * Creates a Sardine WebDAV client configured for the current settings. + * + * @param context Application context + * @param space The space containing WebDAV credentials + * @return Configured OkHttpSardine instance + * @throws TorNotReadyException if Tor is enabled but not yet connected + */ + suspend fun getSardine(context: Context, user: String, pass: String): OkHttpSardine { + val sardine = OkHttpSardine(get(context)) + sardine.setCredentials(user, pass) + return sardine } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/BasicAuthInterceptor.kt b/app/src/main/java/net/opendasharchive/openarchive/services/common/auth/BasicAuthInterceptor.kt similarity index 91% rename from app/src/main/java/net/opendasharchive/openarchive/services/webdav/BasicAuthInterceptor.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/common/auth/BasicAuthInterceptor.kt index 8f956d751..1b5ac966d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/BasicAuthInterceptor.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/common/auth/BasicAuthInterceptor.kt @@ -1,4 +1,4 @@ -package net.opendasharchive.openarchive.services.webdav +package net.opendasharchive.openarchive.services.common.auth import okhttp3.Credentials.basic import okhttp3.Interceptor diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/CreativeCommonsLicenseContent.kt b/app/src/main/java/net/opendasharchive/openarchive/services/common/license/CreativeCommonsLicenseContent.kt similarity index 96% rename from app/src/main/java/net/opendasharchive/openarchive/services/webdav/CreativeCommonsLicenseContent.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/common/license/CreativeCommonsLicenseContent.kt index d57e8e9cb..cb56af961 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/CreativeCommonsLicenseContent.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/common/license/CreativeCommonsLicenseContent.kt @@ -1,4 +1,4 @@ -package net.opendasharchive.openarchive.services.webdav +package net.opendasharchive.openarchive.services.common.license import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -7,6 +7,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -55,7 +56,7 @@ fun CreativeCommonsLicenseContent( onCheckedChange = licenseCallbacks::onCcEnabledChange, enabled = enabled, colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.surface, + checkedThumbColor = Color.White, checkedTrackColor = MaterialTheme.colorScheme.tertiary ) ) @@ -100,7 +101,7 @@ fun CreativeCommonsLicenseContent( // Show license URL when CC is enabled if (licenseState.ccEnabled) { - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(16.dp)) // License URL - matches TextView lines 132-138 in content_cc.xml licenseState.licenseUrl?.let { url -> @@ -116,7 +117,7 @@ fun CreativeCommonsLicenseContent( } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Learn More Link - matches TextView lines 140-147 in content_cc.xml Text( diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/LicenseState.kt b/app/src/main/java/net/opendasharchive/openarchive/services/common/license/LicenseState.kt similarity index 90% rename from app/src/main/java/net/opendasharchive/openarchive/services/webdav/LicenseState.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/common/license/LicenseState.kt index 1e6d5b1cb..316e71d18 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/LicenseState.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/common/license/LicenseState.kt @@ -1,4 +1,4 @@ -package net.opendasharchive.openarchive.services.webdav +package net.opendasharchive.openarchive.services.common.license import androidx.compose.runtime.Immutable diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt b/app/src/main/java/net/opendasharchive/openarchive/services/common/network/RequestBodyUtil.kt similarity index 88% rename from app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/common/network/RequestBodyUtil.kt index ddab48a98..49654cc37 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/common/network/RequestBodyUtil.kt @@ -1,7 +1,8 @@ -package net.opendasharchive.openarchive.services.internetarchive +package net.opendasharchive.openarchive.services.common.network import android.content.ContentResolver import android.net.Uri +import net.opendasharchive.openarchive.core.logger.AppLogger import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody @@ -34,7 +35,7 @@ object RequestBodyUtil { return try { contentLength ?: inputStream.available().toLong() } catch (e: IOException) { - Timber.i("BodyRequestUtil couldn't get contentLength, returning 0 instead", e) + AppLogger.i("BodyRequestUtil couldn't get contentLength, returning 0 instead", e) 0 } } @@ -70,7 +71,7 @@ object RequestBodyUtil { ) else cr.openInputStream(uri) mListener = listener } catch (e: FileNotFoundException) { - Timber.e("BodyRequest init failed", e) + AppLogger.e("BodyRequest init failed", e) } } @@ -82,9 +83,10 @@ object RequestBodyUtil { @Throws(IOException::class) override fun writeTo(sink: BufferedSink) { init() + val stream = inputStream ?: throw IOException("Failed to open file: ${uri.path}") var source: Source? = null try { - source = inputStream!!.source() + source = stream.source() sink.writeAll(source, listener) } finally { source?.closeQuietly() @@ -117,7 +119,7 @@ object RequestBodyUtil { try { inputStream = FileInputStream(fileSource) } catch (e: FileNotFoundException) { - Timber.e("RequestBody init failed", e) + AppLogger.e("RequestBody init failed", e) } } @@ -129,7 +131,8 @@ object RequestBodyUtil { @Throws(IOException::class) override fun writeTo(sink: BufferedSink) { init() - val source = inputStream!!.source() + val stream = inputStream ?: throw IOException("File not found: ${fileSource.path}") + val source = stream.source() if (listener == null) { sink.writeAll(source) } else { diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestListener.kt b/app/src/main/java/net/opendasharchive/openarchive/services/common/network/RequestListener.kt similarity index 65% rename from app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestListener.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/common/network/RequestListener.kt index f3e9f9aae..92ca3d894 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestListener.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/common/network/RequestListener.kt @@ -1,4 +1,4 @@ -package net.opendasharchive.openarchive.services.internetarchive +package net.opendasharchive.openarchive.services.common.network interface RequestListener { fun transferred(bytes: Long) diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt deleted file mode 100644 index 7ac8a1228..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt +++ /dev/null @@ -1,258 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.content.Context -import android.net.Uri -import com.google.gson.GsonBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.services.Conduit -import net.opendasharchive.openarchive.services.SaveClient -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import java.io.File -import java.io.IOException - -class IaConduit(media: Media, context: Context) : Conduit(media, context) { - - - companion object { - const val ARCHIVE_BASE_URL = "https://archive.org/" - const val NAME = "Internet Archive" - - const val ARCHIVE_API_ENDPOINT = "https://s3.us.archive.org" - private const val ARCHIVE_DETAILS_ENDPOINT = "https://archive.org/details/" - - private fun getSlug(title: String): String { - return title.replace("[^A-Za-z\\d]".toRegex(), "-") - } - - val textMediaType = "texts".toMediaTypeOrNull() - - private val gson = GsonBuilder().excludeFieldsWithoutExposeAnnotation().create() - } - - override suspend fun upload(): Boolean { - sanitize() - - try { - val mimeType = mMedia.mimeType - - val client = SaveClient.get(mContext) - - val fileName = getUploadFileName(mMedia, true) - val metaJson = gson.toJson(mMedia) - // Commenting out proof generation - 17th April 2025 - // val proof = getProof() - - if (mMedia.serverUrl.isBlank()) { - // TODO this should make sure we aren't accidentally using one of archive.org's metadata fields by accident - val slug = getSlug(mMedia.title) - val newIdentifier = "$slug-${Util.RandomString(4).nextString()}" - // create an identifier for the upload - mMedia.serverUrl = newIdentifier - } - - // upload content synchronously for progress - client.uploadContent(fileName, mimeType) - - // upload metadata and proofs async, and report failures - client.uploadMetaData(metaJson, fileName) - - // Commenting out proof generation - 17th April 2025 - // Upload ProofMode metadata, if enabled and successfully created. - // for (file in proof) { - // client.uploadProofFiles(file) - // } - - jobSucceeded() - - return true - } catch (e: Throwable) { - jobFailed(e) - } - - return false - } - - override suspend fun createFolder(url: String) { - // Ignored. Not used here. - } - - private suspend fun OkHttpClient.uploadContent(fileName: String, mimeType: String) { - val mediaUri = mMedia.originalFilePath - - val url = "${ARCHIVE_API_ENDPOINT}/${mMedia.serverUrl}/$fileName" - - val requestBody = RequestBodyUtil.create( - mContext.contentResolver, - Uri.parse(mediaUri), - mMedia.contentLength, - mimeType.toMediaTypeOrNull(), - createListener( - cancellable = { !mCancelled }, - onProgress = { - jobProgress(it) - } - ) - ) - - val request = Request.Builder() - .url(url) - .put(requestBody) - .headers(mainHeader()) - .build() - - execute(request) - } - - @Throws(IOException::class) - private fun OkHttpClient.uploadMetaData(content: String, fileName: String) { - val requestBody = RequestBodyUtil.create( - textMediaType, - content.byteInputStream(), - content.length.toLong(), - createListener(cancellable = { !mCancelled }) - ) - - val url = "${ARCHIVE_API_ENDPOINT}/${mMedia.serverUrl}/$fileName.meta.json" - - val request = Request.Builder() - .url(url) - .put(requestBody) - .headers(metadataHeader()) - .build() - - enqueue(request) - } - - /// upload proof mode - @Throws(IOException::class) - private fun OkHttpClient.uploadProofFiles(uploadFile: File) { - val requestBody = RequestBodyUtil.create( - mContext.contentResolver, - Uri.fromFile(uploadFile), - uploadFile.length(), - textMediaType, createListener(cancellable = { !mCancelled }) - ) - - val url = "$ARCHIVE_API_ENDPOINT/${mMedia.serverUrl}/${uploadFile.name}" - - val request = Request.Builder() - .url(url) - .put(requestBody) - .headers(metadataHeader()) - .build() - - enqueue(request) - } - - private fun mainHeader(): Headers { - val builder = Headers.Builder() - .add("Accept", "*/*") - .add("x-archive-auto-make-bucket", "1") - .add("x-amz-auto-make-bucket", "1") - .add("x-archive-interactive-priority", "1") - .add("x-archive-meta-language", "eng") // FIXME set based on locale or selected. - .add("Authorization", "LOW " + mMedia.space?.username + ":" + mMedia.space?.password) - - val author = mMedia.author - if (author.isNotEmpty()) { - builder.add("x-archive-meta-author", author) - } - - if (mMedia.contentLength > 0) { - builder.add("x-archive-size-hint", mMedia.contentLength.toString()) - } - - val collection = when { - mMedia.mimeType.startsWith("video") -> "opensource_movies" - mMedia.mimeType.startsWith("audio") -> "opensource_audio" - else -> "opensource_media" - } - builder.add("x-archive-meta-collection", collection) - - if (mMedia.mimeType.isNotEmpty()) { - val mediaType = when { - mMedia.mimeType.startsWith("image") -> "image" - mMedia.mimeType.startsWith("video") -> "movies" - mMedia.mimeType.startsWith("audio") -> "audio" - else -> "data" - } - builder.add("x-archive-meta-mediatype", mediaType) - } - - if (mMedia.location.isNotEmpty()) { - builder.add("x-archive-meta-location", sanitizeHeaderValue(mMedia.location)) - } - - if (mMedia.tags.isNotEmpty()) { - val tags = mMedia.tagSet - tags.add(mContext.getString(R.string.default_tags)) - mMedia.tagSet = tags - - builder.add("x-archive-meta-subject", mMedia.tags) - } - - if (mMedia.description.isNotEmpty()) { - builder.add("x-archive-meta-description", sanitizeHeaderValue(mMedia.description)) - } - - if (mMedia.title.isNotEmpty()) { - builder.add("x-archive-meta-title", mMedia.title) - } - - var licenseUrl = mMedia.licenseUrl - - if (licenseUrl.isNullOrEmpty()) { - licenseUrl = "https://creativecommons.org/licenses/by/4.0/" - } - - builder.add("x-archive-meta-licenseurl", licenseUrl) - - return builder.build() - } - - /// headers for meta-data and proof mode - private fun metadataHeader(): Headers { - return Headers.Builder() - .add("x-amz-auto-make-bucket", "1") - .add("x-archive-meta-language", "eng") // TODO: FIXME set based on locale or selected - .add("Authorization", "LOW " + mMedia.space?.username + ":" + mMedia.space?.password) - .add("x-archive-meta-mediatype", "texts") - .add("x-archive-meta-collection", "opensource") - .build() - } - - @Throws(Exception::class) - private suspend fun OkHttpClient.execute(request: Request) = withContext(Dispatchers.IO) { - val result = newCall(request) - .execute() - - if (result.isSuccessful.not()) { - throw RuntimeException("${result.code}: ${result.message}") - } - } - - @Throws(Exception::class) - private fun OkHttpClient.enqueue(request: Request) { - newCall(request) - .enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - jobFailed(e) - } - - override fun onResponse(call: Call, response: Response) { - if (!response.isSuccessful) { - jobFailed(Exception("${response.code}: ${response.message}")) - } - } - - }) - } - - private fun sanitizeHeaderValue(value: String): String { - return value.replace("[^\\x20-\\x7E]".toRegex(), "") // Removes non-ASCII characters - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/Util.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/Util.kt deleted file mode 100644 index 1146b9ca6..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/Util.kt +++ /dev/null @@ -1,69 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.os.Build -import android.view.View -import android.view.inputmethod.InputMethodManager -import android.webkit.CookieManager -import android.webkit.WebView -import androidx.annotation.ColorRes -import androidx.core.content.ContextCompat -import java.security.SecureRandom -import java.util.* - -object Util { - - fun clearWebviewAndCookies(webview: WebView?) { - val cookieManager = CookieManager.getInstance() - cookieManager.removeAllCookies(null) - - webview?.clearHistory() - webview?.clearCache(true) - webview?.clearFormData() - webview?.loadUrl("about:blank") - webview?.destroy() - } - - // TODO audit code for security since we use the to generate random strings for url slugs - class RandomString(length: Int) { - private val random: Random = SecureRandom() - private val buf: CharArray - fun nextString(): String { - for (idx in buf.indices) buf[idx] = symbols[random.nextInt(symbols.length)] - return String(buf) - } - - companion object { - /* Assign a string that contains the set of characters you allow. */ - private const val symbols = "abcdefghijklmnopqrstuvwxyz0123456789" - } - - init { - require(length >= 1) { "length < 1: $length" } - buf = CharArray(length) - } - } - - @SuppressLint("UseCompatLoadingForColorStateLists") - @JvmStatic - fun setBackgroundTint(view: View, @ColorRes color: Int) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - view.backgroundTintList = view.resources.getColorStateList(color, view.context.theme) - } - else { - view.backgroundTintList = ContextCompat.getColorStateList(view.context, color) - } - } - - @JvmStatic - fun hideSoftKeyboard(activity: Activity) { - val windowToken = activity.currentFocus?.windowToken - if (windowToken != null) { - val imm: InputMethodManager = - activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(windowToken, 0) - } - } -} \ No newline at end of file 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 new file mode 100644 index 000000000..78a78abf7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/IaConduit.kt @@ -0,0 +1,282 @@ +package net.opendasharchive.openarchive.services.internetarchive.data + +import android.content.Context +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.VaultAuth +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.services.Conduit +import net.opendasharchive.openarchive.services.SaveClient +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import java.io.File +import java.io.IOException +import androidx.core.net.toFile +import androidx.core.net.toUri +import net.opendasharchive.openarchive.services.common.network.RequestBodyUtil +import net.opendasharchive.openarchive.services.common.network.RequestListener +import net.opendasharchive.openarchive.services.common.network.createListener +import net.opendasharchive.openarchive.util.Utility + +class IaConduit(evidence: Evidence, context: Context) : Conduit(evidence, context) { + + + companion object { + const val ARCHIVE_BASE_URL = "https://archive.org/" + const val NAME = "Internet Archive" + + const val ARCHIVE_API_ENDPOINT = "https://s3.us.archive.org" + private const val ARCHIVE_DETAILS_ENDPOINT = "https://archive.org/details/" + + private fun getSlug(title: String): String { + return title.replace("[^A-Za-z\\d]".toRegex(), "-") + } + + val textMediaType = "texts".toMediaTypeOrNull() + } + + override suspend fun upload(): Boolean { + sanitize() + + try { + val vault = spaceRepository.getSpaceById(mEvidence.vaultId) ?: return false + val auth = spaceRepository.getVaultAuth(mEvidence.vaultId) ?: return false + val mimeType = mEvidence.mimeType + + val client = SaveClient.get(mContext) + + 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 + val slug = getSlug(mEvidence.title) + val newIdentifier = "$slug-${Utility.RandomString(4).nextString()}" + // create an identifier for the upload + mEvidence = mEvidence.copy(serverUrl = newIdentifier) + } + + // upload content synchronously for progress + client.uploadContent(fileName, mimeType, vault, auth) + + // upload metadata — non-fatal if it fails + try { + client.uploadMetaData(metaJson, fileName, auth) + } catch (e: Throwable) { + 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 + } catch (e: Throwable) { + jobFailed(e) + } + + return false + } + + override suspend fun createFolder(url: String) { + // Ignored. Not used here. + } + + private suspend fun OkHttpClient.uploadContent( + fileName: String, + mimeType: String, + vault: Vault, + auth: VaultAuth + ) { + val url = "${ARCHIVE_API_ENDPOINT}/${mEvidence.serverUrl}/$fileName" + val listener = createListener(cancellable = { !mCancelled }, onProgress = { jobProgress(it) }) + + val requestBody = run { + val uri = mEvidence.originalFilePath.toUri() + val scheme = uri.scheme + if (scheme == null || scheme == "file") { + // plain path or file:// URI — open directly as File to avoid ContentResolver issues + val file = if (scheme == "file") uri.toFile() else File(mEvidence.originalFilePath) + RequestBodyUtil.create(file, mimeType.toMediaTypeOrNull(), listener) + } else { + RequestBodyUtil.create( + mContext.contentResolver, + uri, + mEvidence.contentLength, + mimeType.toMediaTypeOrNull(), + listener + ) + } + } + + val request = Request.Builder() + .url(url) + .put(requestBody) + .headers(mainHeader(vault, auth)) + .build() + + execute(request) + } + + @Throws(IOException::class) + private suspend fun OkHttpClient.uploadMetaData(content: String, fileName: String, auth: VaultAuth) { + val requestBody = RequestBodyUtil.create( + textMediaType, + content.byteInputStream(), + content.length.toLong(), + createListener(cancellable = { !mCancelled }) + ) + + val url = "${ARCHIVE_API_ENDPOINT}/${mEvidence.serverUrl}/$fileName.meta.json" + + val request = Request.Builder() + .url(url) + .put(requestBody) + .headers(metadataHeader(auth)) + .build() + + execute(request) + } + + /// upload proof mode + @Throws(IOException::class) + private suspend fun OkHttpClient.uploadProofFiles(uploadFile: File, auth: VaultAuth) { + val requestBody = RequestBodyUtil.create( + mContext.contentResolver, + Uri.fromFile(uploadFile), + uploadFile.length(), + textMediaType, createListener(cancellable = { !mCancelled }) + ) + + val url = "$ARCHIVE_API_ENDPOINT/${mEvidence.serverUrl}/${uploadFile.name}" + + val request = Request.Builder() + .url(url) + .put(requestBody) + .headers(metadataHeader(auth)) + .build() + + execute(request) + } + + private fun mainHeader(vault: Vault, auth: VaultAuth): Headers { + val builder = Headers.Builder() + .add("Accept", "*/*") + .add("x-archive-auto-make-bucket", "1") + .add("x-amz-auto-make-bucket", "1") + .add("x-archive-interactive-priority", "1") + .add("x-archive-meta-language", "eng") // FIXME set based on locale or selected. + .add("Authorization", "LOW " + auth.username + ":" + auth.secret) + + val author = mEvidence.author + if (author.isNotEmpty()) { + builder.add("x-archive-meta-author", author) + } + + if (mEvidence.contentLength > 0) { + builder.add("x-archive-size-hint", mEvidence.contentLength.toString()) + } + + val collection = when { + mEvidence.mimeType.startsWith("video") -> "opensource_movies" + mEvidence.mimeType.startsWith("audio") -> "opensource_audio" + else -> "opensource_media" + } + builder.add("x-archive-meta-collection", collection) + + if (mEvidence.mimeType.isNotEmpty()) { + val mediaType = when { + mEvidence.mimeType.startsWith("image") -> "image" + mEvidence.mimeType.startsWith("video") -> "movies" + mEvidence.mimeType.startsWith("audio") -> "audio" + else -> "data" + } + builder.add("x-archive-meta-mediatype", mediaType) + } + + if (mEvidence.location.isNotEmpty()) { + builder.add("x-archive-meta-location", sanitizeHeaderValue(mEvidence.location)) + } + + if (mEvidence.tags.isNotEmpty()) { + val tags = mEvidence.tags.toMutableList() + tags.add(mContext.getString(R.string.default_tags)) + mEvidence = mEvidence.copy(tags = tags) + + builder.add("x-archive-meta-subject", tags.joinToString(",")) + } + + if (mEvidence.description.isNotEmpty()) { + builder.add("x-archive-meta-description", sanitizeHeaderValue(mEvidence.description)) + } + + if (mEvidence.title.isNotEmpty()) { + builder.add("x-archive-meta-title", mEvidence.title) + } + + var licenseUrl = vault.licenseUrl + + if (licenseUrl.isNullOrEmpty()) { + licenseUrl = "https://creativecommons.org/licenses/by/4.0/" + } + + builder.add("x-archive-meta-licenseurl", licenseUrl) + + return builder.build() + } + + /// headers for meta-data and proof mode + private fun metadataHeader(auth: VaultAuth): Headers { + return Headers.Builder() + .add("x-amz-auto-make-bucket", "1") + .add("x-archive-meta-language", "eng") // TODO: FIXME set based on locale or selected + .add("Authorization", "LOW " + auth.username + ":" + auth.secret) + .add("x-archive-meta-mediatype", "texts") + .add("x-archive-meta-collection", "opensource") + .build() + } + + @Throws(Exception::class) + private suspend fun OkHttpClient.execute(request: Request) = withContext(Dispatchers.IO) { + val result = newCall(request) + .execute() + + if (result.isSuccessful.not()) { + throw RuntimeException("${result.code}: ${result.message}") + } + } + + @Throws(Exception::class) + private fun OkHttpClient.enqueue(request: Request) { + newCall(request) + .enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + jobFailedAsync(e) + } + + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + jobFailedAsync(Exception("${response.code}: ${response.message}")) + } + } + + }) + } + + private fun sanitizeHeaderValue(value: String): String { + return value.replace("[^\\x20-\\x7E]".toRegex(), "") // Removes non-ASCII characters + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/InternetArchiveAuthenticator.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/InternetArchiveAuthenticator.kt new file mode 100644 index 000000000..02f59cf19 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/InternetArchiveAuthenticator.kt @@ -0,0 +1,121 @@ +package net.opendasharchive.openarchive.services.internetarchive.data + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import net.opendasharchive.openarchive.core.domain.Credentials +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultAuthenticator +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult +import net.opendasharchive.openarchive.services.SaveClient +import okhttp3.FormBody +import okhttp3.Request + +private const val LOGIN_URI = "https://archive.org/services/xauthn?op=login" +private const val ARCHIVE_API_ENDPOINT = "https://archive.org" + +class InternetArchiveAuthenticator( + private val context: Context, + private val json: Json, +) : VaultAuthenticator { + + override suspend fun authenticate(credentials: Credentials): Result { + if (credentials !is Credentials.InternetArchive) { + return Result.failure(IllegalArgumentException("Invalid credentials type")) + } + + val authDataResult = withContext(Dispatchers.IO) { + SaveClient.get(context).enqueueResult( + Request.Builder() + .url(LOGIN_URI) + .post( + FormBody.Builder() + .add("email", credentials.email) + .add("password", credentials.pass).build() + ) + .build() + ) { response -> + val body = response.body?.string() ?: return@enqueueResult Result.failure(Exception("Empty response body")) + val data = json.decodeFromString(body) + + if (!data.success) { + return@enqueueResult Result.failure(IllegalArgumentException(data.values.reason ?: "Unknown error")) + } + + val auth = data.values.s3 ?: return@enqueueResult Result.failure(Exception("S3 keys missing in response")) + + Result.success( + LoginIntermediateData( + access = auth.access, + secret = auth.secret, + screenName = data.values.screenname ?: "", + email = data.values.email ?: "" + ) + ) + } + } + + return authDataResult.fold( + onSuccess = { intermediate -> + // Test connection (now outside the lambda, so suspension works) + val testResult = testConnectionInternal(intermediate.access, intermediate.secret) + if (testResult.isFailure) { + return Result.failure(testResult.exceptionOrNull() ?: Exception("Connection test failed")) + } + + val metaData = InternetArchiveMetadata( + screenName = intermediate.screenName, + email = intermediate.email + ) + + Result.success( + Vault( + type = VaultType.INTERNET_ARCHIVE, + name = VaultType.INTERNET_ARCHIVE.friendlyName, + username = intermediate.access, + password = intermediate.secret, + displayName = intermediate.screenName, + metaData = json.encodeToString(InternetArchiveMetadata.serializer(), metaData), + host = ARCHIVE_API_ENDPOINT + ) + ) + }, + onFailure = { Result.failure(it) } + ) + } + + private data class LoginIntermediateData( + val access: String, + val secret: String, + val screenName: String, + val email: String + ) + + override suspend fun testConnection(vault: Vault): Result { + return testConnectionInternal(vault.username, vault.password) + } + + private suspend fun testConnectionInternal(access: String, secret: String): Result = withContext(Dispatchers.IO) { + SaveClient.get(context).enqueueResult( + Request.Builder() + .url(ARCHIVE_API_ENDPOINT) + .method("GET", null) + .addHeader("Authorization", "LOW $access:$secret") + .build() + ) { response -> + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(UnauthenticatedException()) + } + } + } +} + +@kotlinx.serialization.Serializable +data class InternetArchiveMetadata( + val screenName: String, + val email: String +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/InternetArchiveModels.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/InternetArchiveModels.kt new file mode 100644 index 000000000..b9db4321b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/InternetArchiveModels.kt @@ -0,0 +1,34 @@ +package net.opendasharchive.openarchive.services.internetarchive.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class InternetArchiveLoginRequest( + val email: String, + val password: String +) + +@Serializable +data class InternetArchiveLoginResponse( + val success: Boolean, + val values: Values, + val version: Int +) { + @Serializable + data class Values( + val s3: S3? = null, + val screenname: String? = null, + val email: String? = null, + @SerialName("itemname") val itemName: String? = null, + val reason: String? = null + ) + + @Serializable + data class S3( + val access: String, + val secret: String + ) +} + +class UnauthenticatedException : Exception("Unauthenticated") diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/InternetArchiveRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/InternetArchiveRepository.kt new file mode 100644 index 000000000..bcaf78dbe --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/InternetArchiveRepository.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.services.internetarchive.data + +import net.opendasharchive.openarchive.features.folders.Folder + +class InternetArchiveRepository { + // Currently IA doesn't require folder listing for target selection, + // but the structure is standardized for future parity. + suspend fun getFolders(): List = emptyList() +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt similarity index 62% rename from app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt index fb3b667ea..bbde52985 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt @@ -1,10 +1,6 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation.details +package net.opendasharchive.openarchive.services.internetarchive.presentation.details import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,84 +19,65 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.fragment.app.FragmentActivity -import androidx.navigation.findNavController import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.ToolbarConfigurable +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark import net.opendasharchive.openarchive.features.core.UiImage import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.features.internetarchive.presentation.login.CustomTextField -import net.opendasharchive.openarchive.services.webdav.CreativeCommonsLicenseContent -import net.opendasharchive.openarchive.services.webdav.LicenseCallbacks -import net.opendasharchive.openarchive.services.webdav.LicenseState -import org.koin.androidx.compose.koinViewModel - - -class InternetArchiveDetailFragment : BaseFragment(), ToolbarConfigurable { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - return ComposeView(requireContext()).apply { - setContent { - SaveAppTheme { - InternetArchiveDetailsScreen( - onNavigateBack = { - findNavController().popBackStack() - } - ) - } - } - } - } - - override fun getToolbarTitle() = getString(R.string.internet_archive) - override fun shouldShowBackButton() = true -} +import net.opendasharchive.openarchive.services.common.license.CreativeCommonsLicenseContent +import net.opendasharchive.openarchive.services.common.license.LicenseCallbacks +import net.opendasharchive.openarchive.services.common.license.LicenseState +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomTextField @Composable -private fun InternetArchiveDetailsScreen( - viewModel: InternetArchiveDetailsViewModel = koinViewModel(), - onNavigateBack: () -> Unit, +fun InternetArchiveDetailsScreen( + viewModel: InternetArchiveDetailsViewModel, + dialogManager: DialogStateManager, ) { val state by viewModel.uiState.collectAsState() LaunchedEffect(Unit) { - viewModel.events.collect { event -> + viewModel.uiEvent.collect { event -> when (event) { - is InternetArchiveDetailsEvent.NavigateBack -> onNavigateBack() + is InternetArchiveDetailsEvent.ShowRemoveSpaceDialog -> { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + title = UiText.Resource(R.string.remove_from_app) + message = + UiText.Resource(R.string.are_you_sure_you_want_to_remove_this_server_from_the_app) + icon = UiImage.DrawableResource(R.drawable.ic_trash) + destructiveButton { + text = UiText.Resource(R.string.lbl_remove) + action = { + viewModel.onAction(InternetArchiveDetailsAction.RemoveSpace) + } + } + + neutralButton { + text = UiText.Resource(R.string.action_cancel) + } + } + } } } } - val context = LocalContext.current - val activity = context as FragmentActivity - val dialogManager = (activity as BaseActivity).dialogManager - InternetArchiveDetailsContent(state, viewModel::onAction, dialogManager) + InternetArchiveDetailsContent( + state = state, + onAction = viewModel::onAction + ) } @Composable private fun InternetArchiveDetailsContent( state: InternetArchiveDetailsState, onAction: (InternetArchiveDetailsAction) -> Unit, - dialogManager: DialogStateManager? = null ) { val scrollState = rememberScrollState() @@ -125,21 +102,21 @@ private fun InternetArchiveDetailsContent( ) CustomTextField( - label = stringResource(R.string.label_username), + placeholder = stringResource(R.string.label_username), value = state.userName, onValueChange = {}, enabled = false, ) CustomTextField( - label = stringResource(R.string.label_screen_name), + placeholder = stringResource(R.string.label_screen_name), value = state.screenName, onValueChange = {}, enabled = false, ) CustomTextField( - label = stringResource(R.string.label_email), + placeholder = stringResource(R.string.label_email), value = state.email, onValueChange = {}, enabled = false, @@ -193,25 +170,7 @@ private fun InternetArchiveDetailsContent( ) { TextButton( onClick = { - dialogManager?.showDialog(dialogManager.requireResourceProvider()) { - title = UiText.StringResource(R.string.remove_from_app) - message = - UiText.StringResource(R.string.are_you_sure_you_want_to_remove_this_server_from_the_app) - icon = UiImage.DrawableResource(R.drawable.ic_trash) - destructiveButton { - text = UiText.StringResource(R.string.lbl_remove) - action = { - onAction(InternetArchiveDetailsAction.Remove) - } - } - - neutralButton { - text = UiText.StringResource(R.string.action_cancel) - action = { - //dismiss - } - } - } + onAction(InternetArchiveDetailsAction.ShowRemoveSpaceDialog) }, colors = ButtonDefaults.textButtonColors( contentColor = colorResource(R.color.red_bg) @@ -228,19 +187,18 @@ private fun InternetArchiveDetailsContent( } @Composable -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@PreviewLightDark private fun InternetArchiveScreenPreview() { DefaultScaffoldPreview { InternetArchiveDetailsContent( state = InternetArchiveDetailsState( + spaceId = 1L, email = "abc@example.com", userName = "@abc_name", screenName = "ABC Name", license = "https://creativecommons.org/licenses/by-nc-sa/4.0/" ), onAction = {}, - dialogManager = null ) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/details/InternetArchiveDetailsState.kt similarity index 80% rename from app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/details/InternetArchiveDetailsState.kt index 72682a290..daaae3cec 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/details/InternetArchiveDetailsState.kt @@ -1,14 +1,18 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation.details +package net.opendasharchive.openarchive.services.internetarchive.presentation.details import androidx.compose.runtime.Immutable @Immutable data class InternetArchiveDetailsState( + val spaceId: Long, + val userName: String = "", val screenName: String = "", val email: String = "", val license: String? = null, + val isLoading: Boolean = false, + // Creative Commons License state val ccEnabled: Boolean = false, val allowRemix: Boolean = false, @@ -19,8 +23,8 @@ data class InternetArchiveDetailsState( ) sealed interface InternetArchiveDetailsAction { - data object Remove : InternetArchiveDetailsAction - data object Cancel : InternetArchiveDetailsAction + data object RemoveSpace : InternetArchiveDetailsAction + data class UpdateLicense(val license: String?) : InternetArchiveDetailsAction // Creative Commons License actions data class UpdateCcEnabled(val enabled: Boolean) : InternetArchiveDetailsAction @@ -28,8 +32,10 @@ sealed interface InternetArchiveDetailsAction { data class UpdateRequireShareAlike(val required: Boolean) : InternetArchiveDetailsAction data class UpdateAllowCommercial(val allowed: Boolean) : InternetArchiveDetailsAction data class UpdateCc0Enabled(val enabled: Boolean) : InternetArchiveDetailsAction + + data object ShowRemoveSpaceDialog : InternetArchiveDetailsAction } sealed interface InternetArchiveDetailsEvent { - data object NavigateBack : InternetArchiveDetailsEvent + data object ShowRemoveSpaceDialog : InternetArchiveDetailsEvent } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt similarity index 70% rename from app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt index de56c7a8a..f56ef5f51 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt @@ -1,9 +1,7 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation.details +package net.opendasharchive.openarchive.services.internetarchive.presentation.details -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.gson.Gson import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -11,26 +9,33 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import kotlinx.serialization.json.Json +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.services.internetarchive.data.InternetArchiveMetadata +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager import org.koin.core.component.KoinComponent class InternetArchiveDetailsViewModel( - private val gson: Gson, - savedStateHandle: SavedStateHandle + private val route: AppRoute.IADetailRoute, + private val navigator: Navigator, + private val json: Json, + private val spaceRepository: SpaceRepository, ) : ViewModel(), KoinComponent { - private val args = InternetArchiveDetailFragmentArgs.fromSavedStateHandle(savedStateHandle) + private lateinit var vault: Vault - private val space: Space = Space.get(args.spaceId)!! - - - private val _uiState = MutableStateFlow(InternetArchiveDetailsState()) + private val _uiState = MutableStateFlow( + InternetArchiveDetailsState( + spaceId = route.spaceId + ) + ) val uiState: StateFlow = _uiState.asStateFlow() - private val _events = Channel() - val events = _events.receiveAsFlow() + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() init { loadSpaceData() @@ -38,16 +43,10 @@ class InternetArchiveDetailsViewModel( fun onAction(action: InternetArchiveDetailsAction) { when (action) { - is InternetArchiveDetailsAction.Remove -> { + is InternetArchiveDetailsAction.RemoveSpace -> { removeSpace() } - is InternetArchiveDetailsAction.Cancel -> { - viewModelScope.launch { - _events.send(InternetArchiveDetailsEvent.NavigateBack) - } - } - is InternetArchiveDetailsAction.UpdateLicense -> { _uiState.update { it.copy(license = action.license) } updateLicense(action.license) @@ -127,70 +126,83 @@ class InternetArchiveDetailsViewModel( } generateAndUpdateLicense() } + + InternetArchiveDetailsAction.ShowRemoveSpaceDialog -> viewModelScope.launch { + _uiEvent.send(InternetArchiveDetailsEvent.ShowRemoveSpaceDialog) + } } } - private fun loadSpaceData() { + private fun loadSpaceData() = viewModelScope.launch { + + vault = spaceRepository.getSpaceById(route.spaceId) ?: run { + navigator.navigateBack() + return@launch + } + try { - val metaData = if (space.metaData.isNotEmpty()) { - gson.fromJson(space.metaData, InternetArchive.MetaData::class.java) + val metaData = if (vault.metaData.isNotEmpty()) { + json.decodeFromString(vault.metaData) } else { // Fallback to space properties if no metaData - InternetArchive.MetaData( - userName = space.username, - screenName = space.displayname.ifEmpty { space.username }, - email = space.username + InternetArchiveMetadata( + screenName = vault.displayName.ifEmpty { vault.username }, + email = vault.username ) } + _uiState.update { currentState -> val newState = currentState.copy( - userName = metaData.userName, + userName = metaData.email, // In IA, userName is often used as email/identifier email = metaData.email, screenName = metaData.screenName, - license = space.license + license = vault.licenseUrl ) - initializeLicenseState(newState, space.license) + + initializeLicenseState(newState, vault.licenseUrl) } + } catch (e: Exception) { // If JSON parsing fails, use space properties as fallback - val fallbackMetaData = InternetArchive.MetaData( - userName = space.username, - screenName = space.displayname.ifEmpty { space.username }, - email = space.username + val fallbackMetaData = InternetArchiveMetadata( + screenName = vault.displayName.ifEmpty { vault.username }, + email = vault.username ) _uiState.update { currentState -> val newState = currentState.copy( - userName = fallbackMetaData.userName, + userName = fallbackMetaData.email, email = fallbackMetaData.email, screenName = fallbackMetaData.screenName, - license = space.license + license = vault.licenseUrl ) - initializeLicenseState(newState, space.license) + + initializeLicenseState(newState, vault.licenseUrl) } } } private fun removeSpace() { viewModelScope.launch { - space.delete() - _events.send(InternetArchiveDetailsEvent.NavigateBack) + val isSuccess = spaceRepository.deleteSpace(route.spaceId) + if (!isSuccess) { + return@launch + } + navigator.navigateBack() } } - private fun updateLicense(license: String?) { - space.license = license - space.save() - } - - private fun getInternetArchiveSpace(): Space? { - val iaSpaces = Space.get(Space.Type.INTERNET_ARCHIVE) - return iaSpaces.firstOrNull() + private fun updateLicense(license: String?) = viewModelScope.launch { + vault = vault.copy(licenseUrl = license) + spaceRepository.updateSpace(route.spaceId, vault) } - private fun initializeLicenseState(currentState: InternetArchiveDetailsState, currentLicense: String?): InternetArchiveDetailsState { + private fun initializeLicenseState( + currentState: InternetArchiveDetailsState, + currentLicense: String? + ): InternetArchiveDetailsState { val isCc0 = currentLicense?.contains("publicdomain/zero", true) ?: false val isCC = currentLicense?.contains("creativecommons.org/licenses", true) ?: false - + return if (isCc0) { // CC0 license detected currentState.copy( @@ -201,14 +213,17 @@ class InternetArchiveDetailsViewModel( requireShareAlike = false, licenseUrl = currentLicense ) - } else if (isCC && currentLicense != null) { + } else if (isCC) { // Regular CC license detected currentState.copy( ccEnabled = true, cc0Enabled = false, allowRemix = !(currentLicense.contains("-nd", true)), allowCommercial = !(currentLicense.contains("-nc", true)), - requireShareAlike = !(currentLicense.contains("-nd", true)) && currentLicense.contains("-sa", true), + requireShareAlike = !(currentLicense.contains( + "-nd", + true + )) && currentLicense.contains("-sa", true), licenseUrl = currentLicense ) } else { @@ -233,7 +248,7 @@ class InternetArchiveDetailsViewModel( allowCommercial = currentState.allowCommercial, cc0Enabled = currentState.cc0Enabled ) - + _uiState.update { it.copy(licenseUrl = newLicense) } updateLicense(newLicense) } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/ButtonBar.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/ButtonBar.kt new file mode 100644 index 000000000..827e8682c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/ButtonBar.kt @@ -0,0 +1,90 @@ +package net.opendasharchive.openarchive.services.internetarchive.presentation.login + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.asString + +@Composable +fun ButtonBar( + modifier: Modifier = Modifier, + backButtonText: UiText = UiText.Resource(R.string.back), + nextButtonText: UiText = UiText.Resource(R.string.next), + isBackEnabled: Boolean = false, + isNextEnabled: Boolean = false, + isLoading: Boolean = false, + onBack: () -> Unit, + onNext: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + modifier = Modifier + .padding(8.dp) + .heightIn(ThemeDimensions.touchable) + .weight(1f), + colors = ButtonDefaults.textButtonColors( + contentColor = colorResource(R.color.colorOnBackground) + ), + enabled = isBackEnabled, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = onBack + ) { + Text(backButtonText.asString()) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + modifier = Modifier + .padding(8.dp) + .heightIn(ThemeDimensions.touchable) + .weight(1f), + enabled = isNextEnabled, + shape = androidx.compose.foundation.shape.RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + disabledContainerColor = colorResource(R.color.grey_50), + disabledContentColor = colorResource(R.color.extra_light_grey)//MaterialTheme.colorScheme.onBackground + ), + onClick = onNext, + ) { + if (isLoading) { + CircularProgressIndicator(color = ThemeColors.material.primary) + } else { + Text( + nextButtonText.asString(), + style = MaterialTheme.typography.labelLarge + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/CustomSecureField.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/CustomSecureField.kt new file mode 100644 index 000000000..f35a98fa0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/CustomSecureField.kt @@ -0,0 +1,124 @@ +package net.opendasharchive.openarchive.services.internetarchive.presentation.login + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.PlatformImeOptions +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.sp +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions + +@Composable +fun CustomSecureField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean = true, + placeholder: String, + isError: Boolean = false, + isLoading: Boolean = false, + showToggle: Boolean = true, + keyboardType: KeyboardType, + imeAction: ImeAction, + onImeAction: (() -> Unit)? = null, +) { + + var showPassword by rememberSaveable { mutableStateOf(false) } + + OutlinedTextField( + modifier = modifier.fillMaxWidth(), + value = value, + enabled = !isLoading && enabled, + onValueChange = onValueChange, + placeholder = { + Text( + text = placeholder, + style = MaterialTheme.typography.labelMedium.copy( + color = colorResource(R.color.colorOnSurfaceVariant) + ) + ) + }, + singleLine = true, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = keyboardType, + imeAction = imeAction, + platformImeOptions = PlatformImeOptions(), + showKeyboardOnFocus = true, + hintLocales = null + ), + keyboardActions = KeyboardActions( + onDone = { + onImeAction?.invoke() + }, + onNext = { + onImeAction?.invoke() + }, + onGo = { + onImeAction?.invoke() + }, + onSearch = { + onImeAction?.invoke() + }, + onSend = { + onImeAction?.invoke() + } + ), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + isError = isError, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.background, + unfocusedContainerColor = MaterialTheme.colorScheme.background, + focusedBorderColor = MaterialTheme.colorScheme.tertiary, + cursorColor = MaterialTheme.colorScheme.tertiary + //focusedIndicatorColor = Color.Transparent, + //unfocusedIndicatorColor = Color.Transparent, + ), + trailingIcon = if (showToggle) ({ + IconButton( + enabled = !isLoading && enabled, + modifier = Modifier.sizeIn(ThemeDimensions.touchable), + onClick = { showPassword = !showPassword }) { + + val (iconRes, cd) = + if (showPassword) { + R.drawable.ic_visibility_off to + "Hide password" + } else { + R.drawable.ic_visibility to + "Show password" + } + + Icon( + painter = painterResource(iconRes), + contentDescription = cd + ) + } + }) else null, + ) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/CustomTextField.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/CustomTextField.kt new file mode 100644 index 000000000..fc1229b09 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/CustomTextField.kt @@ -0,0 +1,109 @@ +package net.opendasharchive.openarchive.services.internetarchive.presentation.login + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PlatformImeOptions +import androidx.compose.ui.unit.sp +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions + +@Composable +fun CustomTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean = true, + placeholder: String? = null, + isError: Boolean = false, + isLoading: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Text, + imeAction: ImeAction = ImeAction.Next, + onFocusChange: ((Boolean) -> Unit)? = null, + onImeAction: (() -> Unit)? = null, +) { + + val customTextSelectionColors = TextSelectionColors( + handleColor = MaterialTheme.colorScheme.tertiary, + backgroundColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.4f) + ) + CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) { + OutlinedTextField( + modifier = modifier + .fillMaxWidth() + .let { mod -> + onFocusChange?.let { callback -> + mod.onFocusChanged { callback(it.isFocused) } + } ?: mod + }, + value = value, + enabled = !isLoading && enabled, + onValueChange = onValueChange, + placeholder = { + placeholder?.let { + Text( + text = placeholder, + style = MaterialTheme.typography.labelMedium.copy( + color = colorResource(R.color.colorOnSurfaceVariant) + ) + ) + } + }, + singleLine = true, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = keyboardType, + imeAction = imeAction, + platformImeOptions = PlatformImeOptions(), + showKeyboardOnFocus = true, + hintLocales = null + ), + keyboardActions = KeyboardActions( + onDone = { + onImeAction?.invoke() + }, + onNext = { + onImeAction?.invoke() + }, + onGo = { + onImeAction?.invoke() + }, + onSearch = { + onImeAction?.invoke() + }, + onSend = { + onImeAction?.invoke() + } + ), + isError = isError, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.background, + unfocusedContainerColor = MaterialTheme.colorScheme.background, + focusedBorderColor = MaterialTheme.colorScheme.tertiary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + cursorColor = MaterialTheme.colorScheme.tertiary, + //focusedIndicatorColor = Color.Transparent, + //unfocusedIndicatorColor = Color.Transparent, + ), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveHeader.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveHeader.kt similarity index 92% rename from app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveHeader.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveHeader.kt index 88619c3ea..bc697868d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveHeader.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveHeader.kt @@ -1,4 +1,4 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation.login +package net.opendasharchive.openarchive.services.internetarchive.presentation.login import android.content.res.Configuration import androidx.compose.foundation.Image @@ -20,7 +20,7 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.opendasharchive.openarchive.R @@ -65,8 +65,7 @@ fun InternetArchiveHeader(modifier: Modifier = Modifier) { } @Composable -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@PreviewLightDark private fun InternetArchiveHeaderPreview() { SaveAppTheme { InternetArchiveHeader() diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveLoginScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveLoginScreen.kt new file mode 100644 index 000000000..1be227805 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveLoginScreen.kt @@ -0,0 +1,285 @@ +package net.opendasharchive.openarchive.services.internetarchive.presentation.login + +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.util.NetworkUtils + +@Composable +fun InternetArchiveLoginScreen( + viewModel: InternetArchiveLoginViewModel, +) { + + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + + is InternetArchiveLoginEvent.LoginError -> { + // Error handling can be done here if needed + } + } + } + } + + InternetArchiveLoginContent(state, viewModel::onAction) +} + +@Composable +private fun InternetArchiveLoginContent( + state: InternetArchiveLoginState, + onAction: (InternetArchiveLoginAction) -> Unit +) { + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = {} + ) + + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val passwordFocusRequester = remember { FocusRequester() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 32.dp, bottom = 16.dp) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + InternetArchiveHeader( + modifier = Modifier + .padding(vertical = 48.dp) + .padding(end = 24.dp) + ) + + + + Box { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 16.dp) + ) { + Text( + stringResource(R.string.account), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge + ) + } + } + + CustomTextField( + value = state.username, + onValueChange = { + onAction(InternetArchiveLoginAction.ErrorClear) + onAction(InternetArchiveLoginAction.UpdateUsername(it)) + }, + placeholder = stringResource(R.string.prompt_email), + isError = state.isUsernameError, + isLoading = state.isBusy, + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + onImeAction = { + passwordFocusRequester.requestFocus() + } + ) + + Spacer(Modifier.height(ThemeDimensions.spacing.large)) + + CustomSecureField( + value = state.password, + onValueChange = { + onAction(InternetArchiveLoginAction.ErrorClear) + onAction(InternetArchiveLoginAction.UpdatePassword(it)) + }, + placeholder = stringResource(R.string.prompt_password), + isError = state.isPasswordError, + isLoading = state.isBusy, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + onImeAction = { + focusManager.clearFocus() + }, + modifier = Modifier.focusRequester(passwordFocusRequester) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + AnimatedVisibility( + visible = state.isLoginError, + enter = fadeIn(), + exit = fadeOut() + ) { + Text( + text = stringResource(R.string.error_incorrect_email_or_password), + color = MaterialTheme.colorScheme.error + ) + } + } + + Spacer(Modifier.height(ThemeDimensions.spacing.large)) + Row( + modifier = Modifier + .padding(top = ThemeDimensions.spacing.small), + verticalAlignment = Alignment.CenterVertically + ) { + + Text( + text = stringResource(R.string.prompt_no_account), + style = MaterialTheme.typography.bodyLarge.copy( // reuse your themed style + color = ThemeColors.material.onBackground, + fontWeight = FontWeight.SemiBold + ) + ) + + TextButton( + modifier = Modifier.heightIn(ThemeDimensions.touchable), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ), + onClick = { + launcher.launch( + Intent( + Intent.ACTION_VIEW, "https://archive.org/account/signup".toUri() + ) + ) + } + ) { + Text( + text = stringResource(R.string.label_create_login), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold + ) + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + modifier = Modifier + .padding(8.dp) + .heightIn(ThemeDimensions.touchable) + .weight(1f), + colors = ButtonDefaults.textButtonColors( + contentColor = colorResource(R.color.colorOnBackground) + ), + enabled = !state.isBusy, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { onAction(InternetArchiveLoginAction.Cancel) }) { + Text(stringResource(R.string.back), style = MaterialTheme.typography.titleLarge) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + modifier = Modifier + .padding(8.dp) + .heightIn(ThemeDimensions.touchable) + .weight(1f), + enabled = !state.isBusy && state.isValid, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + disabledContainerColor = colorResource(R.color.grey_50), + disabledContentColor = colorResource(R.color.black), + contentColor = colorResource(R.color.black) + ), + onClick = { + if (NetworkUtils.isNetworkAvailable(context)) { + onAction(InternetArchiveLoginAction.Login) + } else { + Toast.makeText(context, R.string.error_no_internet, Toast.LENGTH_LONG) + .show() + } + }, + ) { + if (state.isBusy) { + CircularProgressIndicator(color = ThemeColors.material.primary) + } else { + Text( + stringResource(R.string.next), + style = MaterialTheme.typography.titleLarge + ) + } + } + } + } +} + +@Composable +@PreviewLightDark +private fun InternetArchiveLoginPreview() { + DefaultScaffoldPreview { + InternetArchiveLoginContent( + state = InternetArchiveLoginState( + username = "", + password = "", + isLoginError = true, + isPasswordError = true, + isUsernameError = true + ), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveLoginState.kt similarity index 67% rename from app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveLoginState.kt index e4d3aab29..eecf4a0c5 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveLoginState.kt @@ -1,7 +1,6 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation.login +package net.opendasharchive.openarchive.services.internetarchive.presentation.login import androidx.compose.runtime.Immutable -import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive @Immutable data class InternetArchiveLoginState( @@ -19,13 +18,9 @@ sealed interface InternetArchiveLoginAction { data class UpdatePassword(val password: String) : InternetArchiveLoginAction data object Login : InternetArchiveLoginAction data object Cancel : InternetArchiveLoginAction - data object CreateLogin : InternetArchiveLoginAction data object ErrorClear : InternetArchiveLoginAction } sealed interface InternetArchiveLoginEvent { - data class LoginSuccess(val spaceId: Long) : InternetArchiveLoginEvent data class LoginError(val error: Throwable) : InternetArchiveLoginEvent - data object NavigateToSignup : InternetArchiveLoginEvent - data object NavigateBack : InternetArchiveLoginEvent } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt similarity index 61% rename from app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt index e3c540e74..101562f60 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt @@ -1,6 +1,5 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation.login +package net.opendasharchive.openarchive.services.internetarchive.presentation.login -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel @@ -10,22 +9,22 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.InternetArchiveLoginUseCase -import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.ValidateLoginCredentialsUseCase +import net.opendasharchive.openarchive.core.domain.Credentials +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.services.internetarchive.data.InternetArchiveAuthenticator +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.koin.core.parameter.parametersOf class InternetArchiveLoginViewModel( - private val validateLoginCredentials: ValidateLoginCredentialsUseCase, + private val route: AppRoute.IALoginRoute, + private val navigator: Navigator, + private val spaceRepository: SpaceRepository, ) : ViewModel(), KoinComponent { - val space = Space(Space.Type.INTERNET_ARCHIVE) - - private val loginUseCase: InternetArchiveLoginUseCase by inject { - parametersOf(space) - } + private val authenticator: InternetArchiveAuthenticator by inject() private val _uiState = MutableStateFlow(InternetArchiveLoginState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -39,7 +38,7 @@ class InternetArchiveLoginViewModel( _uiState.update { currentState -> currentState.copy( username = action.username, - isValid = validateLoginCredentials(action.username, currentState.password) + isValid = action.username.isNotBlank() && currentState.password.isNotBlank() ) } } @@ -48,7 +47,7 @@ class InternetArchiveLoginViewModel( _uiState.update { currentState -> currentState.copy( password = action.password, - isValid = validateLoginCredentials(currentState.username, action.password) + isValid = currentState.username.isNotBlank() && action.password.isNotBlank() ) } } @@ -59,13 +58,7 @@ class InternetArchiveLoginViewModel( is InternetArchiveLoginAction.Cancel -> { viewModelScope.launch { - _events.send(InternetArchiveLoginEvent.NavigateBack) - } - } - - is InternetArchiveLoginAction.CreateLogin -> { - viewModelScope.launch { - _events.send(InternetArchiveLoginEvent.NavigateToSignup) + navigator.navigateBack() } } @@ -79,10 +72,19 @@ class InternetArchiveLoginViewModel( _uiState.update { it.copy(isBusy = true) } viewModelScope.launch { val currentState = _uiState.value - loginUseCase.invoke(currentState.username, currentState.password) - .onSuccess { ia -> + val credentials = Credentials.InternetArchive( + email = currentState.username, + pass = currentState.password + ) + + authenticator.authenticate(credentials) + .onSuccess { vault -> + val vaultId = spaceRepository.addSpace(vault) + spaceRepository.setCurrentSpace(vaultId) + _uiState.update { it.copy(isBusy = false) } - _events.send(InternetArchiveLoginEvent.LoginSuccess(space.id)) + + navigator.navigateTo(AppRoute.SetupLicenseRoute(spaceId = vaultId, spaceType = VaultType.INTERNET_ARCHIVE)) } .onFailure { error -> _uiState.update { it.copy(isLoginError = true, isUsernameError = true, isPasswordError = true, isBusy = false) } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/BaseSnowbirdFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/BaseSnowbirdFragment.kt deleted file mode 100644 index 5e926c027..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/BaseSnowbirdFragment.kt +++ /dev/null @@ -1,102 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.content.Context -import android.os.Bundle -import android.view.View -import android.view.inputmethod.InputMethodManager -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.analytics.api.AnalyticsManager -import net.opendasharchive.openarchive.analytics.api.session.SessionTracker -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.db.SnowbirdError -import net.opendasharchive.openarchive.extensions.androidViewModel -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.ToolbarConfigurable -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.util.FullScreenOverlayManager -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.activityViewModel - -abstract class BaseSnowbirdFragment : Fragment(), ToolbarConfigurable { - - protected val dialogManager: DialogStateManager by activityViewModel() - protected val analyticsManager: AnalyticsManager by inject() - protected val sessionTracker: SessionTracker by inject() - - private var screenStartTime: Long = 0 - private var previousScreen: String = "" - - val snowbirdGroupViewModel: SnowbirdGroupViewModel by androidViewModel() - val snowbirdRepoViewModel: SnowbirdRepoViewModel by androidViewModel() - - protected open fun getScreenName(): String = this::class.simpleName ?: "UnknownSnowbirdFragment" - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - ensureComposeDialogHost() - } - - private fun ensureComposeDialogHost() { - (requireActivity() as? BaseActivity)?.ensureComposeDialogHost() - } - - open fun dismissKeyboard(view: View) { - val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - } - - open fun handleError(error: SnowbirdError) { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - title = UiText.DynamicString("Oops") - message = UiText.DynamicString(error.friendlyMessage) - positiveButton { - text = UiText.StringResource(R.string.lbl_ok) - } - } - } - - open fun handleLoadingStatus(isLoading: Boolean) { - if (isLoading) { - FullScreenOverlayManager.show(this@BaseSnowbirdFragment) - } else { - FullScreenOverlayManager.hide() - } - } - - override fun onResume() { - super.onResume() - (activity as? SnowbirdActivity)?.updateToolbarFromFragment(this) - - screenStartTime = System.currentTimeMillis() - val screenName = getScreenName() - AppLogger.setCurrentScreen(screenName) - - lifecycleScope.launch { - analyticsManager.trackScreenView(screenName, null, previousScreen) - } - sessionTracker.setCurrentScreen(screenName) - - if (previousScreen.isNotEmpty() && previousScreen != screenName) { - lifecycleScope.launch { - analyticsManager.trackNavigation(previousScreen, screenName) - } - } - } - - override fun onPause() { - super.onPause() - val timeSpent = (System.currentTimeMillis() - screenStartTime) / 1000 - val screenName = getScreenName() - - lifecycleScope.launch { - analyticsManager.trackScreenView(screenName, timeSpent, previousScreen) - } - - previousScreen = screenName - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdActivity.kt deleted file mode 100644 index 9b9ae0824..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdActivity.kt +++ /dev/null @@ -1,65 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.os.Bundle -import androidx.fragment.app.Fragment -import androidx.navigation.NavController -import androidx.navigation.NavGraph -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupActionBarWithNavController -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivitySnowbirdBinding -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.ToolbarConfigurable - - -class SnowbirdActivity : BaseActivity() { - - private lateinit var binding: ActivitySnowbirdBinding - private lateinit var navController: NavController - private lateinit var navGraph: NavGraph - private lateinit var appBarConfiguration: AppBarConfiguration - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - binding = ActivitySnowbirdBinding.inflate(layoutInflater) - setContentView(binding.root) - - setupToolbar(showBackButton = true) - initSnowbirdNavigation() - } - - private fun initSnowbirdNavigation() { - val navHostFragment = supportFragmentManager.findFragmentById(R.id.snowbird_nav_host_fragment) as NavHostFragment - - navController = navHostFragment.navController - navGraph = navController.navInflater.inflate(R.navigation.snowbird_nav_graph) - - appBarConfiguration = AppBarConfiguration(emptySet()) - setupActionBarWithNavController(navController, appBarConfiguration) - } - - fun updateToolbarFromFragment(fragment: Fragment) { - if (fragment is ToolbarConfigurable) { - val title = fragment.getToolbarTitle() - val subtitle = fragment.getToolbarSubtitle() - val showBackButton = fragment.shouldShowBackButton() - setupToolbar(title = title, showBackButton = showBackButton) - supportActionBar?.subtitle = subtitle - } else { - setupToolbar(title = getString(R.string.dweb_title), showBackButton = true) - supportActionBar?.subtitle = null - } - } - - override fun onSupportNavigateUp(): Boolean { - return if (::navController.isInitialized && navController.currentDestination?.id == R.id.snowbird_dashboard) { - finish() - true - } else { - navController.navigateUp() || super.onSupportNavigateUp() - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdBridge.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdBridge.kt index c495398ef..82c9cadeb 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdBridge.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdBridge.kt @@ -3,9 +3,14 @@ package net.opendasharchive.openarchive.services.snowbird import android.content.Context import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdServiceStatus +/** + * Java_net_opendasharchive_openarchive_services_snowbird_SnowbirdBridge_initializeRustService + */ class SnowbirdBridge { - private val _status = MutableStateFlow(SnowbirdServiceStatus.BackendInitializing) + private val _status = + MutableStateFlow(SnowbirdServiceStatus.BackendInitializing) val status = _status.asStateFlow() fun initialize() { @@ -28,11 +33,12 @@ class SnowbirdBridge { @JvmStatic fun updateStatusFromRust(code: Int, message: String) { - instance?._status?.value = SnowbirdServiceStatus.fromCode(code) + // Preserve error context from Rust when available. + instance?._status?.value = SnowbirdServiceStatus.fromCode(code, message) } } private external fun initializeRustService() external fun startServer(context: Context, baseDirectory: String, socketPath: String): String external fun stopServer() -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdCreateGroupFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdCreateGroupFragment.kt deleted file mode 100644 index b763a2486..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdCreateGroupFragment.kt +++ /dev/null @@ -1,260 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.BuildConfig -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.FragmentSnowbirdCreateGroupBinding -import net.opendasharchive.openarchive.db.SnowbirdError -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.db.SnowbirdRepo -import net.opendasharchive.openarchive.util.FullScreenOverlayCreateGroupManager -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets -import java.io.File - -class SnowbirdCreateGroupFragment : BaseSnowbirdFragment() { - - private lateinit var binding: FragmentSnowbirdCreateGroupBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentSnowbirdCreateGroupBinding.inflate(inflater) - - binding.buttonBar.applyEdgeToEdgeInsets(WindowInsetsCompat.Type.navigationBars()) { insets -> - bottomMargin = insets.bottom - } - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.btnNext.setOnClickListener { - snowbirdGroupViewModel.createGroup(binding.groupNameTextfield.text.toString()) - dismissKeyboard(it) - } - - binding.btnCancel.setOnClickListener { - findNavController().popBackStack() - } - - initializeViewModelObservers() - setupTextWatchers() - if (BuildConfig.DEBUG) { - //setupFilesList() - } - } - - private fun initializeViewModelObservers() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - snowbirdGroupViewModel.groupState.collectLatest { state -> - handleGroupStateUpdate( - state - ) - } - } - launch { - snowbirdRepoViewModel.repoState.collectLatest { state -> - handleRepoStateUpdate( - state - ) - } - } - } - } - } - - private fun handleGroupStateUpdate(state: SnowbirdGroupViewModel.GroupState) { - AppLogger.d("group state = $state") - when (state) { - is SnowbirdGroupViewModel.GroupState.Loading -> handleCreateGroupLoadingStatus(true) - is SnowbirdGroupViewModel.GroupState.SingleGroupSuccess -> handleGroupCreated(state.group) - is SnowbirdGroupViewModel.GroupState.Error -> handleError(state.error) - else -> Unit - } - } - - private fun handleCreateGroupLoadingStatus(isLoading: Boolean) { - if (isLoading) { - FullScreenOverlayCreateGroupManager.show(this@SnowbirdCreateGroupFragment) - } else { - FullScreenOverlayCreateGroupManager.hide() - } - } - - - private fun handleRepoStateUpdate(state: SnowbirdRepoViewModel.RepoState) { - AppLogger.d("repo state = $state") - when (state) { - is SnowbirdRepoViewModel.RepoState.Loading -> handleCreateGroupLoadingStatus(true) - is SnowbirdRepoViewModel.RepoState.SingleRepoSuccess -> handleRepoCreated(state.repo) - is SnowbirdRepoViewModel.RepoState.Error -> handleError(state.error) - else -> Unit - } - } - - override fun handleError(error: SnowbirdError) { - handleCreateGroupLoadingStatus(false) - super.handleError(error) - } - - private fun handleGroupCreated(group: SnowbirdGroup?) { - if (group == null) { - handleError(SnowbirdError.GeneralError("Group was null")) - return - } - - snowbirdGroupViewModel.setCurrentGroup(group) - - lifecycleScope.launch { - group.save() - snowbirdRepoViewModel.createRepo( - groupKey = group.key, - repoName = binding.repoNameTextfield.text.toString() - ) - } - } - - private fun handleRepoCreated(repo: SnowbirdRepo?) { - handleCreateGroupLoadingStatus(false) - if (repo == null) { - handleError(SnowbirdError.GeneralError("Repo was null")) - return - } - - repo.groupKey = snowbirdGroupViewModel.currentGroup.value!!.key - repo.permissions = "READ_WRITE" - repo.save() - showConfirmation(repo) - } - - private fun showConfirmation(repo: SnowbirdRepo?) { - val group = SnowbirdGroup.get(repo!!.groupKey) - - if (group == null) { - handleError(SnowbirdError.GeneralError("Group was null")) - return - } - - val action = SnowbirdCreateGroupFragmentDirections - .actionFragmentSnowbirdCreateGroupToFragmentSnowbirdSetupSuccess( - message = getString(R.string.you_have_successfully_created_dweb), - dwebGroupKey = group.key, - ) - findNavController().navigate(action) - } - - private fun setupTextWatchers() { - // Create a common TextWatcher for all three fields - val textWatcher = object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - updateAuthenticateButtonState() - } - - override fun afterTextChanged(s: Editable?) { - dismissCredentialsError() - } - } - - binding.groupNameTextfield.addTextChangedListener(textWatcher) - binding.repoNameTextfield.addTextChangedListener(textWatcher) - } - - private fun updateAuthenticateButtonState() { - val groupName = binding.groupNameTextfield.text?.toString()?.trim().orEmpty() - val repoName = binding.repoNameTextfield.text?.toString()?.trim().orEmpty() - - // Enable the button only if none of the fields are empty - binding.btnNext.isEnabled = groupName.isNotEmpty() && repoName.isNotEmpty() - } - - private fun dismissCredentialsError() { - //binding.errorHint.hide() - } - - private fun setupFilesList() { - try { - val filesDir = requireContext().filesDir - val files = filesDir.listFiles() - - val fileInfoList = mutableListOf() - - if (files != null) { - // Add socket file specifically if it exists - val socketFile = File(filesDir, "rust_server.sock") - if (socketFile.exists()) { - fileInfoList.add("🔗 rust_server.sock (${formatFileSize(socketFile.length())})") - } - - // Add other files - files.filter { it.name != "rust_server.sock" }.forEach { file -> - val icon = if (file.isDirectory()) "📁" else "📄" - val size = if (file.isDirectory()) "" else " (${formatFileSize(file.length())})" - fileInfoList.add("$icon ${file.name}$size") - } - - if (fileInfoList.isEmpty()) { - fileInfoList.add("(No files found)") - } - } else { - fileInfoList.add("(Unable to access files directory)") - } - - // Update the label to show directory path - binding.filesLabel.text = "App Files Directory (${filesDir.absolutePath}):" - binding.filesList.visibility = View.VISIBLE - val adapter = ArrayAdapter( - requireContext(), - android.R.layout.simple_list_item_1, - fileInfoList - ) - binding.filesList.adapter = adapter - binding.filesList.visibility = View.VISIBLE - - } catch (e: Exception) { - AppLogger.e("Error listing app files", e) - val errorList = listOf("Error loading files: ${e.message}") - val adapter = ArrayAdapter( - requireContext(), - android.R.layout.simple_list_item_1, - errorList - ) - binding.filesList.adapter = adapter - binding.filesList.visibility = View.VISIBLE - } - } - - private fun formatFileSize(bytes: Long): String { - return when { - bytes >= 1024 * 1024 -> "${bytes / (1024 * 1024)} MB" - bytes >= 1024 -> "${bytes / 1024} KB" - else -> "$bytes bytes" - } - } - - override fun getToolbarTitle(): String { - return "Create DWeb Storage Group" - } - -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListAdapter.kt deleted file mode 100644 index 488646c3a..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListAdapter.kt +++ /dev/null @@ -1,97 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.content.res.ColorStateList -import android.os.Build -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.annotation.RequiresExtension -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.SnowbirdMediaGridItemBinding -import net.opendasharchive.openarchive.db.SnowbirdFileItem -import java.lang.ref.WeakReference - -class SnowbirdFileViewHolder(val binding: SnowbirdMediaGridItemBinding) : RecyclerView.ViewHolder(binding.root) - -class SnowbirdFileListAdapter( - onClickListener: ((SnowbirdFileItem) -> Unit)? = null, - onLongPressListener: ((SnowbirdFileItem) -> Unit)? = null -) : ListAdapter(SnowbirdFileDiffCallback()) { - - private val onClickCallback = WeakReference(onClickListener) - private val onLongPressCallback = WeakReference(onLongPressListener) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SnowbirdFileViewHolder { - val binding = SnowbirdMediaGridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return SnowbirdFileViewHolder(binding) - } - - @RequiresExtension(extension = Build.VERSION_CODES.S, version = 7) - override fun onBindViewHolder(holder: SnowbirdFileViewHolder, position: Int) { - val item = getItem(position) - - with (holder.binding) { - val context = root.context - - // Set filename - name.text = item.name ?: "No name provided" - - // Determine file type and show appropriate icon - val fileExtension = item.name?.substringAfterLast(".", "")?.lowercase() ?: "" - - when { - isImageFile(fileExtension) -> setDefaultIcon(R.drawable.ic_image) - isVideoFile(fileExtension) -> setDefaultIcon(R.drawable.ic_video) - isAudioFile(fileExtension) -> setDefaultIcon(R.drawable.ic_music) - else -> setDefaultIcon(R.drawable.ic_folder_new) - } - - // Show download badge if not downloaded - downloadBadge.visibility = if (item.isDownloaded) View.GONE else View.VISIBLE - - root.setOnClickListener { - onClickCallback.get()?.invoke(item) - } - - root.setOnLongClickListener { - onLongPressCallback.get()?.invoke(item) - true - } - } - } - - private fun SnowbirdMediaGridItemBinding.setDefaultIcon(iconRes: Int) { - icon.scaleType = ImageView.ScaleType.CENTER_INSIDE - icon.setImageDrawable(ContextCompat.getDrawable(root.context, iconRes)?.mutate()) - icon.imageTintList = ColorStateList.valueOf( - ContextCompat.getColor(root.context, R.color.colorOnBackground) - ) - } - - private fun isImageFile(extension: String): Boolean { - return extension in listOf("jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "heif") - } - - private fun isVideoFile(extension: String): Boolean { - return extension in listOf("mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "3gp") - } - - private fun isAudioFile(extension: String): Boolean { - return extension in listOf("mp3", "wav", "ogg", "m4a", "flac", "aac", "wma") - } -} - -class SnowbirdFileDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SnowbirdFileItem, newItem: SnowbirdFileItem): Boolean { - return oldItem.hash == newItem.hash - } - - override fun areContentsTheSame(oldItem: SnowbirdFileItem, newItem: SnowbirdFileItem): Boolean { - return oldItem == newItem - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt deleted file mode 100644 index 300c5a369..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt +++ /dev/null @@ -1,567 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.Manifest -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.webkit.MimeTypeMap -import android.widget.Toast -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat -import androidx.core.view.MenuProvider -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.GridLayoutManager -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentSnowbirdListMediaBinding -import net.opendasharchive.openarchive.db.FileUploadResult -import net.opendasharchive.openarchive.db.SnowbirdError -import net.opendasharchive.openarchive.db.SnowbirdFileItem -import net.opendasharchive.openarchive.extensions.androidViewModel -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.features.media.AddMediaType -import net.opendasharchive.openarchive.features.media.ContentPickerFragment -import net.opendasharchive.openarchive.features.media.Picker -import net.opendasharchive.openarchive.features.media.camera.CameraActivity -import net.opendasharchive.openarchive.features.media.camera.CameraConfig -import net.opendasharchive.openarchive.features.settings.passcode.AppConfig -import net.opendasharchive.openarchive.util.SpacingItemDecoration -import org.koin.android.ext.android.inject -import timber.log.Timber -import java.io.File - -class SnowbirdFileListFragment : BaseSnowbirdFragment() { - - private val snowbirdFileViewModel: SnowbirdFileViewModel by androidViewModel() - private val appConfig: AppConfig by inject() - private lateinit var viewBinding: FragmentSnowbirdListMediaBinding - private lateinit var adapter: SnowbirdFileListAdapter - private lateinit var groupKey: String - private lateinit var repoKey: String - private var currentPhotoUri: Uri? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - arguments?.let { - groupKey = it.getString(RESULT_VAL_RAVEN_GROUP_KEY, "") - repoKey = it.getString(RESULT_VAL_RAVEN_REPO_KEY, "") - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - viewBinding = FragmentSnowbirdListMediaBinding.inflate(inflater) - - return viewBinding.root - } - - // Permission launcher for camera - private val cameraPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (isGranted) { - launchCamera() - } - } - - // Modern visual media picker for gallery (supports up to 10 items) - private val galleryLauncher = - registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(10)) { uris: List? -> - if (!uris.isNullOrEmpty()) { - handleSelectedFiles(uris) - } else { - Timber.d("No media selected from gallery") - } - } - - // Document picker for file browser (shows actual file manager, not gallery) - private val filePickerLauncher = - registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris: List -> - if (!uris.isNullOrEmpty()) { - // Filter to only allow media files - val filteredUris = uris.filter { uri -> - val mimeType = requireContext().contentResolver.getType(uri) - mimeType?.startsWith("image/") == true || - mimeType?.startsWith("video/") == true || - mimeType?.startsWith("audio/") == true - } - - if (filteredUris.isEmpty()) { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - title = UiText.DynamicString("Invalid File Type") - message = UiText.DynamicString("Please select only image, video, or audio files. Other file types are not supported.") - positiveButton { - text = UiText.StringResource(R.string.lbl_ok) - } - } - } else { - if (filteredUris.size < uris.size) { - // Some files were filtered out - Toast.makeText( - requireContext(), - "Some files were skipped (only images, videos, and audio are supported)", - Toast.LENGTH_LONG - ).show() - } - handleSelectedFiles(filteredUris) - } - } - } - - // Modern camera launcher using TakePicture contract for photo capture - private val modernCameraLauncher = - registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> - if (success && currentPhotoUri != null) { - currentPhotoUri?.let { uri -> - Timber.d("Processing camera capture from URI: $uri") - handleMedia(uri) - } - currentPhotoUri = null - } else { - Timber.d("Camera capture cancelled or failed") - currentPhotoUri = null - } - } - - // Custom camera launcher for video and photo with multiple capture - private val customCameraLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { - val capturedUris = - result.data?.getStringArrayListExtra(CameraActivity.EXTRA_CAPTURED_URIS) - if (!capturedUris.isNullOrEmpty()) { - val uris = capturedUris.map { Uri.parse(it) } - handleSelectedFiles(uris) - } else { - Timber.w("No captures returned from custom camera") - } - } else { - Timber.w("Custom camera capture cancelled or failed") - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupMenu() - setupRecyclerView() - setupSwipeRefresh() - initializeViewModelObservers() - } - - private fun setupMenu() { - requireActivity().addMenuProvider(object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_snowbird, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_add -> { - Timber.d("Add button clicked!") - openContentPickerSheet() - true - } - else -> false - } - } - }, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - private fun handleAudio(uri: Uri) { - handleMedia(uri) - } - - private fun handleImage(uri: Uri) { - handleMedia(uri) - } - - private fun handleVideo(uri: Uri) { - handleMedia(uri) - } - - private fun handleMedia(uri: Uri) { - Timber.d("Going to upload file") - snowbirdFileViewModel.uploadFile(groupKey, repoKey, uri) - } - - private fun handleSelectedFiles(uris: List) { - if (uris.isNotEmpty()) { - var unsupportedCount = 0 - for (uri in uris) { - val mimeType = requireContext().contentResolver.getType(uri) - when { - mimeType?.startsWith("image/") == true -> handleImage(uri) - mimeType?.startsWith("video/") == true -> handleVideo(uri) - mimeType?.startsWith("audio/") == true -> handleAudio(uri) - else -> { - unsupportedCount++ - Timber.w("Unsupported file type: $mimeType for URI: $uri") - } - } - } - - if (unsupportedCount > 0) { - Toast.makeText( - requireContext(), - "$unsupportedCount file(s) skipped. Only images, videos, and audio are supported.", - Toast.LENGTH_LONG - ).show() - } - } else { - Timber.d("No files selected") - } - } - - private fun openFilePicker() { - // Use OpenMultipleDocuments to show the file browser (not gallery) - // Only allow media file types (images, videos, audio) - try { - filePickerLauncher.launch(arrayOf("image/*", "video/*", "audio/*")) - } catch (e: Exception) { - Timber.e(e, "Error launching file picker") - Toast.makeText( - requireContext(), - "Could not open file picker", - Toast.LENGTH_SHORT - ).show() - } - } - - private fun openContentPickerSheet() { - val contentPickerSheet = ContentPickerFragment { mediaType -> - handleMediaTypeSelection(mediaType) - } - contentPickerSheet.show(parentFragmentManager, ContentPickerFragment.TAG) - } - - private fun handleMediaTypeSelection(mediaType: AddMediaType) { - when (mediaType) { - AddMediaType.CAMERA -> openCamera() - AddMediaType.GALLERY -> openGallery() - AddMediaType.FILES -> openFilePicker() - } - } - - private fun openCamera() { - when { - ContextCompat.checkSelfPermission( - requireContext(), - Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED -> { - launchCamera() - } - - shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> { - // Show rationale dialog - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - title = UiText.DynamicString("Camera Permission") - message = UiText.DynamicString("Camera access is needed to take pictures. Please grant permission.") - positiveButton { - text = UiText.DynamicString("Accept") - action = { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - neutralButton { - text = UiText.DynamicString("Cancel") - } - } - } - - else -> { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - } - - private fun launchCamera() { - if (appConfig.useCustomCamera) { - // Use custom camera with photo and video support - val cameraConfig = CameraConfig( - allowVideoCapture = true, - allowPhotoCapture = true, - allowMultipleCapture = false, - enablePreview = true, - showFlashToggle = true, - showGridToggle = true, - showCameraSwitch = true - ) - Picker.launchCustomCamera( - requireActivity(), - customCameraLauncher, - cameraConfig - ) - } else { - // Use system camera - val photoFile = File(requireContext().cacheDir, "camera_${System.currentTimeMillis()}.jpg") - currentPhotoUri = androidx.core.content.FileProvider.getUriForFile( - requireContext(), - "${requireContext().packageName}.fileprovider", - photoFile - ) - modernCameraLauncher.launch(currentPhotoUri) - } - } - - private fun openGallery() { - // PickVisualMedia doesn't require READ_MEDIA permissions on Android 13+ - // The system photo picker handles access internally - launchGallery() - } - - private fun launchGallery() { - val request = PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) - try { - galleryLauncher.launch(request) - } catch (e: Exception) { - Timber.e(e, "Error launching gallery picker") - // Fallback to file picker if gallery fails - openFilePicker() - } - } - - private fun setupRecyclerView() { - adapter = SnowbirdFileListAdapter( - onClickListener = { onClick(it) } - ) - - val spacingInPixels = resources.getDimensionPixelSize(R.dimen.list_item_spacing) - viewBinding.snowbirdMediaRecyclerView.addItemDecoration(SpacingItemDecoration(spacingInPixels)) - - viewBinding.snowbirdMediaRecyclerView.setEmptyView(R.layout.view_empty_state) - viewBinding.snowbirdMediaRecyclerView.layoutManager = GridLayoutManager(requireContext(), 3) - viewBinding.snowbirdMediaRecyclerView.adapter = adapter - } - - private fun onClick(item: SnowbirdFileItem) { -// if (!item.isDownloaded) { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - title = UiText.DynamicString("Download Media?") - message = UiText.DynamicString("Are you sure you want to download this media?") - positiveButton { - text = UiText.DynamicString("Yes") - action = { - snowbirdFileViewModel.downloadFile(groupKey, repoKey, item.name) - } - } - neutralButton { - text = UiText.DynamicString("No") - } - } -// } - } - - private fun handleMediaStateUpdate(state: SnowbirdFileViewModel.State) { - Timber.d("state = $state") - when (state) { - is SnowbirdFileViewModel.State.Idle -> { /* Initial state */ } - is SnowbirdFileViewModel.State.Loading -> onLoading() - is SnowbirdFileViewModel.State.FetchSuccess -> onFilesFetched(state.files, state.isRefresh) - is SnowbirdFileViewModel.State.UploadSuccess -> onFileUploaded(state.result) - is SnowbirdFileViewModel.State.DownloadSuccess -> onFileDownloaded(state.uri) - is SnowbirdFileViewModel.State.Error -> handleError(state.error) - } - } - - override fun handleError(error: SnowbirdError) { - handleLoadingStatus(false) - viewBinding.swipeRefreshLayout.isRefreshing = false - super.handleError(error) - } - - private fun onLoading() { - handleLoadingStatus(true) - viewBinding.swipeRefreshLayout.isRefreshing = false - } - - private fun onFilesFetched(files: List, isRefresh: Boolean) { - handleLoadingStatus(false) - - if (isRefresh) { - Timber.d("Clearing SnowbirdFileItems") - SnowbirdFileItem.clear() - } - - saveFiles(files) - - adapter.submitList(files) - } - - private fun onFileDownloaded(uri: Uri) { - handleLoadingStatus(false) - Timber.d("File successfully downloaded: $uri") - - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Success - title = UiText.StringResource(R.string.label_success_title) - message = UiText.DynamicString("File successfully downloaded") - positiveButton { - text = UiText.DynamicString("Open") - action = { - openDownloadedFile(uri) - } - } - neutralButton { - text = UiText.StringResource(R.string.lbl_ok) - } - } - } - - private fun openDownloadedFile(uri: Uri) { - try { - // The URI is already a FileProvider content URI, we can use it directly - // Extract filename from the URI to determine MIME type - val filename = uri.lastPathSegment ?: "file" - val mimeType = getMimeType(filename) ?: "*/*" - - Timber.d("Opening file with URI: $uri, MIME type: $mimeType") - - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, mimeType) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - // Check if there's an app that can handle this intent - if (intent.resolveActivity(requireContext().packageManager) != null) { - startActivity(intent) - } else { - // Fallback: try to open with file manager using generic MIME type - val fileManagerIntent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "*/*") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - val chooser = Intent.createChooser(fileManagerIntent, "Open file with") - if (chooser.resolveActivity(requireContext().packageManager) != null) { - startActivity(chooser) - } else { - // No app can handle this - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - title = UiText.DynamicString("No App Found") - message = UiText.DynamicString("No app is available to open this type of file.") - positiveButton { - text = UiText.StringResource(R.string.lbl_ok) - } - } - } - } - } catch (e: Exception) { - Timber.e(e, "Failed to open downloaded file") - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Error - title = UiText.DynamicString("Error") - message = UiText.DynamicString("Could not open file: ${e.message}") - positiveButton { - text = UiText.StringResource(R.string.lbl_ok) - } - } - } - } - - private fun getMimeType(fileName: String): String? { - val extension = fileName.substringAfterLast(".", "") - return when (extension.lowercase()) { - // Images - "jpg", "jpeg" -> "image/jpeg" - "png" -> "image/png" - "gif" -> "image/gif" - "bmp" -> "image/bmp" - "webp" -> "image/webp" - "heic", "heif" -> "image/heic" - - // Videos - "mp4" -> "video/mp4" - "avi" -> "video/x-msvideo" - "mkv" -> "video/x-matroska" - "mov" -> "video/quicktime" - "wmv" -> "video/x-ms-wmv" - "flv" -> "video/x-flv" - "webm" -> "video/webm" - "3gp" -> "video/3gpp" - - // Audio - "mp3" -> "audio/mpeg" - "wav" -> "audio/wav" - "ogg" -> "audio/ogg" - "m4a" -> "audio/mp4" - "flac" -> "audio/flac" - "aac" -> "audio/aac" - "wma" -> "audio/x-ms-wma" - - // Documents - "pdf" -> "application/pdf" - "doc" -> "application/msword" - "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - "txt" -> "text/plain" - - else -> MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - } - } - - private fun onFileUploaded(result: FileUploadResult) { - handleLoadingStatus(false) - Timber.d("File successfully uploaded: $result") - SnowbirdFileItem( - name = result.name, - hash = result.updatedCollectionHash, - groupKey = groupKey, - repoKey = repoKey, - isDownloaded = true - ).save() - snowbirdFileViewModel.fetchFiles(groupKey, repoKey, forceRefresh = false) - } - - private fun saveFiles(files: List) { - files.forEach { file -> - file.saveWith(groupKey, repoKey) - } - } - - private fun initializeViewModelObservers() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { snowbirdFileViewModel.mediaState.collect { state -> handleMediaStateUpdate(state) } } - launch { snowbirdFileViewModel.fetchFiles(groupKey, repoKey, forceRefresh = false) } - } - } - } - - private fun setupSwipeRefresh() { - viewBinding.swipeRefreshLayout.setOnRefreshListener { - lifecycleScope.launch { - snowbirdFileViewModel.fetchFiles(groupKey, repoKey, forceRefresh = true) - } - } - - viewBinding.swipeRefreshLayout.setColorSchemeResources( - R.color.colorPrimary, R.color.colorPrimaryDark - ) - } - - override fun getToolbarTitle(): String { - return "My Files" - } - - companion object { - const val RESULT_VAL_RAVEN_GROUP_KEY = "dweb_group_key" - const val RESULT_VAL_RAVEN_REPO_KEY = "dweb_repo_key" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileRepository.kt deleted file mode 100644 index 7e8c3cf78..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileRepository.kt +++ /dev/null @@ -1,59 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.net.Uri -import net.opendasharchive.openarchive.db.FileUploadResult -import net.opendasharchive.openarchive.db.SnowbirdFileItem -import net.opendasharchive.openarchive.db.toFile -import net.opendasharchive.openarchive.extensions.toSnowbirdError -import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI - -interface ISnowbirdFileRepository { - suspend fun fetchFiles(groupKey: String, repoKey: String, forceRefresh: Boolean = false): SnowbirdResult> - suspend fun downloadFile(groupKey: String, repoKey: String, filename: String): SnowbirdResult - suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): SnowbirdResult -} - -class SnowbirdFileRepository(val api: ISnowbirdAPI) : ISnowbirdFileRepository { - - override suspend fun fetchFiles(groupKey: String, repoKey: String, forceRefresh: Boolean): SnowbirdResult> { - return if (forceRefresh) { - fetchFilesFromNetwork(groupKey, repoKey) - } else { - fetchFilesFromCache(groupKey, repoKey) - } - } - - private fun fetchFilesFromCache(groupKey: String, repoKey: String): SnowbirdResult> { - return SnowbirdResult.Success(SnowbirdFileItem.findBy(groupKey, repoKey)) - } - - private suspend fun fetchFilesFromNetwork(groupKey: String, repoKey: String): SnowbirdResult> { - return try { - val response = api.fetchFiles(groupKey, repoKey) - val files = response.files.map { it.toFile(groupKey = groupKey, repoKey = repoKey) } - SnowbirdResult.Success(files) - } catch (e: Exception) { - SnowbirdResult.Error(e.toSnowbirdError()) - } - } - - override suspend fun downloadFile(groupKey: String, repoKey: String, filename: String): SnowbirdResult { - return try { - val response = api.downloadFile(groupKey, repoKey, filename) - SnowbirdResult.Success(response) - } catch (e: Exception) { - SnowbirdResult.Error(e.toSnowbirdError()) - } - } - - override suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): SnowbirdResult { - return try { - val response = api.uploadFile(groupKey, repoKey, uri) - SnowbirdResult.Success(response) - } catch (e: Exception) { - e.printStackTrace() - SnowbirdResult.Error(e.toSnowbirdError()) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileViewModel.kt deleted file mode 100644 index 0baccf2a3..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileViewModel.kt +++ /dev/null @@ -1,192 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.app.Application -import android.content.ContentValues -import android.content.Context -import android.media.MediaScannerConnection -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import androidx.core.content.FileProvider -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.db.FileUploadResult -import net.opendasharchive.openarchive.db.SnowbirdError -import net.opendasharchive.openarchive.db.SnowbirdFileItem -import net.opendasharchive.openarchive.util.BaseViewModel -import net.opendasharchive.openarchive.util.trackProcessingWithTimeout -import timber.log.Timber -import java.io.File -import java.io.FileOutputStream - -class SnowbirdFileViewModel( - private val application: Application, - private val repository: ISnowbirdFileRepository -) : BaseViewModel(application) { - - sealed class State { - data object Idle : State() - data object Loading : State() - data class DownloadSuccess(val uri: Uri) : State() - data class FetchSuccess(val files: List, var isRefresh: Boolean) : State() - data class UploadSuccess(val result: FileUploadResult) : State() - data class Error(val error: SnowbirdError) : State() - } - - private val _mediaState = MutableStateFlow(State.Idle) - val mediaState: StateFlow = _mediaState.asStateFlow() - - fun downloadFile(groupKey: String, repoKey: String, filename: String) { - viewModelScope.launch { - _mediaState.value = State.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(120_000, "download_file") { - repository.downloadFile(groupKey, repoKey, filename) - } - - _mediaState.value = when (result) { - is SnowbirdResult.Success -> onDownload(result.value, filename) - is SnowbirdResult.Error -> State.Error(result.error) - } - } catch (e: TimeoutCancellationException) { - _mediaState.value = State.Error(SnowbirdError.TimedOut) - } - } - } - - fun fetchFiles(groupKey: String, repoKey: String, forceRefresh: Boolean = false) { - viewModelScope.launch { - _mediaState.value = State.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(60_000, "fetch_files") { - repository.fetchFiles(groupKey, repoKey, forceRefresh) - } - - _mediaState.value = when (result) { - is SnowbirdResult.Success -> State.FetchSuccess(result.value, forceRefresh) - is SnowbirdResult.Error -> State.Error(result.error) - } - } catch (e: TimeoutCancellationException) { - _mediaState.value = State.Error(SnowbirdError.TimedOut) - } - } - } - - // Example reponse: - // { - // "updated_collection_hash": "7dkgeko3oeyyr5xympsg2mhbicb2k2ba4wqen6lpt6qs7mgza7vq" - // } - // - fun uploadFile(groupKey: String, repoKey: String, uri: Uri) { - viewModelScope.launch { - _mediaState.value = State.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(120_000, "upload_file") { - repository.uploadFile(groupKey, repoKey, uri) - } - - _mediaState.value = when (result) { - is SnowbirdResult.Success -> State.UploadSuccess(result.value) - is SnowbirdResult.Error -> State.Error(result.error) - } - } catch (e: TimeoutCancellationException) { - _mediaState.value = State.Error(SnowbirdError.TimedOut) - } - } - } - - private suspend fun onDownload(bytes: ByteArray, filename: String): State { - Timber.d("Downloaded ${bytes.size} bytes") - - val internalUri = saveByteArrayToFile(application.applicationContext, bytes, filename) - .getOrElse { throw it } - - val galleryUri = runCatching { - saveImageToGallery(application.applicationContext, bytes, filename) - }.getOrNull() - - return if (galleryUri != null) { - State.DownloadSuccess(internalUri).also { - Timber.d("Saved to gallery: $galleryUri") - } - } else { - // if gallery write failed, treat as download success anyway - State.DownloadSuccess(internalUri) - } - -// return saveByteArrayToFile(application.applicationContext, bytes, filename).fold( -// onSuccess = { uri -> State.DownloadSuccess(uri) }, -// onFailure = { error -> State.Error(SnowbirdError.GeneralError("Error saving file: ${error.message}")) } -// ) - } - - private suspend fun saveByteArrayToFile(context: Context, byteArray: ByteArray, filename: String): Result = - withContext(Dispatchers.IO) { - runCatching { - val directory = File(context.filesDir, "files").apply { mkdirs() } - val file = File(directory, filename) - - file.outputStream().use { it.write(byteArray) } - - FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - file - ) - } - } - - suspend fun saveImageToGallery( - context: Context, - imageBytes: ByteArray, - displayName: String // e.g. "photo1.jpg" - ): Uri? = withContext(Dispatchers.IO) { - // 1) for Q+ use MediaStore: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val values = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, displayName) - put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") - // put it in Pictures/YourApp (no extra permission) - put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Save") - put(MediaStore.Images.Media.IS_PENDING, 1) - } - - val resolver = context.contentResolver - val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) - ?: return@withContext null - - resolver.openOutputStream(uri)?.use { it.write(imageBytes) } - // release the "pending" flag so it shows up - values.clear() - values.put(MediaStore.Images.Media.IS_PENDING, 0) - resolver.update(uri, values, null, null) - - return@withContext uri - } - - // 2) for Pre-Q: still possible, but you need WRITE_EXTERNAL_STORAGE - val imagesDir = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), - "YourApp" - ).apply { if (!exists()) mkdirs() } - - val file = File(imagesDir, displayName) - FileOutputStream(file).use { it.write(imageBytes) } - - // tell MediaStore about it - MediaScannerConnection.scanFile( - context, - arrayOf(file.absolutePath), - arrayOf("image/jpeg"), - null - ) - return@withContext Uri.fromFile(file) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt deleted file mode 100644 index 208816d9f..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt +++ /dev/null @@ -1,595 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.content.Intent -import android.content.res.Configuration -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter.Companion.tint -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation.fragment.findNavController -import com.google.zxing.BarcodeFormat -import com.google.zxing.BinaryBitmap -import com.google.zxing.DecodeHintType -import com.google.zxing.MultiFormatReader -import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.HybridBinarizer -import com.google.zxing.integration.android.IntentIntegrator -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.core.presentation.theme.SaveTextStyles -import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors -import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.extensions.getQueryParameter -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.features.main.QRScannerActivity -import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus -import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService - -class SnowbirdFragment : BaseSnowbirdFragment() { - - private val qrCodeLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) - if (scanResult != null) { - if (scanResult.contents != null) { - processScannedData(scanResult.contents) - } - } - } - - private val imagePickerLauncher = registerForActivityResult( - ActivityResultContracts.GetContent() - ) { uri: Uri? -> - uri?.let { processImageForQR(it) } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - val onJoinGroup: () -> Unit = { - showQRScanOptions() - } - - val onCreateGroup = { - val action = - SnowbirdFragmentDirections.actionFragmentSnowbirdToFragmentSnowbirdCreateGroup() - findNavController().navigate(action) - } - - val onMyGroups = { - val action = - SnowbirdFragmentDirections.actionFragmentSnowbirdToFragmentSnowbirdGroupList() - findNavController().navigate(action) - } - - return ComposeView(requireContext()).apply { - // Dispose of the Composition when the view's LifecycleOwner - // is destroyed - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - SaveAppTheme { - - LaunchedEffect(Unit) { - snowbirdGroupViewModel.groupState.collect { state -> - handleGroupStateUpdate( - state - ) - } - } - - SnowbirdScreen( - onJoinGroup = onJoinGroup, - onCreateGroup = onCreateGroup, - onMyGroups = onMyGroups, - onServerToggle = { enabled -> - if (enabled) { - requireContext().startForegroundService(Intent(requireContext(), SnowbirdService::class.java)) - } else { - requireContext().stopService(Intent(requireContext(), SnowbirdService::class.java)) - } - } - ) - } - } - } - } - - private fun handleGroupStateUpdate(state: SnowbirdGroupViewModel.GroupState) { - handleLoadingStatus(false) - AppLogger.d("group state = $state") - when (state) { - is SnowbirdGroupViewModel.GroupState.Loading -> handleLoadingStatus(true) - is SnowbirdGroupViewModel.GroupState.Error -> handleError(state.error) - else -> Unit - } - } - - private fun showQRScanOptions() { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Info - title = UiText.DynamicString("Scan QR Code") - message = UiText.DynamicString("Choose how you want to scan the QR code") - positiveButton { - text = UiText.DynamicString("Camera") - action = { startQRScanner() } - } - neutralButton { - text = UiText.DynamicString("Gallery") - action = { startImagePicker() } - } - } - } - - private fun startImagePicker() { - imagePickerLauncher.launch("image/*") - } - - private fun startQRScanner() { - val integrator = IntentIntegrator(requireActivity()) - integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) - integrator.setPrompt("Scan QR Code") - integrator.setCameraId(0) // Use the rear camera - integrator.setBeepEnabled(false) - integrator.setBarcodeImageEnabled(true) - integrator.setCaptureActivity(QRScannerActivity::class.java) - - val scanningIntent = integrator.createScanIntent() - - qrCodeLauncher.launch(scanningIntent) - } - - private fun processImageForQR(imageUri: Uri) { - try { - val inputStream = requireContext().contentResolver.openInputStream(imageUri) - val bitmap = BitmapFactory.decodeStream(inputStream) - inputStream?.close() - - if (bitmap != null) { - val qrContent = decodeQRFromBitmap(bitmap) - if (qrContent != null) { - processScannedData(qrContent) - } else { - showQRNotFoundDialog() - } - } else { - showQRNotFoundDialog() - } - } catch (e: Exception) { - AppLogger.e("Error processing image for QR: ${e.message}") - showQRNotFoundDialog() - } - } - - private fun decodeQRFromBitmap(bitmap: Bitmap): String? { - val intArray = IntArray(bitmap.width * bitmap.height) - bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) - - val source = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) - val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) - - val reader = MultiFormatReader() - val hints = mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)) - reader.setHints(hints) - - return try { - val result = reader.decode(binaryBitmap) - result.text - } catch (e: Exception) { - null - } - } - - private fun showQRNotFoundDialog() { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - title = UiText.DynamicString("No QR Code Found") - message = UiText.DynamicString("Could not find a valid QR code in the selected image. Please try another image.") - positiveButton { - text = UiText.StringResource(R.string.lbl_ok) - } - } - } - - private fun processScannedData(uriString: String) { - val name = uriString.getQueryParameter("name") - - if (name == null) { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - title = UiText.DynamicString("Oops!") - message = UiText.DynamicString("Unable to determine group name from QR code.") - positiveButton { - text = UiText.StringResource(R.string.lbl_ok) - } - } - return - } - - if (SnowbirdGroup.exists(name)) { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - title = UiText.DynamicString("Oops!") - message = UiText.DynamicString("You have already joined this group.") - positiveButton { - text = UiText.StringResource(R.string.lbl_ok) - } - } - return - } - - val action = SnowbirdFragmentDirections - .actionFragmentSnowbirdToFragmentSnowbirdJoinGroup(dwebGroupKey = uriString) - findNavController().navigate(action) - - } - - override fun getToolbarTitle(): String { - return "DWeb Storage" - } -} - -@Composable -fun SnowbirdScreen( - onJoinGroup: () -> Unit = {}, - onCreateGroup: () -> Unit = {}, - onMyGroups: () -> Unit = {}, - onServerToggle: (Boolean) -> Unit = {} -) { - // Observe server status - val serverStatus by SnowbirdService.serviceStatus.collectAsState() - - // Get navigation bar insets for edge-to-edge support - val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues() - - // Use a scrollable Column to mimic ScrollView + LinearLayout - Column( - modifier = Modifier - .fillMaxSize() - .padding(top = 32.dp) - .padding(horizontal = 24.dp) - .padding(bottom = navigationBarPadding.calculateBottomPadding() + 16.dp), - ) { - - // Header texts - SpaceAuthHeader( - description = "Preserve your media on the decentralized web (DWeb) Storage.", - imagePainter = painterResource(R.drawable.ic_dweb), - modifier = Modifier - .padding(vertical = 48.dp) - .padding(end = 24.dp) - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // WebDav option - DwebOptionItem( - title = "Join group", - subtitle = "Connect to existing group", - onClick = onJoinGroup - ) - - DwebOptionItem( - title = "Create group", - subtitle = "Create a new group via Dweb", - onClick = onCreateGroup - ) - - DwebOptionItem( - title = "My groups", - subtitle = "View and manage your groups", - onClick = onMyGroups - ) - - Spacer(modifier = Modifier.weight(1f)) - - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - ) - - // Server Control Section at the bottom using custom preference style - DwebServerPreference( - serverStatus = serverStatus, - onToggle = onServerToggle - ) - - } -} - -@Composable -fun DwebServerPreference( - serverStatus: ServiceStatus, - onToggle: (Boolean) -> Unit -) { - val isServerEnabled = serverStatus !is ServiceStatus.Stopped - val isConnecting = serverStatus is ServiceStatus.Connecting - - // Summary text based on status - val summaryText = when (serverStatus) { - is ServiceStatus.Stopped -> "Enable to share and sync media" - is ServiceStatus.Connecting -> "Connecting..." - is ServiceStatus.Connected -> "Running on localhost:8080" - is ServiceStatus.Failed -> "Failed to start. Try again." - } - - // Custom preference-style UI - Row( - modifier = Modifier - .fillMaxWidth() - .toggleable( - value = isServerEnabled, - enabled = !isConnecting, - role = Role.Switch, - onValueChange = onToggle - ) - .padding(horizontal = 16.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Title and Summary - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = "DWeb Server", - style = SaveTextStyles.bodyLarge, - color = if (!isConnecting) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - } - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = summaryText, - style = SaveTextStyles.bodySmallEmphasis, - color = if (!isConnecting) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) - } - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - // Switch with custom colors - Switch( - checked = isServerEnabled, - onCheckedChange = null, // Handled by toggleable modifier - enabled = !isConnecting, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.surface, - checkedTrackColor = MaterialTheme.colorScheme.tertiary, - uncheckedThumbColor = MaterialTheme.colorScheme.outline, - uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) - } -} - -@Preview -@Composable -private fun SnowbirdScreenPreview() { - DefaultScaffoldPreview { - SnowbirdScreen() - } -} - -@Composable -fun DwebOptionItem( - title: String, - subtitle: String, - onClick: () -> Unit -) { - // You can customize this look to match your original design - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp) - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.background - ), - border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.onBackground), - shape = RoundedCornerShape(8.dp) - ) { - - Row( - modifier = Modifier - .fillMaxWidth() - .height(80.dp) - .padding(16.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - - - Spacer(modifier = Modifier.width(16.dp)) - - Column( - modifier = Modifier - .align(Alignment.Top) - .weight(1f), - verticalArrangement = Arrangement.Center - ) { - Text( - text = title, - fontWeight = FontWeight.SemiBold, - fontSize = 18.sp - ) - - Text( - text = subtitle, - fontWeight = FontWeight.Normal, - fontSize = 14.sp - ) - } - - Icon( - modifier = Modifier - .size(24.dp) - .align(Alignment.CenterVertically), - painter = painterResource(R.drawable.ic_arrow_forward_ios), - contentDescription = null, - ) - } - - - } -} - - -@Composable -fun SpaceAuthHeader( - modifier: Modifier = Modifier, - description: String = stringResource(id = R.string.internet_archive_description), - imagePainter: Painter = painterResource(id = R.drawable.ic_internet_archive) -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Box( - modifier = Modifier - .size(50.dp) - .clip(CircleShape) - .background(ThemeColors.material.surfaceDim,) - .padding(8.dp), - contentAlignment = Alignment.Center - ) { - Image( - modifier = Modifier.size(30.dp), - painter = imagePainter, - contentDescription = "Space Image", - colorFilter = tint(colorResource(id = R.color.colorTertiary)) - ) - } - - Column( - modifier = Modifier.padding(start = ThemeDimensions.spacing.medium, end = ThemeDimensions.spacing.xlarge) - ) { - Text( - text = description, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = ThemeColors.material.onSurfaceVariant, - ) - } - } -} - -@Composable -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun SpaceAuthHeaderPreview() { - SaveAppTheme { - SpaceAuthHeader() - } -} - -@Preview -@Composable -private fun DwebServerPreferencePreview() { - SaveAppTheme { - Column( - modifier = Modifier.fillMaxSize() - ) { - Text("Stopped:", modifier = Modifier.padding(8.dp)) - DwebServerPreference( - serverStatus = ServiceStatus.Stopped, - onToggle = {} - ) - - HorizontalDivider() - - Text("Connecting:", modifier = Modifier.padding(8.dp)) - DwebServerPreference( - serverStatus = ServiceStatus.Connecting, - onToggle = {} - ) - - HorizontalDivider() - - Text("Connected:", modifier = Modifier.padding(8.dp)) - DwebServerPreference( - serverStatus = ServiceStatus.Connected, - onToggle = {} - ) - - HorizontalDivider() - - Text("Failed:", modifier = Modifier.padding(8.dp)) - DwebServerPreference( - serverStatus = ServiceStatus.Failed(Throwable("Failed to start")), - onToggle = {} - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListAdapter.kt deleted file mode 100644 index 884a949e3..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListAdapter.kt +++ /dev/null @@ -1,86 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.OneLineRowBinding -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.db.shortHash -import net.opendasharchive.openarchive.extensions.scaled -import java.lang.ref.WeakReference - -//interface SnowbirdGroupsAdapterListener { -// fun groupSelected(group: SnowbirdGroup) -//} - -class SnowbirdGroupsAdapter( - onClickListener: ((String) -> Unit)? = null, - onLongPressListener: ((String) -> Unit)? = null -) : ListAdapter(DIFF_CALLBACK) { - - private val onClickCallback = WeakReference(onClickListener) - private val onLongPressCallback = WeakReference(onLongPressListener) - - inner class ViewHolder(private val binding: OneLineRowBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(group: SnowbirdGroup?) { - - if (group == null) { - return - } - - val context = binding.button.context - - binding.button.setLeftIcon( - ContextCompat.getDrawable(context, R.drawable.ic_dweb)?.scaled(40, context) - ) - //binding.button.setBackgroundResource(R.drawable.button_outlined_ripple) - binding.button.setTitle(group.name ?: "No name provided") - binding.button.setSubTitle(group.shortHash()) - - binding.button.setOnClickListener { - onClickCallback.get()?.invoke(group.key) - } - - binding.button.setOnLongClickListener { - onLongPressCallback.get()?.invoke(group.key) - true - } - } - } - - companion object { - private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SnowbirdGroup, newItem: SnowbirdGroup): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame( - oldItem: SnowbirdGroup, - newItem: SnowbirdGroup - ): Boolean { - return oldItem.key == newItem.key - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder( - OneLineRowBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val group = getItem(position) - holder.bind(group) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListFragment.kt deleted file mode 100644 index 8cac4bee3..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListFragment.kt +++ /dev/null @@ -1,194 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.core.view.MenuProvider -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.FragmentSnowbirdGroupListBinding -import net.opendasharchive.openarchive.db.SnowbirdError -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.util.SpacingItemDecoration -import timber.log.Timber - -class SnowbirdGroupListFragment : BaseSnowbirdFragment() { - - private lateinit var viewBinding: FragmentSnowbirdGroupListBinding - private lateinit var adapter: SnowbirdGroupsAdapter - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - viewBinding = FragmentSnowbirdGroupListBinding.inflate(inflater) - - return viewBinding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupMenu() - setupSwipeRefresh() - setupRecyclerView() - initializeViewModelObservers() - - snowbirdGroupViewModel.fetchGroups() - } - - private fun setupSwipeRefresh() { - viewBinding.swipeRefreshLayout.setOnRefreshListener { - snowbirdGroupViewModel.fetchGroups(true) - } - - viewBinding.swipeRefreshLayout.setColorSchemeResources( - R.color.colorPrimary, - R.color.colorPrimaryDark - ) - } - - private fun setupMenu() { - requireActivity().addMenuProvider(object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_snowbird, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_add -> { - - val action = - SnowbirdGroupListFragmentDirections.actionFragmentSnowbirdGroupListToFragmentSnowbirdCreateGroup() - findNavController().navigate(action) - true - } - - else -> false - } - } - }, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - private fun setupRecyclerView() { - adapter = SnowbirdGroupsAdapter( - onClickListener = { groupKey -> - onClick(groupKey) - }, - onLongPressListener = { groupKey -> - onLongPress(groupKey) - } - ) - - val spacingInPixels = resources.getDimensionPixelSize(R.dimen.list_item_spacing) - viewBinding.groupList.addItemDecoration(SpacingItemDecoration(spacingInPixels)) - - viewBinding.groupList.layoutManager = LinearLayoutManager(requireContext()) - viewBinding.groupList.adapter = adapter - - viewBinding.groupList.setEmptyView(R.layout.view_empty_state) - } - - private fun onClick(groupKey: String) { - val action = SnowbirdGroupListFragmentDirections.actionFragmentSnowbirdGroupListToFragmentSnowbirdListRepos(groupKey) - findNavController().navigate(action) - } - - private fun onLongPress(groupKey: String) { - AppLogger.d("Long press!") - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Info - title = UiText.DynamicString("Share Group") - message = UiText.DynamicString("Would you like to share this group?") - positiveButton { - text = UiText.DynamicString("Yes") - action = { - val action = SnowbirdGroupListFragmentDirections.actionFragmentSnowbirdGroupListToFragmentSnowbirdShareGroup(groupKey) - findNavController().navigate(action) - } - } - neutralButton { - text = UiText.DynamicString("No") - } - } - } - - private fun initializeViewModelObservers() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - snowbirdGroupViewModel.groupState.collect { state -> - handleGroupStateUpdate(state) - } - } - } - } - } - - override fun handleError(error: SnowbirdError) { - handleLoadingStatus(false) - viewBinding.swipeRefreshLayout.isRefreshing = false - super.handleError(error) - } - - private fun handleGroupStateUpdate(state: SnowbirdGroupViewModel.GroupState) { - when (state) { - is SnowbirdGroupViewModel.GroupState.Loading -> onLoading() - is SnowbirdGroupViewModel.GroupState.MultiGroupSuccess -> onGroupsFetched( - state.groups, - state.isRefresh - ) - - is SnowbirdGroupViewModel.GroupState.Error -> handleError(state.error) - is SnowbirdGroupViewModel.GroupState.SingleGroupSuccess -> { - AppLogger.d("Group fetched: ${state.group}") - // store it - } - else -> Unit - } - } - - private fun onGroupsFetched(groups: List, isRefresh: Boolean) { - handleLoadingStatus(false) - - if (isRefresh) { - Timber.d("Clearing SnowbirdGroups") - SnowbirdGroup.clear() - saveGroups(groups) - } - - adapter.submitList(groups) - } - - private fun onLoading() { - handleLoadingStatus(true) - viewBinding.swipeRefreshLayout.isRefreshing = false - } - - private fun saveGroups(groups: List) { - groups.forEach { group -> - group.save() - } - } - - override fun getToolbarTitle(): String { - return "My Groups" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupOverviewFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupOverviewFragment.kt deleted file mode 100644 index 8dfc64c44..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupOverviewFragment.kt +++ /dev/null @@ -1,22 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import net.opendasharchive.openarchive.databinding.FragmentSnowbirdGroupOverviewBinding -import net.opendasharchive.openarchive.features.core.BaseFragment - -class SnowbirdGroupOverviewFragment private constructor(): BaseSnowbirdFragment() { - private lateinit var viewBinding: FragmentSnowbirdGroupOverviewBinding - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - viewBinding = FragmentSnowbirdGroupOverviewBinding.inflate(inflater) - - return viewBinding.root - } - - override fun getToolbarTitle(): String { - return "DWeb Storage Group Overview" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupRepository.kt deleted file mode 100644 index 2958fdf30..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupRepository.kt +++ /dev/null @@ -1,76 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import net.opendasharchive.openarchive.db.JoinGroupResponse -import net.opendasharchive.openarchive.db.MembershipRequest -import net.opendasharchive.openarchive.db.RequestName -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.extensions.toSnowbirdError -import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI - -interface ISnowbirdGroupRepository { - suspend fun createGroup(groupName: String): SnowbirdResult - suspend fun fetchGroup(groupKey: String): SnowbirdResult - suspend fun fetchGroups(forceRefresh: Boolean = false): SnowbirdResult> - suspend fun joinGroup(uriString: String): SnowbirdResult -} - -class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository { - private var lastFetchTime: Long = 0 - private val cacheValidityPeriod: Long = 5 * 60 * 1000 - - override suspend fun createGroup(groupName: String): SnowbirdResult { - return try { - val response = api.createGroup( - RequestName(groupName) - ) - SnowbirdResult.Success(response) - } catch (e: Exception) { - SnowbirdResult.Error(e.toSnowbirdError()) - } - } - - override suspend fun fetchGroup(groupKey: String): SnowbirdResult { - return try { - val response = api.fetchGroup(groupKey) - SnowbirdResult.Success(response) - } catch (e: Exception) { - SnowbirdResult.Error(e.toSnowbirdError()) - } - } - - override suspend fun fetchGroups(forceRefresh: Boolean): SnowbirdResult> { - val currentTime = System.currentTimeMillis() - val shouldFetchFromNetwork = forceRefresh || currentTime - lastFetchTime > cacheValidityPeriod - - return if (forceRefresh) { - fetchFromNetwork() - } else { - fetchFromCache() - } - } - - override suspend fun joinGroup(uriString: String): SnowbirdResult { - return try { - val response = api.joinGroup( - MembershipRequest(uriString) - ) - SnowbirdResult.Success(response) - } catch (e: Exception) { - e.printStackTrace() - SnowbirdResult.Error(e.toSnowbirdError()) - } - } - - private suspend fun fetchFromNetwork(): SnowbirdResult> { - return try { - val response = api.fetchGroups() - SnowbirdResult.Success(response.groups) - } catch (e: Exception) { - SnowbirdResult.Error(e.toSnowbirdError()) - } - } - - private fun fetchFromCache(): SnowbirdResult> { - return SnowbirdResult.Success(SnowbirdGroup.getAll()) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupViewModel.kt deleted file mode 100644 index daf24bcee..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupViewModel.kt +++ /dev/null @@ -1,111 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.app.Application -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.db.JoinGroupResponse -import net.opendasharchive.openarchive.db.SnowbirdError -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.util.BaseViewModel -import net.opendasharchive.openarchive.util.trackProcessingWithTimeout - -class SnowbirdGroupViewModel( - application: Application, - private val repository: ISnowbirdGroupRepository -) : BaseViewModel(application) { - - sealed class GroupState { - data object Idle : GroupState() - data object Loading : GroupState() - data class JoinGroupSuccess(val group: JoinGroupResponse) : GroupState() - data class SingleGroupSuccess(val group: SnowbirdGroup) : GroupState() - data class MultiGroupSuccess(val groups: List, val isRefresh: Boolean) : GroupState() - data class Error(val error: SnowbirdError) : GroupState() - } - - private val _groupState = MutableStateFlow(GroupState.Idle) - val groupState: StateFlow = _groupState.asStateFlow() - - private val _currentGroup = MutableStateFlow(null) - val currentGroup: StateFlow = _currentGroup.asStateFlow() - - fun setCurrentGroup(group: SnowbirdGroup) { - _currentGroup.value = group - } - - fun fetchGroup(groupKey: String) { - viewModelScope.launch { - _groupState.value = GroupState.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(60_000, "fetch_group") { - repository.fetchGroup(groupKey) - } - - _groupState.value = when (result) { - is SnowbirdResult.Success -> GroupState.SingleGroupSuccess(result.value) - is SnowbirdResult.Error -> GroupState.Error(result.error) - } - } catch (e: TimeoutCancellationException) { - _groupState.value = GroupState.Error(SnowbirdError.TimedOut) - } - } - } - - fun fetchGroups(forceRefresh: Boolean = false) { - viewModelScope.launch { - _groupState.value = GroupState.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(60_000, "fetch_groups") { - repository.fetchGroups(forceRefresh) - } - - _groupState.value = when (result) { - is SnowbirdResult.Success -> GroupState.MultiGroupSuccess(result.value, forceRefresh) - is SnowbirdResult.Error -> GroupState.Error(result.error) - } - } catch (e: TimeoutCancellationException) { - _groupState.value = GroupState.Error(SnowbirdError.TimedOut) - } - } - } - - fun createGroup(groupName: String) { - viewModelScope.launch { - _groupState.value = GroupState.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(60_000, "create_group") { - repository.createGroup(groupName) - } - - _groupState.value = when (result) { - is SnowbirdResult.Success -> GroupState.SingleGroupSuccess(result.value) - is SnowbirdResult.Error -> GroupState.Error(result.error) - } - } catch (e: TimeoutCancellationException) { - _groupState.value = GroupState.Error(SnowbirdError.TimedOut) - } - } - } - - fun joinGroup(uriString: String) { - viewModelScope.launch { - _groupState.value = GroupState.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(60_000, "join_group") { - repository.joinGroup(uriString) - } - - _groupState.value = when (result) { - is SnowbirdResult.Success -> GroupState.JoinGroupSuccess(result.value) - is SnowbirdResult.Error -> GroupState.Error(result.error) - } - } catch (e: TimeoutCancellationException) { - _groupState.value = GroupState.Error(SnowbirdError.TimedOut) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdJoinGroupFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdJoinGroupFragment.kt deleted file mode 100644 index 4bea58c02..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdJoinGroupFragment.kt +++ /dev/null @@ -1,208 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentSnowbirdJoinGroupBinding -import net.opendasharchive.openarchive.db.SnowbirdError -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.db.SnowbirdRepo -import net.opendasharchive.openarchive.extensions.getQueryParameter -import net.opendasharchive.openarchive.extensions.showKeyboard -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.util.FullScreenOverlayCreateGroupManager -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets -import timber.log.Timber - -class SnowbirdJoinGroupFragment: BaseSnowbirdFragment() { - - private lateinit var binding: FragmentSnowbirdJoinGroupBinding - private lateinit var uriString: String - private lateinit var groupName: String - private lateinit var repoName: String - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - arguments?.let { - uriString = it.getString(DWEB_GROUP_KEY, "") - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentSnowbirdJoinGroupBinding.inflate(inflater) - - - binding.buttonBar.applyEdgeToEdgeInsets(WindowInsetsCompat.Type.navigationBars()) { insets -> - bottomMargin = insets.bottom - } - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - groupName = uriString.getQueryParameter("name") ?: "Unknown group" - - Timber.d("uriString = $uriString") - Timber.d("groupName = $groupName") - - binding.groupNameTextfield.setText(groupName) - - setupViewModelObservers() - setupSideEffects() - setupTextWatchers() - } - - private fun setupViewModelObservers() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { snowbirdGroupViewModel.groupState.collect { state -> onGroupStateUpdate(state) } } - launch { snowbirdRepoViewModel.repoState.collect { state -> onRepoStateUpdate(state) } } - } - } - } - - override fun handleError(error: SnowbirdError) { - handleCreateGroupLoadingStatus(false) - super.handleError(error) - } - - private fun onGroupStateUpdate(state: SnowbirdGroupViewModel.GroupState) { - Timber.d("state = $state") - when (state) { - is SnowbirdGroupViewModel.GroupState.Loading -> onLoading() - is SnowbirdGroupViewModel.GroupState.JoinGroupSuccess -> onJoinSuccess(state.group.group) - is SnowbirdGroupViewModel.GroupState.Error -> handleError(state.error) - else -> Unit - } - } - - private fun onRepoStateUpdate(state: SnowbirdRepoViewModel.RepoState) { - Timber.d("state = $state") - when (state) { - is SnowbirdRepoViewModel.RepoState.Loading -> onLoading() - is SnowbirdRepoViewModel.RepoState.SingleRepoSuccess -> onRepoCreated(state.groupKey, state.repo) - is SnowbirdRepoViewModel.RepoState.Error -> handleError(state.error) - else -> Unit - } - } - - private fun onJoinSuccess(group: SnowbirdGroup) { - // Group name doesn't come back from backend by default so - // we poke it in here. - // - group.name = groupName - group.save() - snowbirdRepoViewModel.createRepo(group.key, repoName) - } - - private fun onLoading() { - handleCreateGroupLoadingStatus(true) - } - - private fun handleCreateGroupLoadingStatus(isLoading: Boolean) { - if (isLoading) { - FullScreenOverlayCreateGroupManager.show(this@SnowbirdJoinGroupFragment) - } else { - FullScreenOverlayCreateGroupManager.hide() - } - } - - private fun onRepoCreated(groupKey: String, repo: SnowbirdRepo) { - repo.permissions = "READ_WRITE" - repo.groupKey = groupKey - repo.save() - handleCreateGroupLoadingStatus(false) - snowbirdRepoViewModel.fetchRepos(groupKey, false) - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Success - title = UiText.StringResource(R.string.label_success_title) - message = UiText.DynamicString("Successfully joined") - positiveButton { - text = UiText.StringResource(R.string.label_got_it) - action = { - - val action = SnowbirdJoinGroupFragmentDirections.actionFragmentSnowbirdJoinGroupToFragmentSnowbirdSetupSuccess( - message = getString(R.string.you_have_successfully_joined_dweb), - dwebGroupKey = groupKey - ) - - findNavController().navigate(action) - } - } - } - } - - private fun setupSideEffects() { - binding.repoNameTextfield.post { - binding.repoNameTextfield.showKeyboard() - } - - binding.btnNext.setOnClickListener { - repoName = binding.repoNameTextfield.text?.toString().orEmpty() - - if (repoName.isBlank()) { - binding.repoNameTextfield.error = "Repository name cannot be empty" - } else { - snowbirdGroupViewModel.joinGroup(uriString) - dismissKeyboard(it) - } - } - - binding.btnCancel.setOnClickListener { - findNavController().popBackStack() - } - } - - private fun setupTextWatchers() { - // Create a common TextWatcher for all three fields - val textWatcher = object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - updateAuthenticateButtonState() - } - - override fun afterTextChanged(s: Editable?) { - dismissCredentialsError() - } - } - - binding.groupNameTextfield.addTextChangedListener(textWatcher) - binding.repoNameTextfield.addTextChangedListener(textWatcher) - } - - private fun updateAuthenticateButtonState() { - val groupName = binding.groupNameTextfield.text?.toString()?.trim().orEmpty() - val repoName = binding.repoNameTextfield.text?.toString()?.trim().orEmpty() - - // Enable the button only if none of the fields are empty - binding.btnNext.isEnabled = groupName.isNotEmpty() && repoName.isNotEmpty() - } - - private fun dismissCredentialsError() { - //binding.errorHint.hide() - } - - companion object { - const val DWEB_GROUP_KEY = "dweb_group_key" - } - - override fun getToolbarTitle(): String { - return "Join Group" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoListAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoListAdapter.kt deleted file mode 100644 index 5024df823..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoListAdapter.kt +++ /dev/null @@ -1,75 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.OneLineRowBinding -import net.opendasharchive.openarchive.db.SnowbirdRepo -import net.opendasharchive.openarchive.db.shortHash -import net.opendasharchive.openarchive.extensions.scaled -import net.opendasharchive.openarchive.util.TwoLetterDrawable -import java.lang.ref.WeakReference - -class SnowbirdRepoListAdapter(listener: ((String) -> Unit)? = null) - : ListAdapter(DIFF_CALLBACK) { - - inner class SnowbirdRepoListViewHolder(private val binding: OneLineRowBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(repo: SnowbirdRepo?) { - if (repo == null) { - return - } - - val context = binding.button.context - - binding.button.setLeftIcon(ContextCompat.getDrawable(context, R.drawable.ic_dweb)?.scaled(40, context)) - //binding.button.setBackgroundResource(R.drawable.button_outlined_ripple) - binding.button.setTitle(repo.name) - binding.button.setSubTitle(repo.shortHash()) - - if (repo.permissions == "READ_ONLY") { - binding.button.setRightIcon(TwoLetterDrawable.ReadOnly(context)) - } else { - binding.button.setRightIcon(TwoLetterDrawable.ReadWrite(context)) - } - - binding.button.setOnClickListener { - mListener.get()?.invoke(repo.key) - } - } - } - - companion object { - private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SnowbirdRepo, newItem: SnowbirdRepo): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: SnowbirdRepo, newItem: SnowbirdRepo): Boolean { - return oldItem.key == newItem.key - } - } - } - - private val mListener = WeakReference(listener) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SnowbirdRepoListViewHolder { - return SnowbirdRepoListViewHolder( - OneLineRowBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onBindViewHolder(holder: SnowbirdRepoListViewHolder, position: Int) { - val repo = getItem(position) - holder.bind(repo) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoListFragment.kt deleted file mode 100644 index 0d1659862..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoListFragment.kt +++ /dev/null @@ -1,201 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.core.view.MenuProvider -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentSnowbirdListReposBinding -import net.opendasharchive.openarchive.db.SnowbirdError -import net.opendasharchive.openarchive.db.SnowbirdRepo -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.util.SpacingItemDecoration -import timber.log.Timber - -class SnowbirdRepoListFragment : BaseSnowbirdFragment() { - - private lateinit var viewBinding: FragmentSnowbirdListReposBinding - private lateinit var adapter: SnowbirdRepoListAdapter - private lateinit var groupKey: String - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - arguments?.let { - groupKey = it.getString(RESULT_VAL_RAVEN_GROUP_KEY, "") - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - viewBinding = FragmentSnowbirdListReposBinding.inflate(inflater) - - return viewBinding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupMenu() - setupSwipeRefresh() - setupViewModel() - initializeViewModelObservers() - } - - private fun handleRepoStateUpdate(state: SnowbirdRepoViewModel.RepoState) { - when (state) { - is SnowbirdRepoViewModel.RepoState.Loading -> handleLoadingStatus(true) - is SnowbirdRepoViewModel.RepoState.RepoFetchSuccess -> handleRepoUpdate( - state.repos, - state.isRefresh - ) - is SnowbirdRepoViewModel.RepoState.Error -> handleError(state.error) - is SnowbirdRepoViewModel.RepoState.RefreshGroupContentSuccess -> handleLoadingStatus(false) - else -> Unit - } - } - - private fun initializeViewModelObservers() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - snowbirdRepoViewModel.repoState.collect { state -> - handleRepoStateUpdate( - state - ) - } - } - launch { snowbirdRepoViewModel.fetchRepos(groupKey, forceRefresh = false) } - } - } - } - - private fun setupMenu() { - requireActivity().addMenuProvider(object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_snowbird, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_add -> { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Warning - title = UiText.DynamicString("Oops!") - message = UiText.DynamicString("Feature not implemented yet.") - positiveButton { - text = UiText.StringResource(R.string.lbl_ok) - } - } - true - } - - else -> false - } - } - }, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - private fun setupViewModel() { - - adapter = SnowbirdRepoListAdapter { repoKey -> - val action = - SnowbirdRepoListFragmentDirections.actionFragmentSnowbirdListReposToFragmentSnowbirdListMedia( - dwebGroupKey = groupKey, - dwebRepoKey = repoKey - ) - findNavController().navigate(action) - } - - val spacingInPixels = resources.getDimensionPixelSize(R.dimen.list_item_spacing) - viewBinding.repoList.addItemDecoration(SpacingItemDecoration(spacingInPixels)) - - viewBinding.repoList.layoutManager = LinearLayoutManager(requireContext()) - viewBinding.repoList.adapter = adapter - - viewBinding.repoList.setEmptyView(R.layout.view_empty_state) - } - - private fun handleRepoUpdate(repos: List, isRefresh: Boolean) { - handleLoadingStatus(false) - - if (isRefresh) { - Timber.d("Clearing SnowbirdRepos for group $groupKey") - SnowbirdRepo.clear(groupKey) - saveRepos(repos) - } - - adapter.submitList(repos) - - if (isRefresh && repos.isEmpty()) { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Info - title = UiText.StringResource(R.string.label_info_title) - message = UiText.DynamicString("No new repositories found.") - positiveButton { - text = UiText.StringResource(R.string.label_got_it) - action = { - parentFragmentManager.popBackStack() - } - } - } - } - } - - override fun handleError(error: SnowbirdError) { - handleLoadingStatus(false) - viewBinding.swipeRefreshLayout.isRefreshing = false - super.handleError(error) - } - - override fun handleLoadingStatus(isLoading: Boolean) { - super.handleLoadingStatus(isLoading) - viewBinding.swipeRefreshLayout.isRefreshing = false - } - - private fun saveRepos(repos: List) { - repos.forEach { repo -> - repo.groupKey = groupKey - repo.save() - } - } - - private fun setupSwipeRefresh() { - viewBinding.swipeRefreshLayout.setOnRefreshListener { - lifecycleScope.launch { - //snowbirdRepoViewModel.fetchRepos(groupKey, forceRefresh = true) - snowbirdRepoViewModel.refreshGroups(groupKey) - } - } - - viewBinding.swipeRefreshLayout.setColorSchemeResources( - R.color.colorPrimary, R.color.colorPrimaryDark - ) - } - - override fun getToolbarTitle(): String { - return "Repositories" - } - - - companion object { - const val RESULT_VAL_RAVEN_GROUP_KEY = "dweb_group_key" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoRepository.kt deleted file mode 100644 index 0046f1e53..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoRepository.kt +++ /dev/null @@ -1,66 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import net.opendasharchive.openarchive.db.RefreshGroupResponse -import net.opendasharchive.openarchive.db.RequestName -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.db.SnowbirdRepo -import net.opendasharchive.openarchive.db.toRepo -import net.opendasharchive.openarchive.extensions.toSnowbirdError -import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI -import timber.log.Timber - -interface ISnowbirdRepoRepository { - suspend fun createRepo(groupKey: String, repoName: String): SnowbirdResult - suspend fun fetchRepos(groupKey: String, forceRefresh: Boolean = false): SnowbirdResult> - suspend fun refreshGroupContent(groupKey: String): SnowbirdResult -} - -class SnowbirdRepoRepository(val api: ISnowbirdAPI) : ISnowbirdRepoRepository { - - override suspend fun createRepo(groupKey: String, repoName: String): SnowbirdResult { - Timber.d("Creating repo: groupKey=$groupKey, repoName=$repoName") - - return try { - val response = api.createRepo(groupKey, RequestName(repoName)) - val repo = response.toRepo(groupKey) - SnowbirdResult.Success(repo) - } catch (e: Exception) { - SnowbirdResult.Error(e.toSnowbirdError()) - } - } - - override suspend fun fetchRepos(groupKey: String, forceRefresh: Boolean): SnowbirdResult> { - return if (forceRefresh) { - fetchFromNetwork(groupKey) - } else { - fetchFromCache(groupKey) - } - } - - private suspend fun fetchFromNetwork(groupKey: String): SnowbirdResult> { - return try { - val response = api.fetchRepos(groupKey) - val repoList = response.repos.map { it.toRepo(groupKey) } - SnowbirdResult.Success(repoList) - } catch (e: Exception) { - SnowbirdResult.Error(e.toSnowbirdError()) - } - } - - private fun fetchFromCache(groupKey: String): SnowbirdResult> { - return SnowbirdResult.Success(SnowbirdRepo.getAllFor(SnowbirdGroup.get(groupKey))) - } - - override suspend fun refreshGroupContent(groupKey: String): SnowbirdResult { - return try { - val response = api.refreshGroupContent(groupKey) - SnowbirdResult.Success(response) - } catch (e: Exception) { - SnowbirdResult.Error(e.toSnowbirdError()) - } - } -} - - diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoViewModel.kt deleted file mode 100644 index 96c88042d..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoViewModel.kt +++ /dev/null @@ -1,148 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.app.Application -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.db.RefreshGroupResponse -import net.opendasharchive.openarchive.db.SnowbirdError -import net.opendasharchive.openarchive.db.SnowbirdFileItem -import net.opendasharchive.openarchive.db.SnowbirdRepo -import net.opendasharchive.openarchive.db.toRepo -import net.opendasharchive.openarchive.util.BaseViewModel -import net.opendasharchive.openarchive.util.trackProcessingWithTimeout - -class SnowbirdRepoViewModel( - application: Application, - private val repository: ISnowbirdRepoRepository -) : BaseViewModel(application) { - - sealed class RepoState { - data object Idle : RepoState() - data object Loading : RepoState() - data class SingleRepoSuccess(val groupKey: String, val repo: SnowbirdRepo) : RepoState() - data class MultiRepoSuccess(val repos: List) : RepoState() - data class RepoFetchSuccess(val repos: List, val isRefresh: Boolean) : RepoState() - data object RefreshGroupContentSuccess: RepoState() - data class Error(val error: SnowbirdError) : RepoState() - } - - private val _repoState = MutableStateFlow(RepoState.Idle) - val repoState: StateFlow = _repoState.asStateFlow() - - fun createRepo(groupKey: String, repoName: String) { - viewModelScope.launch { - _repoState.value = RepoState.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(60_000, "create_repo") { - repository.createRepo(groupKey, repoName) - } - - _repoState.value = when (result) { - is SnowbirdResult.Success -> RepoState.SingleRepoSuccess(groupKey, result.value) - is SnowbirdResult.Error -> RepoState.Error(result.error) - } - } catch (e: TimeoutCancellationException) { - _repoState.value = RepoState.Error(SnowbirdError.TimedOut) - } - } - } - - fun fetchRepos(groupKey: String, forceRefresh: Boolean = false) { - viewModelScope.launch { - _repoState.value = RepoState.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(60_000, "fetch_repos") { - repository.fetchRepos(groupKey, forceRefresh) - } - - _repoState.value = when (result) { - is SnowbirdResult.Success -> RepoState.RepoFetchSuccess( - result.value, - forceRefresh - ) - - is SnowbirdResult.Error -> RepoState.Error(result.error) - } - } catch (e: TimeoutCancellationException) { - _repoState.value = RepoState.Error(SnowbirdError.TimedOut) - } - } - } - - fun refreshGroups(groupKey: String) { - viewModelScope.launch { - _repoState.value = RepoState.Loading - try { - val result = processingTracker.trackProcessingWithTimeout(120_000, "fetch_groups") { - repository.refreshGroupContent(groupKey) - } - - when (result) { - is SnowbirdResult.Error -> { - AppLogger.e(result.error.friendlyMessage) - _repoState.value = RepoState.Error(result.error) - } - - is SnowbirdResult.Success -> { - AppLogger.i("Group content refreshed successfully") - //TODO: Save Repo List and Media List to DB - - // Get existing repos for group - val existingRepos = SnowbirdRepo.getAllForGroupKey(groupKey) - val existingReposMap = existingRepos.associateBy { it.key } - - result.value.refreshedRepos.forEach { repoData -> - - // Log repo errors if any - if (!repoData.error.isNullOrEmpty()) { - AppLogger.e("Error refreshing repo ${repoData.repoId}: ${repoData.error}") - } - - // Update or create repo - val snowbirdRepo = existingReposMap[repoData.repoId] ?: repoData.toRepo().apply { - this.groupKey = groupKey - } - snowbirdRepo.apply { - name = repoData.name - hash = repoData.hash ?: hash - permissions = if (repoData.canWrite) "READ_WRITE" else "READ_ONLY" - }.save() - - // Get existing files for this repo - val existingFiles = SnowbirdFileItem.findBy(groupKey, repoData.repoId) - val existingFilesMap = existingFiles.associateBy { it.name } - - // Process all files (not just refreshed ones) - repoData.allFiles.forEach { fileName -> - val existingFile = existingFilesMap[fileName] - - if (existingFile == null) { - // Create new file if it doesn't exist - SnowbirdFileItem( - name = fileName, - repoKey = repoData.repoId, - groupKey = groupKey, - ).save() - } else { - // Update existing file without overwriting with null - // Note: The refresh API doesn't provide file details, - // so we just maintain the existing file record - } - } - } - _repoState.value = RepoState.RefreshGroupContentSuccess - fetchRepos(groupKey = groupKey) - } - } - - } catch (e: TimeoutCancellationException) { - _repoState.value = RepoState.Error(SnowbirdError.TimedOut) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdResult.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdResult.kt deleted file mode 100644 index ad55ff895..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdResult.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import net.opendasharchive.openarchive.db.SnowbirdError - -sealed class SnowbirdResult { - data class Success(val value: T) : SnowbirdResult() - data class Error(val error: SnowbirdError) : SnowbirdResult() -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdSetupSuccessFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdSetupSuccessFragment.kt deleted file mode 100644 index d7a82575d..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdSetupSuccessFragment.kt +++ /dev/null @@ -1,95 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.MenuProvider -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.navigation.navOptions -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentSpaceSetupSuccessBinding -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets - -class SnowbirdSetupSuccessFragment : BaseSnowbirdFragment() { - - private lateinit var binding: FragmentSpaceSetupSuccessBinding - private val args: SnowbirdSetupSuccessFragmentArgs by navArgs() - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentSpaceSetupSuccessBinding.inflate(inflater) - - binding.mainContainer.applyEdgeToEdgeInsets( - typeMask = WindowInsetsCompat.Type.navigationBars() - ) { insets -> - bottomMargin = insets.bottom - } - - binding.buttonBar.applyEdgeToEdgeInsets( - typeMask = WindowInsetsCompat.Type.navigationBars() - ) { insets -> - bottomMargin = insets.bottom - } - - if (args.message.isNotEmpty()) { - binding.successMessage.text = args.message - } - - binding.btAuthenticate.setOnClickListener { _ -> - val navController = findNavController() - // Navigate to Snowbird Dashboard and clear this success screen from back stack - navController.navigate( - R.id.snowbird_dashboard, - null, - navOptions { - // Clear the entire Snowbird graph so dashboard becomes the new root - popUpTo(R.id.snowbird_nav_graph) { inclusive = true } - launchSingleTop = true - } - ) - } - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - - // Add the menu provider - requireActivity().addMenuProvider(object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_space_setup_success, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_share_group -> { - val action = SnowbirdSetupSuccessFragmentDirections.actionFragmentSnowbirdSetupSuccessToFragmentSnowbirdShareGroup( - dwebGroupKey = args.dwebGroupKey, - isSetupOngoing = true - ) - - findNavController().navigate(action) - true - } - - else -> false - } - } - }, viewLifecycleOwner) - - } - - override fun getToolbarTitle() = getString(R.string.space_setup_success_title) - override fun shouldShowBackButton() = false - -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdShareFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdShareFragment.kt deleted file mode 100644 index 1420b0a24..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdShareFragment.kt +++ /dev/null @@ -1,50 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.navigation.fragment.navArgs -import net.opendasharchive.openarchive.databinding.FragmentSnowbirdShareGroupBinding -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.extensions.asQRCode -import net.opendasharchive.openarchive.extensions.urlEncode -import net.opendasharchive.openarchive.features.core.BaseFragment - -class SnowbirdShareFragment: BaseSnowbirdFragment() { - - private lateinit var binding: FragmentSnowbirdShareGroupBinding - private var isSetupOngoing: Boolean = false - - private val args: SnowbirdShareFragmentArgs by navArgs() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentSnowbirdShareGroupBinding.inflate(inflater) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - if (isSetupOngoing) { - binding.buttonBar.visibility = View.VISIBLE - } else { - binding.buttonBar.visibility = View.GONE - } - - val group = SnowbirdGroup.get(args.dwebGroupKey) - val groupName = group?.name ?: "Unknown group" - - binding.groupName.text = groupName - - SnowbirdGroup.get(args.dwebGroupKey)?.uri?.let { uriString -> - val qrCode = "$uriString&name=${groupName.urlEncode()}".asQRCode(size = 1024) - binding.qrCode.setImageBitmap(qrCode) - } - } - - override fun getToolbarTitle(): String { - return "Share DWeb Storage Group" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdDTOs.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdDTOs.kt new file mode 100644 index 000000000..61e7cec40 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdDTOs.kt @@ -0,0 +1,101 @@ +package net.opendasharchive.openarchive.services.snowbird.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SnowbirdGroupDTO( + val key: String, + val name: String? = null, + val uri: String? = null +) + +@Serializable +data class SnowbirdGroupListDTO( + val groups: List +) + +@Serializable +data class SnowbirdRepoDTO( + val key: String, + val name: String? = null, + @SerialName("can_write") + val canWrite: Boolean? = null, +) + +@Serializable +data class SnowbirdRepoListDTO( + val repos: List +) + +@Serializable +data class SnowbirdFileDTO( + val name: String, + val hash: String = "", + @SerialName("is_downloaded") + val isDownloaded: Boolean = false, + val size: Long = 0, + val mimeType: String? = null, + @SerialName("created_at") + val createdAt: String? = null +) + +@Serializable +data class SnowbirdFileListDTO( + val files: List +) + +@Serializable +data class FileUploadResult( + val name: String, + @SerialName("updated_collection_hash") + val updatedCollectionHash: String, + @SerialName("file_hash") + val fileHash: String? = null +) + +@Serializable +data class CreateRepoResponse( + val key: String, + val name: String? = null +) + +@Serializable +data class JoinGroupResponse( + val group: SnowbirdGroupDTO +) + +@Serializable +data class RefreshGroupResponse( + @SerialName("repos") + val refreshedRepos: List = emptyList(), + val status: String? = null, + val success: Boolean? = null +) + +@Serializable +data class RefreshedRepo( + @SerialName("repo_id") + val repoId: String, + @SerialName("repo_hash") + val hash: String? = null, + val name: String, + @SerialName("can_write") + val canWrite: Boolean = false, + @SerialName("all_files") + val allFiles: List = emptyList(), + @SerialName("refreshed_files") + val refreshedFiles: List = emptyList(), + @SerialName("error") + val error: String? = null +) + +@Serializable +data class RequestName( + val name: String +) + +@Serializable +data class MembershipRequest( + val uri: String +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdMappers.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdMappers.kt new file mode 100644 index 000000000..5e614f79a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/data/SnowbirdMappers.kt @@ -0,0 +1,150 @@ +package net.opendasharchive.openarchive.services.snowbird.data + +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.ArchivePermission +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.db.ArchiveDwebEntity +import net.opendasharchive.openarchive.db.ArchiveEntity +import net.opendasharchive.openarchive.db.ArchiveWithDweb +import net.opendasharchive.openarchive.db.EvidenceDwebEntity +import net.opendasharchive.openarchive.db.EvidenceEntity +import net.opendasharchive.openarchive.db.EvidenceWithDweb +import net.opendasharchive.openarchive.db.VaultDwebEntity +import net.opendasharchive.openarchive.db.VaultEntity +import net.opendasharchive.openarchive.db.VaultWithDweb +import net.opendasharchive.openarchive.util.DateUtils + +fun SnowbirdGroupDTO.toVaultEntity(id: Long = 0): VaultEntity { + return VaultEntity( + type = VaultType.DWEB_STORAGE, + name = name ?: "Untitled Group", + host = uri ?: "", // Snowbird uses URI as host + username = "", + displayName = name ?: "", + id = id, + metaData = "", + licenseUrl = null, + createdAt = DateUtils.nowDateTime + ) +} + +fun SnowbirdGroupDTO.toDwebEntity(vaultId: Long): VaultDwebEntity { + return VaultDwebEntity( + vaultId = vaultId, + vaultKey = key + ) +} + +fun VaultWithDweb.toDomain(): Vault { + return Vault( + id = vault.id, + type = vault.type, + name = vault.name, + username = vault.username, + displayName = vault.displayName, + host = vault.host, + vaultKey = dwebMetadata?.vaultKey + ) +} + +fun SnowbirdRepoDTO.toArchiveEntity(vaultId: Long, submissionId: Long, id: Long = 0): ArchiveEntity { + return ArchiveEntity( + id = id, + vaultId = vaultId, + description = name ?: "Untitled Repo", + createdAt = DateUtils.nowDateTime, + isRemote = true, + archived = false, + openSubmissionId = submissionId, + licenseUrl = null, + ) +} + +fun SnowbirdRepoDTO.toDwebEntity(archiveId: Long): ArchiveDwebEntity { + return ArchiveDwebEntity( + archiveId = archiveId, + archiveKey = key, + archiveHash = "", // Initialize as empty + permissions = if (canWrite == true) ArchivePermission.READ_WRITE else ArchivePermission.READ_ONLY + ) +} + +fun ArchiveWithDweb.toDomain(): Archive { + return Archive( + id = archive.id, + description = archive.description ?: "", + created = archive.createdAt ?: DateUtils.nowDateTime, + vaultId = archive.vaultId, + licenseUrl = archive.licenseUrl, + isRemote = archive.isRemote, + isArchived = archive.archived, + archiveKey = dwebMetadata?.archiveKey, + permissions = dwebMetadata?.permissions + ) +} + +fun SnowbirdFileDTO.toEvidenceEntity(archiveId: Long, submissionId: Long, id: Long = 0): EvidenceEntity { + return EvidenceEntity( + id = id, + originalFilePath = "", // Remote file + mimeType = mimeType ?: "application/octet-stream", + createdAt = createdAt?.let { DateUtils.parseDateTime(it) } ?: DateUtils.nowDateTime, + updatedAt = DateUtils.nowDateTime, + uploadedAt = DateUtils.nowDateTime, + serverUrl = "", + title = name, + description = "", + author = "", + location = "", + tags = "", + licenseUrl = null, + mediaHashString = hash, + status = EvidenceStatus.UPLOADED, + statusMessage = "Remote", + archiveId = archiveId, + submissionId = submissionId, + contentLength = size, + progress = 100, + flag = false, + priority = 0 + ) +} + +fun SnowbirdFileDTO.toDwebEntity(evidenceId: Long): EvidenceDwebEntity { + return EvidenceDwebEntity( + evidenceId = evidenceId, + isDownloaded = isDownloaded + ) +} + +fun EvidenceWithDweb.toDomain(): Evidence { + return Evidence( + id = evidence.id, + originalFilePath = evidence.originalFilePath, + thumbnail = evidence.thumbnail, + mimeType = evidence.mimeType, + createdAt = evidence.createdAt ?: DateUtils.nowDateTime, + updatedAt = evidence.updatedAt ?: DateUtils.nowDateTime, + uploadedAt = evidence.uploadedAt, + serverUrl = evidence.serverUrl, + title = evidence.title, + description = evidence.description, + author = evidence.author, + location = evidence.location, + tags = if (evidence.tags.isEmpty()) emptyList() else evidence.tags.split(";"), + licenseUrl = evidence.licenseUrl, + mediaHashString = evidence.mediaHashString, + status = evidence.status, + statusMessage = evidence.statusMessage, + archiveId = evidence.archiveId, + submissionId = evidence.submissionId, + contentLength = evidence.contentLength, + progress = evidence.progress, + isFlagged = evidence.flag, + priority = evidence.priority, + isDownloaded = dwebMetadata?.isDownloaded ?: false + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/SnowbirdNavGraph.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/SnowbirdNavGraph.kt new file mode 100644 index 000000000..207bfda02 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/SnowbirdNavGraph.kt @@ -0,0 +1,218 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation + +import android.content.Intent +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.EntryProviderScope +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.navigation.NavigationResultKeys +import net.opendasharchive.openarchive.core.navigation.ResultEventBus +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.features.settings.passcode.components.DefaultScaffold +import net.opendasharchive.openarchive.services.snowbird.presentation.dashboard.SnowbirdDashboardScreen +import net.opendasharchive.openarchive.services.snowbird.presentation.dashboard.SnowbirdDashboardViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.file.SnowbirdFileAction +import net.opendasharchive.openarchive.services.snowbird.presentation.file.SnowbirdFileListScreen +import net.opendasharchive.openarchive.services.snowbird.presentation.file.SnowbirdFileViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdCreateGroupScreen +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdCreateGroupViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdGroupListScreen +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdGroupListViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdJoinGroupScreen +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdJoinGroupViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdShareScreen +import net.opendasharchive.openarchive.services.snowbird.presentation.group.SnowbirdShareViewModel +import net.opendasharchive.openarchive.services.snowbird.presentation.qrscanner.QRScannerScreen +import net.opendasharchive.openarchive.services.snowbird.presentation.repo.SnowbirdRepoAction +import net.opendasharchive.openarchive.services.snowbird.presentation.repo.SnowbirdRepoListScreen +import net.opendasharchive.openarchive.services.snowbird.presentation.repo.SnowbirdRepoViewModel +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +/** + * Snowbird feature navigation entries. + * Groups all DWeb/Snowbird screen entries for use in [net.opendasharchive.openarchive.features.main.ui.SaveNavGraph]. + */ +fun EntryProviderScope.snowbirdEntries( + navigator: Navigator +) { + entry { route -> + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.dweb_storage), + onNavigateBack = { navigator.navigateBack() } + ) { + SnowbirdDashboardScreen( + viewModel = viewModel + ) + } + } + + entry { route -> + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.dweb_create_group), + onNavigateBack = { navigator.navigateBack() } + ) { + SnowbirdCreateGroupScreen( + viewModel = viewModel + ) + } + } + + entry { route -> + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.dweb_join_group), + onNavigateBack = { navigator.navigateBack() } + ) { + SnowbirdJoinGroupScreen( + viewModel = viewModel + ) + } + } + + entry { route -> + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.dweb_my_groups), + onNavigateBack = { navigator.navigateBack() } + ) { + SnowbirdGroupListScreen( + viewModel = viewModel + ) + } + } + + entry { route -> + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + val context = androidx.compose.ui.platform.LocalContext.current + val state by viewModel.uiState.collectAsStateWithLifecycle() + + DefaultScaffold( + title = stringResource(id = R.string.dweb_share_group), + onNavigateBack = { navigator.navigateBack() }, + actions = { + TextButton( + enabled = state.qrContent.isNotBlank() && !state.isLoading, + onClick = { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, state.groupName) + putExtra(Intent.EXTRA_TEXT, state.qrContent) + } + context.startActivity(Intent.createChooser(shareIntent, state.groupName)) + } + ) { + Text(text = "Share", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + ) { + SnowbirdShareScreen( + viewModel = viewModel, + onShareQr = { qrContent, groupName -> + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, groupName) + putExtra(Intent.EXTRA_TEXT, qrContent) + } + context.startActivity(Intent.createChooser(shareIntent, groupName)) + } + ) + } + } + + entry { route -> + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.dweb_repos), + onNavigateBack = { navigator.navigateBack() }, + actions = { + IconButton( + onClick = { + viewModel.onAction(SnowbirdRepoAction.RefreshGroupContent) + } + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.refresh), + contentDescription = stringResource(R.string.refresh) + ) + } + } + ) { + SnowbirdRepoListScreen( + viewModel = viewModel + ) + } + } + + entry { route -> + val viewModel = koinViewModel { + parametersOf(navigator, route) + } + + DefaultScaffold( + title = stringResource(id = R.string.dweb_files), + onNavigateBack = { viewModel.onAction(SnowbirdFileAction.NavigateBack) }, + actions = { + if (route.canWrite) { + IconButton(onClick = { viewModel.onAction(SnowbirdFileAction.ShowContentPicker) }) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_add), + contentDescription = "Add Files" + ) + } + } + } + ) { + SnowbirdFileListScreen( + viewModel = viewModel + ) + } + } + + entry { route -> + QRScannerScreen( + onQrCodeScanned = { result -> + ResultEventBus.sendResult( + resultKey = NavigationResultKeys.QR_SCAN_RESULT, + result = result + ) + navigator.navigateBack() + }, + onNavigateBack = { + navigator.navigateBack() + } + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/dashboard/SnowbirdDashboardScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/dashboard/SnowbirdDashboardScreen.kt new file mode 100644 index 000000000..cf55b3bd5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/dashboard/SnowbirdDashboardScreen.kt @@ -0,0 +1,449 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.dashboard + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.collectLatest +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.navigation.NavigationResultKeys +import net.opendasharchive.openarchive.core.navigation.ResultEffect +import net.opendasharchive.openarchive.core.presentation.components.LoadingOverlay +import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight +import net.opendasharchive.openarchive.core.presentation.theme.SaveTextStyles +import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.features.media.AddMediaType +import net.opendasharchive.openarchive.features.media.ContentPickerSheet +import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus + +@Composable +fun SnowbirdDashboardScreen( + viewModel: SnowbirdDashboardViewModel +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + // 1. Gallery Launcher for QR code images + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + viewModel.onAction(SnowbirdDashboardAction.ImagePickedForQR(it, context)) + } + } + + // 2. File Launcher for QR code images + val fileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { + viewModel.onAction(SnowbirdDashboardAction.ImagePickedForQR(it, context)) + } + } + + // 3. Listen for results from QR Scanner screen + ResultEffect(resultKey = NavigationResultKeys.QR_SCAN_RESULT) { result -> + viewModel.onAction(SnowbirdDashboardAction.QRResultScanned(result)) + } + + // 4. Handle ViewModel events + LaunchedEffect(Unit) { + viewModel.events.collectLatest { event -> + when (event) { + is SnowbirdDashboardEvent.LaunchPicker -> { + when (event.type) { + AddMediaType.GALLERY -> { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + + AddMediaType.FILES -> { + fileLauncher.launch(arrayOf("image/*")) + } + + else -> Unit + } + } + } + } + } + + SnowbirdDashboardContent( + state = state, + onAction = viewModel::onAction + ) + +} + +@Composable +fun SnowbirdDashboardContent( + state: SnowbirdDashboardState, + onAction: (SnowbirdDashboardAction) -> Unit +) { + + if (state.showContentPicker) { + val context = LocalContext.current + ContentPickerSheet( + title = "Scan QR Code", + onClipboardClick = { + onAction(SnowbirdDashboardAction.PasteCodeFromClipboard(context)) + }, + onDismiss = { + onAction(SnowbirdDashboardAction.ContentPickerDismissed) + }, + onMediaTypeSelected = { type -> + onAction(SnowbirdDashboardAction.MediaPicked(type)) + }, + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + // Get navigation bar insets for edge-to-edge support + val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues() + + // Use a Column for content + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 32.dp) + .padding(horizontal = 24.dp) + .padding(bottom = navigationBarPadding.calculateBottomPadding() + 16.dp), + ) { + + // Header texts + SpaceAuthHeader( + description = "Preserve your media on the decentralized web (DWeb) Storage.", + imagePainter = painterResource(R.drawable.ic_dweb), + modifier = Modifier + .padding(vertical = 48.dp) + .padding(end = 24.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // WebDav option + DwebOptionItem( + title = "Join group", + subtitle = "Connect to existing group", + onClick = { onAction(SnowbirdDashboardAction.JoinGroupClick) } + ) + + DwebOptionItem( + title = "Create group", + subtitle = "Create a new group via Dweb", + onClick = { onAction(SnowbirdDashboardAction.CreateGroupClick) } + ) + + DwebOptionItem( + title = "My groups", + subtitle = "View and manage your groups", + onClick = { onAction(SnowbirdDashboardAction.MyGroupsClick) } + ) + + Spacer(modifier = Modifier.weight(1f)) + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + + // Server Control Section at the bottom using custom preference style + DwebServerPreference( + serverStatus = state.serverStatus, + onToggle = { enabled -> onAction(SnowbirdDashboardAction.ToggleServer(enabled)) } + ) + + // Native Loading Overlay + if (state.isLoading) { + LoadingOverlay() + } + + } + } +} + +@Composable +fun DwebServerPreference( + serverStatus: ServiceStatus, + onToggle: (Boolean) -> Unit +) { + val isServerEnabled = serverStatus !is ServiceStatus.Stopped + val isConnecting = serverStatus is ServiceStatus.Connecting + + // Summary text based on status + val summaryText = when (serverStatus) { + is ServiceStatus.Stopped -> "Enable to share and sync media" + is ServiceStatus.Connecting -> "Connecting..." + is ServiceStatus.Connected -> "Running on localhost:8080" + is ServiceStatus.Failed -> "Failed to start. Try again." + } + + // Custom preference-style UI + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = isServerEnabled, + enabled = !isConnecting, + role = Role.Switch, + onValueChange = onToggle + ) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Title and Summary + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "DWeb Server", + style = SaveTextStyles.bodyLarge, + color = if (!isConnecting) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = summaryText, + style = SaveTextStyles.bodySmallEmphasis, + color = if (!isConnecting) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + } + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Switch with custom colors + Switch( + checked = isServerEnabled, + onCheckedChange = null, // Handled by toggleable modifier + enabled = !isConnecting, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.surface, + checkedTrackColor = MaterialTheme.colorScheme.tertiary, + uncheckedThumbColor = MaterialTheme.colorScheme.outline, + uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } +} + +@PreviewLight +@Composable +private fun SnowbirdDashboardContentPreview() { + DefaultScaffoldPreview { + SnowbirdDashboardContent( + state = SnowbirdDashboardState( + isLoading = false, + serverStatus = ServiceStatus.Stopped + ), + onAction = {}, + ) + } +} + +@Composable +fun DwebOptionItem( + title: String, + subtitle: String, + onClick: () -> Unit +) { + // You can customize this look to match your original design + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.background + ), + border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.onBackground), + shape = RoundedCornerShape(8.dp) + ) { + + Row( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .padding(16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier + .align(Alignment.Top) + .weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + + Text( + text = subtitle, + fontWeight = FontWeight.Normal, + fontSize = 14.sp + ) + } + + Icon( + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically), + painter = painterResource(R.drawable.ic_arrow_forward_ios), + contentDescription = null, + ) + } + + + } +} + + +@Composable +fun SpaceAuthHeader( + modifier: Modifier = Modifier, + description: String = stringResource(id = R.string.internet_archive_description), + imagePainter: Painter = painterResource(id = R.drawable.ic_internet_archive) +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(50.dp) + .clip(CircleShape) + .background(ThemeColors.material.surfaceDim) + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.size(30.dp), + painter = imagePainter, + contentDescription = "Space Image", + colorFilter = ColorFilter.tint(colorResource(id = R.color.colorTertiary)) + ) + } + + Column( + modifier = Modifier.padding( + start = ThemeDimensions.spacing.medium, + end = ThemeDimensions.spacing.xlarge + ) + ) { + Text( + text = description, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = ThemeColors.material.onSurfaceVariant, + ) + } + } +} + +@PreviewLight +@Composable +private fun DwebServerPreferencePreview() { + DefaultBoxPreview { + Column( + modifier = Modifier.fillMaxSize() + ) { + Text("Stopped:", modifier = Modifier.padding(8.dp)) + DwebServerPreference( + serverStatus = ServiceStatus.Stopped, + onToggle = {} + ) + + HorizontalDivider() + + Text("Connecting:", modifier = Modifier.padding(8.dp)) + DwebServerPreference( + serverStatus = ServiceStatus.Connecting, + onToggle = {} + ) + + HorizontalDivider() + + Text("Connected:", modifier = Modifier.padding(8.dp)) + DwebServerPreference( + serverStatus = ServiceStatus.Connected, + onToggle = {} + ) + + HorizontalDivider() + + Text("Failed:", modifier = Modifier.padding(8.dp)) + DwebServerPreference( + serverStatus = ServiceStatus.Failed(Throwable("Failed to start")), + onToggle = {} + ) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/dashboard/SnowbirdDashboardViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/dashboard/SnowbirdDashboardViewModel.kt new file mode 100644 index 000000000..181e0cb7a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/dashboard/SnowbirdDashboardViewModel.kt @@ -0,0 +1,182 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.dashboard + +import android.content.Context +import android.graphics.BitmapFactory +import android.net.Uri +import android.content.ClipboardManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.showErrorDialog +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.features.media.AddMediaType +import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdServiceController +import net.opendasharchive.openarchive.services.snowbird.util.SnowbirdJoinCode +import net.opendasharchive.openarchive.services.snowbird.util.SnowbirdQRDecoder +import net.opendasharchive.openarchive.util.ProcessingTracker +import net.opendasharchive.openarchive.util.trackProcessing + +data class SnowbirdDashboardState( + val isLoading: Boolean = false, + val serverStatus: ServiceStatus = ServiceStatus.Stopped, + val showContentPicker: Boolean = false +) + +sealed interface SnowbirdDashboardAction { + data object JoinGroupClick : SnowbirdDashboardAction + data object CreateGroupClick : SnowbirdDashboardAction + data object MyGroupsClick : SnowbirdDashboardAction + data class ToggleServer(val enabled: Boolean) : SnowbirdDashboardAction + data object ContentPickerDismissed : SnowbirdDashboardAction + data class MediaPicked(val type: AddMediaType) : SnowbirdDashboardAction + data class QRResultScanned(val result: String) : SnowbirdDashboardAction + data class ImagePickedForQR(val uri: Uri, val context: Context) : SnowbirdDashboardAction + data class PasteCodeFromClipboard(val context: Context) : SnowbirdDashboardAction +} + +sealed interface SnowbirdDashboardEvent { + data class LaunchPicker(val type: AddMediaType) : SnowbirdDashboardEvent +} + +class SnowbirdDashboardViewModel( + private val navigator: Navigator, + private val route: AppRoute.SnowbirdDashboardRoute, + private val dialogManager: DialogStateManager, + private val serviceController: SnowbirdServiceController, + private val processingTracker: ProcessingTracker = ProcessingTracker() +) : ViewModel() { + + private val _uiState = MutableStateFlow(SnowbirdDashboardState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + init { + // Observe server status reactively + SnowbirdService.serviceStatus + .onEach { status -> + _uiState.update { it.copy(serverStatus = status) } + } + .launchIn(viewModelScope) + } + + fun onAction(action: SnowbirdDashboardAction) { + when (action) { + is SnowbirdDashboardAction.JoinGroupClick -> { + _uiState.update { it.copy(showContentPicker = true) } + } + is SnowbirdDashboardAction.CreateGroupClick -> { + navigator.navigateTo(AppRoute.SnowbirdCreateGroupRoute) + } + is SnowbirdDashboardAction.MyGroupsClick -> { + navigator.navigateTo(AppRoute.SnowbirdGroupListRoute) + } + is SnowbirdDashboardAction.ToggleServer -> { + if (action.enabled) { + serviceController.startService() + } else { + serviceController.stopService() + } + } + is SnowbirdDashboardAction.ContentPickerDismissed -> { + _uiState.update { it.copy(showContentPicker = false) } + } + is SnowbirdDashboardAction.MediaPicked -> { + _uiState.update { it.copy(showContentPicker = false) } + when (action.type) { + AddMediaType.CAMERA -> { + navigator.navigateTo(AppRoute.SnowbirdQRScannerRoute) + } + AddMediaType.GALLERY -> { + viewModelScope.launch { _events.emit(SnowbirdDashboardEvent.LaunchPicker(AddMediaType.GALLERY)) } + } + AddMediaType.FILES -> { + viewModelScope.launch { _events.emit(SnowbirdDashboardEvent.LaunchPicker(AddMediaType.FILES)) } + } + } + } + is SnowbirdDashboardAction.QRResultScanned -> { + processScannedData(action.result) + } + is SnowbirdDashboardAction.ImagePickedForQR -> { + processImageForQR(action.uri, action.context) + } + is SnowbirdDashboardAction.PasteCodeFromClipboard -> { + processCodeFromClipboard(action.context) + } + } + } + + private fun processImageForQR(uri: Uri, context: Context) { + viewModelScope.launch { + processingTracker.trackProcessing("decode_qr") { + _uiState.update { it.copy(isLoading = true) } + try { + val inputStream = context.contentResolver.openInputStream(uri) + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + if (bitmap != null) { + val qrContent = SnowbirdQRDecoder.decodeFromBitmap(bitmap) + if (qrContent != null) { + processScannedData(qrContent) + } else { + showError(UiText.Dynamic("No QR code found in the image.")) + } + } else { + showError(UiText.Dynamic("Could not load selected image.")) + } + } catch (e: Exception) { + showError(UiText.Dynamic("Error processing image: ${e.message}")) + } finally { + _uiState.update { it.copy(isLoading = false) } + } + } + } + } + + private fun showError(message: UiText) { + dialogManager.showErrorDialog(message = message) + } + + private fun processScannedData(uriString: String) { + val name = SnowbirdJoinCode.extractGroupName(uriString) + if (name == null) { + showError(UiText.Dynamic("Unable to determine group name from the provided code.")) + return + } + + navigator.navigateTo(AppRoute.SnowbirdJoinGroupRoute(uriString)) + } + + private fun processCodeFromClipboard(context: Context) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val code = clipboard.primaryClip + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.coerceToText(context) + ?.toString() + ?.trim() + + if (code.isNullOrBlank()) { + showError(UiText.Dynamic("Clipboard is empty. Copy a DWeb group code and try again.")) + return + } + + processScannedData(code) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/file/SnowbirdFileListScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/file/SnowbirdFileListScreen.kt new file mode 100644 index 000000000..3e2d0133b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/file/SnowbirdFileListScreen.kt @@ -0,0 +1,422 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.file + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.webkit.MimeTypeMap +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.collectLatest +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.config.AppConfig +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.navigation.NavigationResultKeys +import net.opendasharchive.openarchive.core.navigation.ResultEffect +import net.opendasharchive.openarchive.core.presentation.media.MediaThumbnail +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.core.presentation.theme.SaveTextStyles +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.features.main.ui.CameraCaptureResult +import net.opendasharchive.openarchive.features.media.AddMediaType +import net.opendasharchive.openarchive.features.media.ContentPickerSheet +import net.opendasharchive.openarchive.util.Utility +import org.koin.compose.koinInject + +@Composable +fun SnowbirdFileListScreen( + viewModel: SnowbirdFileViewModel +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val dialogManager: DialogStateManager = koinInject() + val appConfig: AppConfig = koinInject() + + // 1. Camera Launcher (Photo only for now as per system contract) + var currentPhotoUri by remember { mutableStateOf(null) } + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + if (success) { + currentPhotoUri?.let { viewModel.onAction(SnowbirdFileAction.UploadFile(it)) } + } + } + + // Receive camera capture results from CameraScreen via ResultEventBus + ResultEffect(resultKey = NavigationResultKeys.SNOWBIRD_CAMERA_RESULT) { result -> + if (result.projectId == state.archiveId) { + result.capturedUris.forEach { uri -> + viewModel.onAction(SnowbirdFileAction.UploadFile(uri)) + } + } + } + + // 2. Gallery Launcher (Multiple selection - 10) + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(10) + ) { uris -> + uris.forEach { viewModel.onAction(SnowbirdFileAction.UploadFile(it)) } + } + + // 3. File Launcher (Open multiple documents with filtering) + val fileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + if (uris.isNotEmpty()) { + val contentResolver = context.contentResolver + val filteredUris = uris.filter { uri -> + val mimeType = contentResolver.getType(uri) + mimeType?.startsWith("image/") == true || + mimeType?.startsWith("video/") == true || + mimeType?.startsWith("audio/") == true + } + + if (filteredUris.isEmpty()) { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Warning + title = UiText.Dynamic("Invalid File Type") + message = UiText.Dynamic("Please select only image, video, or audio files.") + positiveButton { text = UiText.Resource(R.string.lbl_ok) } + } + } else { + if (filteredUris.size < uris.size) { + Toast.makeText( + context, + "Some files were skipped (unsupported types)", + Toast.LENGTH_SHORT + ).show() + } + filteredUris.forEach { viewModel.onAction(SnowbirdFileAction.UploadFile(it)) } + } + } + } + + // Handle Side Effects + LaunchedEffect(Unit) { + viewModel.events.collectLatest { event -> + when (event) { + is SnowbirdFileEvent.FileDownloaded -> { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Success + title = UiText.Resource(R.string.label_success_title) + message = UiText.Dynamic("File successfully downloaded") + positiveButton { + text = UiText.Dynamic("Open") + action = { openFile(context, event.uri, dialogManager) } + } + neutralButton { + text = UiText.Resource(R.string.lbl_ok) + } + } + } + is SnowbirdFileEvent.LaunchPicker -> { + when (event.type) { + AddMediaType.CAMERA -> { + if (appConfig.useCustomCamera) { + viewModel.onAction(SnowbirdFileAction.NavigateToCamera) + } else { + val photoFile = Utility.getOutputMediaFile(context, "camera_${System.currentTimeMillis()}.jpg") + if (photoFile != null) { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + photoFile + ) + currentPhotoUri = uri + cameraLauncher.launch(uri) + } else { + Toast.makeText(context, "Failed to prepare camera", Toast.LENGTH_SHORT).show() + } + } + } + AddMediaType.GALLERY -> { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + ) + } + AddMediaType.FILES -> { + fileLauncher.launch(arrayOf("image/*", "video/*", "audio/*")) + } + } + } + } + } + } + + // Screen Body + Box(modifier = Modifier.fillMaxSize()) { + SnowbirdFileListContent( + state = state, + onAction = { snowbirdAction -> + if (snowbirdAction is SnowbirdFileAction.DownloadFile) { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Warning + title = UiText.Dynamic("Download Media?") + message = UiText.Dynamic("Are you sure you want to download this media?") + positiveButton { + text = UiText.Dynamic("Yes") + action = { viewModel.onAction(snowbirdAction) } + } + neutralButton { + text = UiText.Dynamic("No") + } + } + } else { + viewModel.onAction(snowbirdAction) + } + } + ) + + if (state.showContentPicker) { + ContentPickerSheet( + onDismiss = { viewModel.onAction(SnowbirdFileAction.ContentPickerDismissed) }, + onMediaTypeSelected = { type -> viewModel.onAction(SnowbirdFileAction.OnMediaTypeSelected(type)) }, + ) + } + } +} + +private fun openFile(context: Context, uri: Uri, dialogManager: DialogStateManager) { + try { + val filename = uri.lastPathSegment ?: "file" + val extension = filename.substringAfterLast(".", "") + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) ?: "*/*" + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + val chooser = Intent.createChooser(intent, "Open file with") + if (chooser.resolveActivity(context.packageManager) != null) { + context.startActivity(chooser) + } else { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Warning + title = UiText.Dynamic("No App Found") + message = UiText.Dynamic("No app is available to open this type of file.") + positiveButton { text = UiText.Resource(R.string.lbl_ok) } + } + } + } + } catch (e: Exception) { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Error + title = UiText.Dynamic("Error") + message = UiText.Dynamic("Could not open file: ${e.message}") + positiveButton { text = UiText.Resource(R.string.lbl_ok) } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SnowbirdFileListContent( + state: SnowbirdFileState, + onAction: (SnowbirdFileAction) -> Unit +) { + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = { onAction(SnowbirdFileAction.RefreshFiles) }, + modifier = Modifier.fillMaxSize() + ) { + if (state.files.isEmpty() && !state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + contentAlignment = Alignment.Center + ) { + Text(text = "No files found") + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.files) { evidence -> + SnowbirdFileItem( + evidence = evidence, + onClick = { onAction(SnowbirdFileAction.DownloadFile(evidence)) } + ) + } + } + } + } +} + +@Composable +fun SnowbirdFileItem( + evidence: Evidence, + onClick: () -> Unit +) { + val fileExtension = evidence.title.substringAfterLast(".", "").lowercase() + val iconRes = when { + isImageFile(fileExtension) -> R.drawable.ic_image + isVideoFile(fileExtension) -> R.drawable.ic_videocam + isAudioFile(fileExtension) -> R.drawable.ic_music + else -> R.drawable.ic_description + } + + Card( + modifier = Modifier + .height(140.dp) + .fillMaxWidth() + .padding(4.dp) + .clickable { onClick() }, + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + colors = CardDefaults.cardColors(containerColor = Color.Transparent) + ) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Box( + modifier = Modifier.size(80.dp), + contentAlignment = Alignment.Center + ) { + if (evidence.isDownloaded && evidence.originalFilePath.isNotBlank()) { + MediaThumbnail( + evidence = evidence, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + } + + if (!evidence.isDownloaded) { + Icon( + painter = painterResource(id = R.drawable.outline_cloud_download_24), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .align(Alignment.BottomEnd) + .padding(2.dp), + tint = MaterialTheme.colorScheme.secondary + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = evidence.title, + style = SaveTextStyles.bodySmall.copy(fontWeight = FontWeight.SemiBold), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + lineHeight = 12.sp + ) + } + } + } +} + +private fun isImageFile(extension: String) = extension in listOf("jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "heif") +private fun isVideoFile(extension: String) = extension in listOf("mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "3gp") +private fun isAudioFile(extension: String) = extension in listOf("mp3", "wav", "ogg", "m4a", "flac", "aac", "wma") + +@PreviewLightDark +@Composable +private fun SnowbirdFileListScreenPreview() { + SaveAppTheme { + SnowbirdFileListContent( + state = SnowbirdFileState( + files = listOf( + Evidence(title = "photo1.jpg", isDownloaded = true), + Evidence(title = "video1.mp4", isDownloaded = false), + Evidence(title = "audio1.mp3", isDownloaded = true), + Evidence(title = "doc1.pdf", isDownloaded = false), + Evidence(title = "photo2.png", isDownloaded = true) + ) + ), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun SnowbirdFileListScreenEmptyPreview() { + DefaultScaffoldPreview { + SnowbirdFileListContent( + state = SnowbirdFileState(files = emptyList()), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun SnowbirdFileListScreenLoadingPreview() { + DefaultScaffoldPreview { + SnowbirdFileListContent( + state = SnowbirdFileState(files = emptyList(), isLoading = true), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/file/SnowbirdFileViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/file/SnowbirdFileViewModel.kt new file mode 100644 index 000000000..84ad32a3d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/file/SnowbirdFileViewModel.kt @@ -0,0 +1,210 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.file + +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.core.domain.DomainResult +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.showErrorDialog +import net.opendasharchive.openarchive.core.navigation.NavigationResultKeys +import net.opendasharchive.openarchive.features.media.AddMediaType +import net.opendasharchive.openarchive.features.media.camera.CameraConfig +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdFileRepository +import net.opendasharchive.openarchive.services.snowbird.util.SnowbirdFileStorage +import net.opendasharchive.openarchive.util.ProcessingTracker +import net.opendasharchive.openarchive.util.trackProcessing + +data class SnowbirdFileState( + val files: List = emptyList(), + val isLoading: Boolean = false, + val archiveId: Long = 0, + val groupKey: String = "", + val repoKey: String = "", + val showContentPicker: Boolean = false +) + +sealed interface SnowbirdFileAction { + data class DownloadFile(val evidence: Evidence) : SnowbirdFileAction + data class UploadFile(val uri: Uri) : SnowbirdFileAction + data object RefreshFiles : SnowbirdFileAction + data object ShowContentPicker : SnowbirdFileAction + data object ContentPickerDismissed : SnowbirdFileAction + data class OnMediaTypeSelected(val type: AddMediaType) : SnowbirdFileAction + data object NavigateToCamera : SnowbirdFileAction + data object NavigateBack : SnowbirdFileAction +} + +sealed interface SnowbirdFileEvent { + data class FileDownloaded(val uri: Uri) : SnowbirdFileEvent + data class LaunchPicker(val type: AddMediaType) : SnowbirdFileEvent +} + +class SnowbirdFileViewModel( + private val navigator: Navigator, + private val route: AppRoute.SnowbirdFileListRoute, + private val repository: ISnowbirdFileRepository, + private val fileStorage: SnowbirdFileStorage, + private val dialogManager: DialogStateManager, + private val processingTracker: ProcessingTracker = ProcessingTracker() +) : ViewModel() { + + private val _uiState = MutableStateFlow( + SnowbirdFileState( + archiveId = route.archiveId, + groupKey = route.groupKey, + repoKey = route.repoKey + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + init { + observeFiles(route.archiveId) + fetchFiles() + } + + fun onAction(action: SnowbirdFileAction) { + when (action) { + is SnowbirdFileAction.DownloadFile -> downloadFile(action.evidence) + is SnowbirdFileAction.UploadFile -> uploadFile(action.uri) + is SnowbirdFileAction.RefreshFiles -> fetchFiles(forceRefresh = true) + is SnowbirdFileAction.ShowContentPicker -> { + _uiState.update { it.copy(showContentPicker = true) } + } + is SnowbirdFileAction.ContentPickerDismissed -> { + _uiState.update { it.copy(showContentPicker = false) } + } + is SnowbirdFileAction.OnMediaTypeSelected -> { + _uiState.update { it.copy(showContentPicker = false) } + viewModelScope.launch { + _events.emit(SnowbirdFileEvent.LaunchPicker(action.type)) + } + } + is SnowbirdFileAction.NavigateToCamera -> { + val cameraConfig = CameraConfig( + allowVideoCapture = true, + allowPhotoCapture = true, + allowMultipleCapture = false, + enablePreview = true, + showFlashToggle = true, + showGridToggle = true, + showCameraSwitch = true + ) + navigator.navigateTo( + AppRoute.CameraRoute( + projectId = uiState.value.archiveId, + config = cameraConfig, + resultKey = NavigationResultKeys.SNOWBIRD_CAMERA_RESULT + ) + ) + } + is SnowbirdFileAction.NavigateBack -> { + navigator.navigateBack() + } + } + } + + private fun observeFiles(archiveId: Long) { + repository.observeFiles(archiveId) + .onEach { files -> + _uiState.update { it.copy(files = files) } + } + .launchIn(viewModelScope) + } + + private fun fetchFiles(forceRefresh: Boolean = false) { + val state = _uiState.value + if (state.archiveId == 0L || state.groupKey.isBlank() || state.repoKey.isBlank()) return + + viewModelScope.launch { + processingTracker.trackProcessing("fetch_files") { + _uiState.update { it.copy(isLoading = true) } + val result = repository.fetchFiles(state.archiveId, state.groupKey, state.repoKey, forceRefresh) + _uiState.update { it.copy(isLoading = false) } + + if (result is DomainResult.Error) { + dialogManager.showErrorDialog(message = UiText.Dynamic(result.error.friendlyMessage)) + } + } + } + } + + private fun downloadFile(evidence: Evidence) { + val state = _uiState.value + val filename = evidence.title + + viewModelScope.launch { + processingTracker.trackProcessing("download_file") { + _uiState.update { it.copy(isLoading = true) } + val result = repository.downloadFile(state.groupKey, state.repoKey, filename) + _uiState.update { it.copy(isLoading = false) } + + when (result) { + is DomainResult.Success -> { + val savedFile = fileStorage.saveByteArrayToFile(result.data, filename).getOrNull() + if (savedFile != null) { + val markResult = repository.markFileDownloaded( + evidenceId = evidence.id, + localFilePath = savedFile.localFileUri, + mimeType = resolveDownloadedMimeType(evidence) + ) + fileStorage.saveImageToGallery(result.data, filename) + if (markResult is DomainResult.Error) { + dialogManager.showErrorDialog( + message = UiText.Dynamic(markResult.error.friendlyMessage) + ) + } + _events.emit(SnowbirdFileEvent.FileDownloaded(savedFile.shareUri)) + } else { + dialogManager.showErrorDialog(message = UiText.Dynamic("Failed to save file locally")) + } + } + is DomainResult.Error -> { + dialogManager.showErrorDialog(message = UiText.Dynamic(result.error.friendlyMessage)) + } + } + } + } + } + + private fun uploadFile(uri: Uri) { + val state = _uiState.value + viewModelScope.launch { + processingTracker.trackProcessing("upload_file") { + _uiState.update { it.copy(isLoading = true) } + val result = repository.uploadFile(state.groupKey, state.repoKey, uri) + _uiState.update { it.copy(isLoading = false) } + + if (result is DomainResult.Error) { + dialogManager.showErrorDialog(message = UiText.Dynamic(result.error.friendlyMessage)) + } else { + fetchFiles(forceRefresh = true) + } + } + } + } + + private fun resolveDownloadedMimeType(evidence: Evidence): String { + val existing = evidence.mimeType.trim() + if (existing.isNotBlank() && existing != "application/octet-stream") { + return existing + } + + val extension = evidence.title.substringAfterLast('.', "").lowercase() + val inferred = if (extension.isNotBlank()) { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + } else { + null + } + return inferred ?: existing.ifBlank { "application/octet-stream" } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdCreateGroupScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdCreateGroupScreen.kt new file mode 100644 index 000000000..05d062a58 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdCreateGroupScreen.kt @@ -0,0 +1,184 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.group + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomTextField + + +@Composable +fun SnowbirdCreateGroupScreen( + viewModel: SnowbirdCreateGroupViewModel +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + SnowbirdCreateGroupScreenContent( + state = state, + onAction = viewModel::onAction + ) +} + +@Composable +fun SnowbirdCreateGroupScreenContent( + state: SnowbirdCreateGroupState, + onAction: (SnowbirdCreateGroupAction) -> Unit +) { + val focusManager = LocalFocusManager.current + val repoFocusRequester = remember { FocusRequester() } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(top = 20.dp, bottom = 100.dp), + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = stringResource(R.string.dweb_create_group_screen_title), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(horizontal = 32.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + + Spacer(modifier = Modifier.height(30.dp)) + + CustomTextField( + value = state.groupName, + onValueChange = { onAction(SnowbirdCreateGroupAction.UpdateGroupName(it)) }, + placeholder = stringResource(R.string.dweb_create_group_group_name), + isLoading = state.isLoading, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + onImeAction = { repoFocusRequester.requestFocus() } + ) + + Spacer(modifier = Modifier.height(30.dp)) + + CustomTextField( + value = state.repoName, + onValueChange = { onAction(SnowbirdCreateGroupAction.UpdateRepoName(it)) }, + placeholder = stringResource(R.string.dweb_create_group_user_name), + isLoading = state.isLoading, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + onImeAction = { focusManager.clearFocus() }, + modifier = Modifier.focusRequester(repoFocusRequester) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.dweb_create_group_screen_description), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Normal + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Button bar + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 24.dp, vertical = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton( + modifier = Modifier + .heightIn(ThemeDimensions.touchable) + .weight(1f), + colors = ButtonDefaults.textButtonColors( + contentColor = colorResource(R.color.colorOnBackground) + ), + enabled = !state.isLoading, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { onAction(SnowbirdCreateGroupAction.Cancel) } + ) { + Text( + stringResource(R.string.back), + style = MaterialTheme.typography.titleLarge + ) + } + + Button( + modifier = Modifier + .heightIn(ThemeDimensions.touchable) + .weight(1f), + enabled = !state.isLoading && state.groupName.isNotBlank() && state.repoName.isNotBlank(), + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + disabledContainerColor = colorResource(R.color.grey_50), + disabledContentColor = colorResource(R.color.black), + contentColor = colorResource(R.color.black) + ), + onClick = { onAction(SnowbirdCreateGroupAction.CreateGroup) } + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onTertiary + ) + } else { + Text( + stringResource(R.string.next), + style = MaterialTheme.typography.titleLarge + ) + } + } + } + } +} + +@PreviewLight +@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SnowbirdCreateGroupScreenPreview() { + SaveAppTheme { + SnowbirdCreateGroupScreenContent( + state = SnowbirdCreateGroupState(), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun SnowbirdCreateGroupScreenLoadingPreview() { + DefaultScaffoldPreview { + SnowbirdCreateGroupScreenContent( + state = SnowbirdCreateGroupState(isLoading = true), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdCreateGroupViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdCreateGroupViewModel.kt new file mode 100644 index 000000000..4cc7feb91 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdCreateGroupViewModel.kt @@ -0,0 +1,108 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.group + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.core.domain.DomainResult +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.showErrorDialog +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdGroupRepository +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdRepoRepository +import net.opendasharchive.openarchive.util.ProcessingTracker +import net.opendasharchive.openarchive.util.trackProcessing + +data class SnowbirdCreateGroupState( + val groupName: String = "", + val repoName: String = "", + val isLoading: Boolean = false +) + +sealed interface SnowbirdCreateGroupAction { + data class UpdateGroupName(val name: String) : SnowbirdCreateGroupAction + data class UpdateRepoName(val name: String) : SnowbirdCreateGroupAction + data object CreateGroup : SnowbirdCreateGroupAction + data object Cancel : SnowbirdCreateGroupAction +} + + +class SnowbirdCreateGroupViewModel( + private val navigator: Navigator, + route: AppRoute.SnowbirdCreateGroupRoute, + private val repository: ISnowbirdGroupRepository, + private val repoRepository: ISnowbirdRepoRepository, + private val dialogManager: DialogStateManager, + private val processingTracker: ProcessingTracker = ProcessingTracker() +) : ViewModel() { + + private val _uiState = MutableStateFlow(SnowbirdCreateGroupState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onAction(action: SnowbirdCreateGroupAction) { + when (action) { + is SnowbirdCreateGroupAction.UpdateGroupName -> { + _uiState.update { it.copy(groupName = action.name) } + } + + is SnowbirdCreateGroupAction.UpdateRepoName -> { + _uiState.update { it.copy(repoName = action.name) } + } + + is SnowbirdCreateGroupAction.CreateGroup -> createGroupWithRepo() + is SnowbirdCreateGroupAction.Cancel -> { + navigator.navigateBack() + } + } + } + + private fun createGroupWithRepo() { + val currentState = _uiState.value + val groupName = currentState.groupName + val repoName = currentState.repoName + + if (groupName.isBlank() || repoName.isBlank()) return + + viewModelScope.launch { + processingTracker.trackProcessing("create_group_with_repo") { + _uiState.update { it.copy(isLoading = true) } + when (val groupResult = repository.createGroup(groupName)) { + is DomainResult.Success -> { + val group = groupResult.data + val groupKey = group.vaultKey.orEmpty() + + val repoResult = repoRepository.createRepo( + vaultId = group.id, + groupKey = groupKey, + repoName = repoName + ) + + when (repoResult) { + is DomainResult.Success -> { + navigator.navigateTo(AppRoute.SpaceSetupSuccessRoute(VaultType.DWEB_STORAGE)) + } + + is DomainResult.Error -> { + _uiState.update { it.copy(isLoading = false) } + dialogManager.showErrorDialog( + message = UiText.Dynamic(repoResult.error.friendlyMessage) + ) + } + } + } + + is DomainResult.Error -> { + _uiState.update { it.copy(isLoading = false) } + dialogManager.showErrorDialog(message = UiText.Dynamic(groupResult.error.friendlyMessage)) + } + } + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdGroupListScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdGroupListScreen.kt new file mode 100644 index 000000000..c0bb98126 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdGroupListScreen.kt @@ -0,0 +1,182 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.group + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.core.presentation.theme.SaveTextStyles + + +@Composable +fun SnowbirdGroupListScreen( + viewModel: SnowbirdGroupListViewModel +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + SnowbirdGroupListContent( + state = state, + onAction = viewModel::onAction + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SnowbirdGroupListContent( + state: SnowbirdGroupListState, + onAction: (SnowbirdGroupListAction) -> Unit +) { + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = { onAction(SnowbirdGroupListAction.RefreshGroups) }, + modifier = Modifier.fillMaxSize() + ) { + if (state.groups.isEmpty() && !state.isLoading) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "No groups found") + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.groups) { group -> + SnowbirdGroupItem( + group = group, + onClick = { onAction(SnowbirdGroupListAction.SelectGroup(group)) }, + onLongClick = { onAction(SnowbirdGroupListAction.ShareGroup(group)) } + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SnowbirdGroupItem( + group: Vault, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_dweb), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = group.name, + style = SaveTextStyles.titleMedium + ) + if (group.vaultKey?.isNotBlank() == true) { + Text( + text = group.vaultKey, + style = SaveTextStyles.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else if (group.host.isNotBlank()) { + Text( + text = group.host, + style = SaveTextStyles.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_right_ios), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@PreviewLightDark +@Composable +private fun SnowbirdGroupListScreenPreview() { + SaveAppTheme { + SnowbirdGroupListContent( + state = SnowbirdGroupListState( + groups = listOf( + Vault(name = "Personal Group", host = "veilid://host1", type = VaultType.DWEB_STORAGE), + Vault(name = "Work Group", host = "veilid://host2", type = VaultType.DWEB_STORAGE), + Vault(name = "Research Group", host = "veilid://host3", type = VaultType.DWEB_STORAGE) + ) + ), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun SnowbirdGroupListScreenEmptyPreview() { + DefaultScaffoldPreview { + SnowbirdGroupListContent( + state = SnowbirdGroupListState(groups = emptyList()), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun SnowbirdGroupListScreenLoadingPreview() { + DefaultScaffoldPreview { + SnowbirdGroupListContent( + state = SnowbirdGroupListState(groups = emptyList(), isLoading = true), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdGroupListViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdGroupListViewModel.kt new file mode 100644 index 000000000..fc9359f48 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdGroupListViewModel.kt @@ -0,0 +1,112 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.group + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.core.domain.DomainResult +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.features.core.dialog.showErrorDialog +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdGroupRepository +import net.opendasharchive.openarchive.util.ProcessingTracker +import net.opendasharchive.openarchive.util.trackProcessing +import net.opendasharchive.openarchive.util.trackProcessingWithTimeout + +data class SnowbirdGroupListState( + val groups: List = emptyList(), + val isLoading: Boolean = false +) + +sealed interface SnowbirdGroupListAction { + data class SelectGroup(val group: Vault) : SnowbirdGroupListAction + data class ShareGroup(val group: Vault) : SnowbirdGroupListAction + data object RefreshGroups : SnowbirdGroupListAction +} + + + +class SnowbirdGroupListViewModel( + private val navigator: Navigator, + route: AppRoute.SnowbirdGroupListRoute, + private val repository: ISnowbirdGroupRepository, + private val dialogManager: DialogStateManager, + private val processingTracker: ProcessingTracker = ProcessingTracker() +) : ViewModel() { + + private val _uiState = MutableStateFlow(SnowbirdGroupListState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + repository.observeGroups() + .onEach { groups -> + _uiState.update { it.copy(groups = groups) } + } + .launchIn(viewModelScope) + + // First load forces network refresh so newly joined memberships surface immediately. + fetchGroups(forceRefresh = true) + } + + fun onAction(action: SnowbirdGroupListAction) { + when (action) { + is SnowbirdGroupListAction.SelectGroup -> { + navigator.navigateTo( + AppRoute.SnowbirdRepoListRoute( + vaultId = action.group.id, + groupKey = action.group.vaultKey ?: "" + ) + ) + } + is SnowbirdGroupListAction.ShareGroup -> { + showShareConfirmation(action.group) + } + is SnowbirdGroupListAction.RefreshGroups -> fetchGroups(forceRefresh = true) + } + } + + private fun fetchGroups(forceRefresh: Boolean = false) { + viewModelScope.launch { + processingTracker.trackProcessing("fetch_groups") { + _uiState.update { it.copy(isLoading = true) } + val result = repository.fetchGroups(forceRefresh) + _uiState.update { it.copy(isLoading = false) } + + if (result is DomainResult.Error) { + dialogManager.showErrorDialog(message = UiText.Dynamic(result.error.friendlyMessage)) + } + } + } + } + + private fun showShareConfirmation(group: Vault) { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Info + title = UiText.Dynamic("Share Group") + message = UiText.Dynamic("Would you like to share this group?") + positiveButton { + text = UiText.Dynamic("Yes") + action = { + navigator.navigateTo( + AppRoute.SnowbirdShareRoute(groupKey = group.vaultKey ?: "") + ) + } + } + neutralButton { + text = UiText.Dynamic("No") + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdJoinGroupScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdJoinGroupScreen.kt new file mode 100644 index 000000000..27d669246 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdJoinGroupScreen.kt @@ -0,0 +1,213 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.group + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.components.LoadingOverlay +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomTextField + + +@Composable +fun SnowbirdJoinGroupScreen( + viewModel: SnowbirdJoinGroupViewModel +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + SnowbirdJoinGroupScreenContent( + state = state, + onAction = viewModel::onAction + ) +} + +@Composable +fun SnowbirdJoinGroupScreenContent( + state: SnowbirdJoinGroupState, + onAction: (SnowbirdJoinGroupAction) -> Unit +) { + val focusManager = LocalFocusManager.current + val repoFocusRequester = remember { FocusRequester() } + val scrollState = rememberScrollState() + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 100.dp) + ) { + // Header section (similar to WebDavHeader) + DwebHeader( + modifier = Modifier + .padding(top = 48.dp, bottom = 24.dp) + .padding(end = 24.dp) + ) + + // Group Name field (Read-only extracted from URI) + CustomTextField( + value = state.groupName, + onValueChange = {}, + enabled = false, + placeholder = stringResource(R.string.dweb_join_group_group_name), + isLoading = state.isLoading, + imeAction = ImeAction.Next, + onImeAction = { repoFocusRequester.requestFocus() } + ) + + Spacer(modifier = Modifier.height(ThemeDimensions.spacing.medium)) + + // Repository Name field + CustomTextField( + value = state.repoName, + onValueChange = { onAction(SnowbirdJoinGroupAction.UpdateRepoName(it)) }, + placeholder = stringResource(R.string.dweb_join_group_repo_name), + isLoading = state.isLoading, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + onImeAction = { focusManager.clearFocus() }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.dweb_join_group_screen_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Action Buttons at the bottom + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Back button + TextButton( + modifier = Modifier + .heightIn(ThemeDimensions.touchable) + .weight(1f), + colors = ButtonDefaults.textButtonColors( + contentColor = colorResource(R.color.colorOnBackground) + ), + enabled = !state.isLoading, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { onAction(SnowbirdJoinGroupAction.Cancel) } + ) { + Text( + stringResource(R.string.back), + style = MaterialTheme.typography.titleLarge + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Join Group button + Button( + modifier = Modifier + .heightIn(ThemeDimensions.touchable) + .weight(1f), + enabled = !state.isLoading && state.isFormValid, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + disabledContainerColor = colorResource(R.color.grey_50), + disabledContentColor = colorResource(R.color.black), + contentColor = colorResource(R.color.black) + ), + onClick = { onAction(SnowbirdJoinGroupAction.Authenticate) } + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onTertiary + ) + } else { + Text( + stringResource(R.string.action_next), + style = MaterialTheme.typography.titleLarge + ) + } + } + } + + // Loading Overlay + if (state.isLoading) { + LoadingOverlay() + } + } +} + +@Composable +private fun DwebHeader(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(colorResource(R.color.colorBackgroundSpaceIcon)) + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.size(32.dp), + painter = painterResource(id = R.drawable.ic_dweb), + contentDescription = null, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(colorResource(R.color.colorTertiary)) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(R.string.dweb_join_group_screen_title), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 32.dp) + ) + } +} + +@PreviewLight +@Composable +private fun SnowbirdJoinGroupScreenPreview() { + SaveAppTheme { + SnowbirdJoinGroupScreenContent( + state = SnowbirdJoinGroupState( + groupName = "Test Group", + repoName = "", + scannedUri = "save-veilid://join?name=TestGroup" + ), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdJoinGroupViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdJoinGroupViewModel.kt new file mode 100644 index 000000000..0fdfaa063 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdJoinGroupViewModel.kt @@ -0,0 +1,112 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.group + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.core.domain.DomainResult +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.showErrorDialog +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdGroupRepository +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdRepoRepository +import net.opendasharchive.openarchive.services.snowbird.util.SnowbirdJoinCode +import net.opendasharchive.openarchive.util.ProcessingTracker +import net.opendasharchive.openarchive.util.trackProcessing + +data class SnowbirdJoinGroupState( + val isLoading: Boolean = false, + val scannedUri: String = "", + val groupName: String = "", + val repoName: String = "" +) { + val isFormValid: Boolean get() = scannedUri.isNotBlank() && repoName.isNotBlank() +} + +sealed interface SnowbirdJoinGroupAction { + data class UpdateRepoName(val name: String) : SnowbirdJoinGroupAction + data object Authenticate : SnowbirdJoinGroupAction + data object Cancel : SnowbirdJoinGroupAction +} + + +class SnowbirdJoinGroupViewModel( + private val navigator: Navigator, + private val route: AppRoute.SnowbirdJoinGroupRoute, + private val repository: ISnowbirdGroupRepository, + private val repoRepository: ISnowbirdRepoRepository, + private val dialogManager: DialogStateManager, + private val processingTracker: ProcessingTracker = ProcessingTracker() +) : ViewModel() { + + private val _uiState = MutableStateFlow(SnowbirdJoinGroupState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // Auto-initialize from route arguments + val groupName = SnowbirdJoinCode.extractGroupName(route.groupKey) ?: "" + _uiState.update { it.copy(scannedUri = route.groupKey, groupName = groupName) } + } + + fun onAction(action: SnowbirdJoinGroupAction) { + when (action) { + is SnowbirdJoinGroupAction.UpdateRepoName -> { + _uiState.update { it.copy(repoName = action.name) } + } + + is SnowbirdJoinGroupAction.Authenticate -> { + val state = _uiState.value + joinGroupWithRepo(state.scannedUri, state.repoName) + } + + is SnowbirdJoinGroupAction.Cancel -> { + navigator.navigateBack() + } + } + } + + private fun joinGroupWithRepo(uri: String, repoName: String) { + viewModelScope.launch { + processingTracker.trackProcessing("join_group_with_repo") { + _uiState.update { it.copy(isLoading = true) } + when (val joinResult = repository.joinGroup(uri)) { + is DomainResult.Success -> { + val group = joinResult.data + val groupKey = group.vaultKey.orEmpty() + + val repoResult = repoRepository.createRepo( + vaultId = group.id, + groupKey = groupKey, + repoName = repoName + ) + + when (repoResult) { + is DomainResult.Error -> { + _uiState.update { it.copy(isLoading = false) } + dialogManager.showErrorDialog( + message = UiText.Dynamic(repoResult.error.friendlyMessage) + ) + } + + is DomainResult.Success -> { + navigator.navigateTo(AppRoute.SpaceSetupSuccessRoute(VaultType.DWEB_STORAGE)) + } + } + + } + + is DomainResult.Error -> { + _uiState.update { it.copy(isLoading = false) } + dialogManager.showErrorDialog(message = UiText.Dynamic(joinResult.error.friendlyMessage)) + } + } + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdShareScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdShareScreen.kt new file mode 100644 index 000000000..e077125d1 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdShareScreen.kt @@ -0,0 +1,226 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.group + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.components.LoadingOverlay +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.extensions.asQRCode + + +@Composable +fun SnowbirdShareScreen( + viewModel: SnowbirdShareViewModel, + onShareQr: (String, String) -> Unit // qrContent, groupName — handled at SaveNavGraph level +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is SnowbirdShareEvent.ShareQrImageExternal -> { + onShareQr(state.qrContent, state.groupName) + } + } + } + } + + SnowbirdShareScreenContent( + state = state, + onAction = viewModel::onAction + ) +} + +@Composable +fun SnowbirdShareScreenContent( + state: SnowbirdShareState, + onAction: (SnowbirdShareAction) -> Unit +) { + val context = LocalContext.current + val qrBitmap = remember(state.qrContent) { + if (state.qrContent.isNotBlank()) { + state.qrContent.asQRCode( + onColor = android.graphics.Color.BLACK, + offColor = android.graphics.Color.WHITE + ) + } else null // Return null if content isn't ready yet + } + + Box( + modifier = Modifier + .fillMaxSize() + ) { + // Centered Content Area + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 100.dp), // Leave space for bottom bar + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + + Surface( + modifier = Modifier + .fillMaxWidth() // Makes it as big as possible horizontally + .aspectRatio(1f) // Keeps it square + .clip(RoundedCornerShape(32.dp)), + color = colorResource(R.color.white), + shadowElevation = 8.dp + ) { + Box( + modifier = Modifier.padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + if (qrBitmap != null) { + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = "Group QR Code", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } else if (state.isLoading) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.tertiary) + } + } + } + + + Spacer(modifier = Modifier.height(12.dp)) + + // Group Name + Text( + text = state.groupName, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Join code with copy action + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = state.qrContent, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) + ) + + IconButton( + enabled = state.qrContent.isNotBlank() && !state.isLoading, + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("DWeb Join Code", state.qrContent)) + Toast.makeText(context, "Join code copied", Toast.LENGTH_SHORT).show() + } + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_document), + contentDescription = "Copy code" + ) + } + } + } + + // Bottom Action Bar + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 24.dp, vertical = 24.dp) + ) { + Button( + modifier = Modifier + .height(ThemeDimensions.touchable) + .weight(1f), + enabled = !state.isLoading, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = colorResource(R.color.black) + ), + onClick = { onAction(SnowbirdShareAction.Cancel) } + ) { + Text( + stringResource(R.string.done), + style = MaterialTheme.typography.titleLarge + ) + } + } + + if (state.isLoading) { + LoadingOverlay() + } + } +} + +@PreviewLightDark +@Composable +private fun SnowbirdShareScreenPreview() { + SaveAppTheme { + SnowbirdShareScreenContent( + state = SnowbirdShareState( + groupName = "Test Collective", + qrContent = "save+dweb::?dht=caf0a5ab51d936a0f6cbdb471feee9601714641523168359dce72fcdbb3394e3&enc=5af9ad74efce749b9b2bed692ecb16f71016729a6b11ea021d20f5f9186a1a7d&pk=0f083d9391c5bddf199320014abc9ab3f5b51c9e41adf97c0261e5be6b1ba41d&sk=e1525c792b78cd3aca849b3e2a1b6d3f7e08dd9339f3e872513b88400196c398", + ), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdShareViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdShareViewModel.kt new file mode 100644 index 000000000..2a264e3a2 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/group/SnowbirdShareViewModel.kt @@ -0,0 +1,79 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.group + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.db.DwebDao +import net.opendasharchive.openarchive.services.snowbird.util.SnowbirdJoinCode + +data class SnowbirdShareState( + val isLoading: Boolean = false, + val groupName: String = "", + val qrContent: String = "" +) + +sealed interface SnowbirdShareAction { + data object ShareQrImage : SnowbirdShareAction + data object Cancel : SnowbirdShareAction +} + +sealed interface SnowbirdShareEvent { + data object ShareQrImageExternal : SnowbirdShareEvent +} + +class SnowbirdShareViewModel( + private val navigator: Navigator, + private val route: AppRoute.SnowbirdShareRoute, + private val dwebDao: DwebDao +) : ViewModel() { + + private val _uiState = MutableStateFlow(SnowbirdShareState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + init { + loadGroup(route.groupKey) + } + + fun onAction(action: SnowbirdShareAction) { + when (action) { + is SnowbirdShareAction.ShareQrImage -> { + viewModelScope.launch { + _events.emit(SnowbirdShareEvent.ShareQrImageExternal) + } + } + is SnowbirdShareAction.Cancel -> { + navigator.navigateBack() + } + } + } + + private fun loadGroup(groupKey: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val vaultWithDweb = dwebDao.getVaultWithDwebByKey(groupKey) + val groupName = vaultWithDweb?.vault?.name ?: "Unknown Group" + val groupUri = vaultWithDweb?.vault?.host?.trim().orEmpty() + val qrContent = SnowbirdJoinCode.build(groupUri, groupName) + + _uiState.update { + it.copy( + isLoading = false, + groupName = groupName, + qrContent = qrContent + ) + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/qrscanner/QRScannerScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/qrscanner/QRScannerScreen.kt new file mode 100644 index 000000000..7f4353bbf --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/qrscanner/QRScannerScreen.kt @@ -0,0 +1,162 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.qrscanner + +import android.graphics.BitmapFactory +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.components.QRScanner +import net.opendasharchive.openarchive.core.presentation.theme.SaveTextStyles +import net.opendasharchive.openarchive.features.core.ComposeAppBar +import net.opendasharchive.openarchive.services.snowbird.util.SnowbirdQRDecoder +import net.opendasharchive.openarchive.util.rememberComposePermissionManager + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QRScannerScreen( + onQrCodeScanned: (String) -> Unit, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val permissionManager = rememberComposePermissionManager() + + LaunchedEffect(Unit) { + permissionManager.checkCameraPermission(onGranted = {}) + } + + // Launcher for picking QR image from gallery + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { + scope.launch { + val decoded = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(it)?.use { input -> + BitmapFactory.decodeStream(input) + }?.let { bitmap -> + SnowbirdQRDecoder.decodeFromBitmap(bitmap) + } + }.getOrNull() + } + + if (!decoded.isNullOrBlank()) { + onQrCodeScanned(decoded) + } else { + Toast.makeText( + context, + "No QR code found in selected image.", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + Scaffold( + topBar = { + ComposeAppBar( + title = stringResource(R.string.scan_qr_code), + onNavigateBack = onNavigateBack + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.qr_scanner_instruction), + style = SaveTextStyles.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + + Spacer(modifier = Modifier.weight(0.8f)) + + // QR Scanner - Large centered square + Box( + modifier = Modifier + .fillMaxWidth(0.95f) + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + ) { + if (permissionManager.isCameraGranted()) { + QRScanner( + modifier = Modifier.fillMaxSize(), + onQrCodeScanned = { result -> + onQrCodeScanned(result) + } + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Camera permission is required to scan QR codes", + style = SaveTextStyles.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + TextButton( + onClick = { galleryLauncher.launch("image/*") } + ) { + Text( + text = stringResource(R.string.open_from_gallery), + style = SaveTextStyles.titleMedium, + color = MaterialTheme.colorScheme.tertiary + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/repo/SnowbirdRepoListScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/repo/SnowbirdRepoListScreen.kt new file mode 100644 index 000000000..ef048a598 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/repo/SnowbirdRepoListScreen.kt @@ -0,0 +1,238 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.repo + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.ArchivePermission +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.core.presentation.theme.SaveTextStyles + + +@Composable +fun SnowbirdRepoListScreen( + viewModel: SnowbirdRepoViewModel +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + SnowbirdRepoListContent( + state = state, + onAction = viewModel::onAction + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SnowbirdRepoListContent( + state: SnowbirdRepoState, + onAction: (SnowbirdRepoAction) -> Unit +) { + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = { onAction(SnowbirdRepoAction.RefreshRepos) }, + modifier = Modifier.fillMaxSize() + ) { + if (state.repos.isEmpty() && !state.isLoading) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "No repositories found") + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { onAction(SnowbirdRepoAction.RefreshGroupContent) }) { + Text("Refresh Content") + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.repos) { repo -> + SnowbirdRepoItem( + repo = repo, + onClick = { onAction(SnowbirdRepoAction.SelectRepo(repo)) } + ) + } + } + } + } +} + +@Composable +fun SnowbirdRepoItem( + repo: Archive, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + repo.permissions?.let { permission -> + Box(modifier = Modifier.width(48.dp), contentAlignment = Alignment.Center) { + RepoPermissionBadge(permission = permission) + } + } ?: Box(modifier = Modifier.width(48.dp), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(id = R.drawable.ic_dweb), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + } + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = repo.description ?: "N/A", + style = SaveTextStyles.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (!repo.archiveKey.isNullOrBlank()) { + Text( + text = "Key: ${repo.archiveKey}", + style = SaveTextStyles.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_right_ios), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun RepoPermissionBadge( + permission: ArchivePermission, + modifier: Modifier = Modifier +) { + + val bgColor = colorResource(id = R.color.c23_teal) + + val label = when (permission) { + ArchivePermission.READ_ONLY -> "RO" + ArchivePermission.READ_WRITE -> "RW" + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(bgColor) + .padding(horizontal = 6.dp, vertical = 2.dp) + .size(width = 28.dp, height = 21.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + ) + } +} + + +@PreviewLightDark +@Composable +private fun SnowbirdRepoListScreenPreview() { + SaveAppTheme { + SnowbirdRepoListContent( + state = SnowbirdRepoState( + repos = listOf( + Archive( + description = "Main Repository", + archiveKey = "key1", + permissions = ArchivePermission.READ_ONLY + ), + Archive( + description = "Backup Repository", + archiveKey = "key2", + permissions = ArchivePermission.READ_WRITE + ), + Archive( + description = "Shared Repository", + archiveKey = "key3", + permissions = null + ) + ) + ), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun SnowbirdRepoListScreenEmptyPreview() { + DefaultScaffoldPreview { + SnowbirdRepoListContent( + state = SnowbirdRepoState(repos = emptyList()), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun SnowbirdRepoListScreenLoadingPreview() { + DefaultScaffoldPreview { + SnowbirdRepoListContent( + state = SnowbirdRepoState(repos = emptyList(), isLoading = true), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/repo/SnowbirdRepoViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/repo/SnowbirdRepoViewModel.kt new file mode 100644 index 000000000..9dd8a22a3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/presentation/repo/SnowbirdRepoViewModel.kt @@ -0,0 +1,140 @@ +package net.opendasharchive.openarchive.services.snowbird.presentation.repo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.ArchivePermission +import net.opendasharchive.openarchive.core.domain.DomainResult +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.showErrorDialog +import net.opendasharchive.openarchive.services.snowbird.service.repository.ISnowbirdRepoRepository +import net.opendasharchive.openarchive.util.ProcessingTracker +import net.opendasharchive.openarchive.util.trackProcessing + +data class SnowbirdRepoState( + val repos: List = emptyList(), + val isLoading: Boolean = false, + val vaultId: Long = 0, + val groupKey: String = "" +) + +sealed interface SnowbirdRepoAction { + data class SelectRepo(val repo: Archive) : SnowbirdRepoAction + data object RefreshRepos : SnowbirdRepoAction + data object RefreshGroupContent : SnowbirdRepoAction +} + + + +class SnowbirdRepoViewModel( + private val navigator: Navigator, + private val route: AppRoute.SnowbirdRepoListRoute, + private val repository: ISnowbirdRepoRepository, + private val dialogManager: DialogStateManager, + private val processingTracker: ProcessingTracker = ProcessingTracker() +) : ViewModel() { + + private val _uiState = MutableStateFlow( + SnowbirdRepoState( + vaultId = route.vaultId, + groupKey = route.groupKey + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + observeRepos(route.vaultId) + fetchRepos() + } + + fun onAction(action: SnowbirdRepoAction) { + when (action) { + is SnowbirdRepoAction.SelectRepo -> { + navigator.navigateTo( + AppRoute.SnowbirdFileListRoute( + archiveId = action.repo.id, + groupKey = _uiState.value.groupKey, + repoKey = action.repo.archiveKey ?: "", + canWrite = action.repo.permissions == ArchivePermission.READ_WRITE + ) + ) + } + is SnowbirdRepoAction.RefreshRepos -> fetchRepos(forceRefresh = true) + is SnowbirdRepoAction.RefreshGroupContent -> refreshGroupContent() + } + } + + private fun observeRepos(vaultId: Long) { + repository.observeRepos(vaultId) + .onEach { repos -> + _uiState.update { it.copy(repos = repos) } + } + .launchIn(viewModelScope) + } + + private fun fetchRepos(forceRefresh: Boolean = false) { + val vaultId = _uiState.value.vaultId + val groupKey = _uiState.value.groupKey + if (vaultId == 0L || groupKey.isBlank()) return + + viewModelScope.launch { + processingTracker.trackProcessing("fetch_repos") { + _uiState.update { it.copy(isLoading = true) } + val result = repository.fetchRepos(vaultId, groupKey, forceRefresh) + _uiState.update { it.copy(isLoading = false) } + + if (result is DomainResult.Error) { + dialogManager.showErrorDialog(message = UiText.Dynamic(result.error.friendlyMessage)) + } + } + } + } + + private fun refreshGroupContent() { + val groupKey = _uiState.value.groupKey + viewModelScope.launch { + processingTracker.trackProcessing("refresh_group_content") { + _uiState.update { it.copy(isLoading = true) } + val result = repository.refreshGroupContent(groupKey) + _uiState.update { it.copy(isLoading = false) } + + when (result) { + is DomainResult.Error -> { + dialogManager.showErrorDialog(message = UiText.Dynamic(result.error.friendlyMessage)) + } + is DomainResult.Success -> { + val repoErrors = result.data.refreshedRepos.mapNotNull { repo -> + val error = repo.error?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val bucket = classifyRefreshError(error) + "Repo ${repo.name} (${repo.repoId}): $bucket - $error" + } + if (repoErrors.isNotEmpty()) { + val summary = repoErrors.take(8).joinToString("\n") + val suffix = if (repoErrors.size > 8) "\n... and ${repoErrors.size - 8} more" else "" + dialogManager.showErrorDialog( + message = UiText.Dynamic( + "Some repositories failed to refresh:\n$summary$suffix" + ) + ) + } + fetchRepos(forceRefresh = true) + } + } + } + } + } + + private fun classifyRefreshError(message: String): String { + val value = message.lowercase() + return when { + "dht" in value || "repo root hash" in value -> "DHT_DISCOVERY" + "download from any peer" in value || "any peer" in value -> "PEER_DOWNLOAD" + else -> "UNKNOWN" + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/ISnowbirdAPI.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/ISnowbirdAPI.kt index ac2b56913..ae2b9b2b7 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/ISnowbirdAPI.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/ISnowbirdAPI.kt @@ -1,32 +1,22 @@ package net.opendasharchive.openarchive.services.snowbird.service import android.net.Uri -import net.opendasharchive.openarchive.db.CreateRepoResponse -import net.opendasharchive.openarchive.db.FileUploadResult -import net.opendasharchive.openarchive.db.JoinGroupResponse -import net.opendasharchive.openarchive.db.MembershipRequest -import net.opendasharchive.openarchive.db.RefreshGroupResponse -import net.opendasharchive.openarchive.db.RequestName -import net.opendasharchive.openarchive.db.SnowbirdFileList -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.db.SnowbirdGroupList -import net.opendasharchive.openarchive.db.SnowbirdRepo -import net.opendasharchive.openarchive.db.SnowbirdRepoList +import net.opendasharchive.openarchive.services.snowbird.data.* interface ISnowbirdAPI { // Media - suspend fun fetchFiles(groupKey: String, repoKey: String): SnowbirdFileList + suspend fun fetchFiles(groupKey: String, repoKey: String): SnowbirdFileListDTO suspend fun downloadFile(groupKey: String, repoKey: String, filename: String): ByteArray suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): FileUploadResult // Groups - suspend fun createGroup(groupName: RequestName): SnowbirdGroup - suspend fun fetchGroup(key: String): SnowbirdGroup - suspend fun fetchGroups(): SnowbirdGroupList + suspend fun createGroup(groupName: RequestName): SnowbirdGroupDTO + suspend fun fetchGroup(key: String): SnowbirdGroupDTO + suspend fun fetchGroups(): SnowbirdGroupListDTO suspend fun joinGroup(request: MembershipRequest): JoinGroupResponse suspend fun refreshGroupContent(groupKey: String): RefreshGroupResponse // Repos - suspend fun createRepo(groupKey: String, repoName: RequestName): CreateRepoResponse - suspend fun fetchRepos(groupKey: String): SnowbirdRepoList + suspend fun createRepo(groupKey: String, repoName: RequestName): SnowbirdRepoDTO + suspend fun fetchRepos(groupKey: String): SnowbirdRepoListDTO } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/RetrofitAPI.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/RetrofitAPI.kt index ae15dc350..621b2a31c 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/RetrofitAPI.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/RetrofitAPI.kt @@ -2,40 +2,89 @@ package net.opendasharchive.openarchive.services.snowbird.service import android.content.Context import android.net.Uri -import net.opendasharchive.openarchive.db.CreateRepoResponse -import net.opendasharchive.openarchive.db.FileUploadResult -import net.opendasharchive.openarchive.db.JoinGroupResponse -import net.opendasharchive.openarchive.db.MembershipRequest -import net.opendasharchive.openarchive.db.RefreshGroupResponse -import net.opendasharchive.openarchive.db.RequestName -import net.opendasharchive.openarchive.db.SnowbirdFileList -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.db.SnowbirdGroupList -import net.opendasharchive.openarchive.db.SnowbirdRepo -import net.opendasharchive.openarchive.db.SnowbirdRepoList +import kotlinx.serialization.json.Json import net.opendasharchive.openarchive.extensions.getFilename +import net.opendasharchive.openarchive.services.snowbird.data.FileUploadResult +import net.opendasharchive.openarchive.services.snowbird.data.JoinGroupResponse +import net.opendasharchive.openarchive.services.snowbird.data.MembershipRequest +import net.opendasharchive.openarchive.services.snowbird.data.RefreshGroupResponse +import net.opendasharchive.openarchive.services.snowbird.data.RequestName +import net.opendasharchive.openarchive.services.snowbird.data.SnowbirdFileListDTO +import net.opendasharchive.openarchive.services.snowbird.data.SnowbirdGroupDTO +import net.opendasharchive.openarchive.services.snowbird.data.SnowbirdGroupListDTO +import net.opendasharchive.openarchive.services.snowbird.data.SnowbirdRepoDTO +import net.opendasharchive.openarchive.services.snowbird.data.SnowbirdRepoListDTO import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody import okio.BufferedSink import okio.source +import retrofit2.Response +import java.io.IOException class RetrofitAPI(private var context: Context, private val client: RetrofitClient) : ISnowbirdAPI { + private val json = Json { ignoreUnknownKeys = true } + + private fun ensureServerReadyForNetwork() { + if (SnowbirdService.getCurrentStatus() !is ServiceStatus.Connected) { + throw IOException("DWeb server is not ready. Enable DWeb Server and wait for it to connect.") + } + } + + private suspend fun safeApiCall(call: suspend () -> Response): T { + try { + ensureServerReadyForNetwork() + val response = call() + if (response.isSuccessful) { + return response.body() ?: throw IOException("Empty response body") + } else { + val errorBody = response.errorBody()?.string() + val errorMessage = parseError(errorBody) ?: "Unknown error occurred (Code: ${response.code()})" + throw IOException(errorMessage) + } + } catch (e: Exception) { + if (e is IOException) throw e + throw IOException("Network error: ${e.localizedMessage ?: "Unknown error"}", e) + } + } + + private fun parseError(errorBody: String?): String? { + if (errorBody == null) return null + return try { + // Try to parse as JSON string (it's often quoted like "Error message") + json.decodeFromString(errorBody) + } catch (e: Exception) { + // If it's not a valid JSON string, just return it as is (stripped of whitespace) + errorBody.trim().removeSurrounding("\"") + } + } // Groups // Create group - override suspend fun createGroup(groupName: RequestName): SnowbirdGroup { - return client.createGroup(groupName) + override suspend fun createGroup(groupName: RequestName): SnowbirdGroupDTO { + return safeApiCall { client.createGroup(groupName) } } - override suspend fun fetchFiles(groupKey: String, repoKey: String): SnowbirdFileList { - return client.fetchFiles(groupKey, repoKey) + override suspend fun fetchFiles(groupKey: String, repoKey: String): SnowbirdFileListDTO { + return safeApiCall { client.fetchFiles(groupKey, repoKey) } } override suspend fun downloadFile(groupKey: String, repoKey: String, filename: String): ByteArray { - val responseBody = client.downloadFile(groupKey, repoKey, filename) - return responseBody.bytes() + try { + ensureServerReadyForNetwork() + val response = client.downloadFile(groupKey, repoKey, filename) + if (response.isSuccessful) { + return response.body()?.bytes() ?: throw IOException("Empty response body") + } + + val errorBody = response.errorBody()?.string() + val errorMessage = parseError(errorBody) ?: "Unknown error occurred (Code: ${response.code()})" + throw IOException(errorMessage) + } catch (e: Exception) { + if (e is IOException) throw e + throw IOException("Network error: ${e.localizedMessage ?: "Unknown error"}", e) + } } override suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): FileUploadResult { @@ -59,37 +108,39 @@ class RetrofitAPI(private var context: Context, private val client: RetrofitClie // Encode for the path segment Rust expects as {file_name} val encodedFilename = Uri.encode(uri.getFilename(context) ?: "upload.bin") - return client.uploadFile( - groupKey = groupKey, - repoKey = repoKey, - filename = encodedFilename, - imageData = requestBody - ) + return safeApiCall { + client.uploadFile( + groupKey = groupKey, + repoKey = repoKey, + filename = encodedFilename, + imageData = requestBody + ) + } } - override suspend fun fetchGroup(key: String): SnowbirdGroup { - return client.fetchGroup(key) + override suspend fun fetchGroup(key: String): SnowbirdGroupDTO { + return safeApiCall { client.fetchGroup(key) } } - override suspend fun fetchGroups(): SnowbirdGroupList { - return client.fetchGroups() + override suspend fun fetchGroups(): SnowbirdGroupListDTO { + return safeApiCall { client.fetchGroups() } } override suspend fun joinGroup(request: MembershipRequest): JoinGroupResponse { - return client.joinGroup(request) + return safeApiCall { client.joinGroup(request) } } override suspend fun refreshGroupContent(groupKey: String): RefreshGroupResponse { - return client.refreshGroup(groupKey) + return safeApiCall { client.refreshGroup(groupKey) } } - override suspend fun createRepo(groupKey: String, repoName: RequestName): CreateRepoResponse { - return client.createRepo(groupKey, repoName) + override suspend fun createRepo(groupKey: String, repoName: RequestName): SnowbirdRepoDTO { + return safeApiCall { client.createRepo(groupKey, repoName) } } - override suspend fun fetchRepos(groupKey: String): SnowbirdRepoList { - return client.fetchRepos(groupKey) + override suspend fun fetchRepos(groupKey: String): SnowbirdRepoListDTO { + return safeApiCall { client.fetchRepos(groupKey) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/RetrofitClient.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/RetrofitClient.kt index 10c4bd165..9e6d47ffd 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/RetrofitClient.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/RetrofitClient.kt @@ -1,17 +1,9 @@ package net.opendasharchive.openarchive.services.snowbird.service -import net.opendasharchive.openarchive.db.CreateRepoResponse -import net.opendasharchive.openarchive.db.FileUploadResult -import net.opendasharchive.openarchive.db.JoinGroupResponse -import net.opendasharchive.openarchive.db.MembershipRequest -import net.opendasharchive.openarchive.db.RefreshGroupResponse -import net.opendasharchive.openarchive.db.RequestName -import net.opendasharchive.openarchive.db.SnowbirdFileList -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.db.SnowbirdGroupList -import net.opendasharchive.openarchive.db.SnowbirdRepoList +import net.opendasharchive.openarchive.services.snowbird.data.* import okhttp3.RequestBody import okhttp3.ResponseBody +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Headers @@ -26,14 +18,14 @@ interface RetrofitClient { suspend fun fetchFiles( @Path("groupKey") groupKey: String, @Path("repoKey") repoKey: String - ): SnowbirdFileList + ): Response @GET("groups/{groupKey}/repos/{repoKey}/media/{filename}") suspend fun downloadFile( @Path("groupKey") groupKey: String, @Path("repoKey") repoKey: String, @Path("filename") filename: String - ): ResponseBody + ): Response @POST("groups/{groupKey}/repos/{repoKey}/media/{filename}") @Headers("Content-Type: application/octet-stream") @@ -42,32 +34,32 @@ interface RetrofitClient { @Path("repoKey") repoKey: String, @Path(value = "filename", encoded = true) filename: String, @Body imageData: RequestBody - ): FileUploadResult + ): Response // Groups @POST("groups") suspend fun createGroup( @Body groupName: RequestName - ): SnowbirdGroup + ): Response @GET("groups/{groupKey}") suspend fun fetchGroup( @Path("groupKey") groupKey: String - ): SnowbirdGroup + ): Response @GET("groups") - suspend fun fetchGroups(): SnowbirdGroupList + suspend fun fetchGroups(): Response @POST("memberships") suspend fun joinGroup( @Body request: MembershipRequest - ): JoinGroupResponse + ): Response @POST("groups/{group_id}/refresh") suspend fun refreshGroup( @Path("group_id") groupKey: String - ): RefreshGroupResponse + ): Response // Repos @@ -75,10 +67,10 @@ interface RetrofitClient { suspend fun createRepo( @Path("groupKey") groupKey: String, @Body repoName: RequestName - ): CreateRepoResponse + ): Response @GET("groups/{groupKey}/repos") suspend fun fetchRepos( @Path("groupKey") groupKey: String - ): SnowbirdRepoList -} \ No newline at end of file + ): Response +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdConduit.kt similarity index 58% rename from app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdConduit.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdConduit.kt index d0a3f7b4c..eb990ade8 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdConduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdConduit.kt @@ -1,11 +1,11 @@ -package net.opendasharchive.openarchive.services.snowbird +package net.opendasharchive.openarchive.services.snowbird.service import android.content.Context -import net.opendasharchive.openarchive.db.Media +import net.opendasharchive.openarchive.core.domain.Evidence import net.opendasharchive.openarchive.services.Conduit import timber.log.Timber -class SnowbirdConduit (media: Media, context: Context) : Conduit(media, context) { +class SnowbirdConduit (evidence: Evidence, context: Context) : Conduit(evidence, context) { override suspend fun upload(): Boolean { Timber.d("upload") return true diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt index 25f856615..1e93c8b4e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt @@ -5,6 +5,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import kotlinx.coroutines.CoroutineScope @@ -16,14 +17,22 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.SaveApp +import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.extensions.RetryAttempt import net.opendasharchive.openarchive.extensions.retryWithScope import net.opendasharchive.openarchive.extensions.suspendToRetry -import net.opendasharchive.openarchive.features.main.MainActivity +import net.opendasharchive.openarchive.features.main.HomeActivity import net.opendasharchive.openarchive.services.snowbird.SnowbirdBridge +import net.opendasharchive.openarchive.services.tor.TorServiceManager +import net.opendasharchive.openarchive.services.tor.TorStatus +import net.opendasharchive.openarchive.util.Prefs +import org.koin.android.ext.android.inject import timber.log.Timber import java.io.File import java.io.IOException @@ -31,12 +40,12 @@ import java.net.ConnectException import java.net.HttpURLConnection import java.net.SocketTimeoutException import java.net.URL -import java.nio.file.Files -import kotlin.io.path.Path import kotlin.time.Duration.Companion.seconds class SnowbirdService : Service() { + private val torServiceManager: TorServiceManager by inject() + companion object { var DEFAULT_BACKEND_DIRECTORY = "" private set @@ -85,13 +94,60 @@ class SnowbirdService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - startForeground( - SaveApp.SNOWBIRD_SERVICE_ID, - createNotification("Snowbird Server is starting up.") - ) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + SaveApp.SNOWBIRD_SERVICE_ID, + createNotification("Snowbird Server is starting up."), + android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground( + SaveApp.SNOWBIRD_SERVICE_ID, + createNotification("Snowbird Server is starting up."), + ) + } + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + e is android.app.ForegroundServiceStartNotAllowedException + ) { + Timber.w("Cannot start foreground service from background, stopping: ${e.message}") + updateStatus(ServiceStatus.Failed(e)) + stopSelf() + return START_NOT_STICKY + } + throw e + } - // Launch a coroutine to check & start + // Launch startup flow serviceScope.launch { + // Wait for Tor to be ready before initialising the Rust bridge. + // This covers both fresh launches and START_STICKY restarts — in both + // cases the Tokio thread-pool must not compete with Tor's bootstrap. + if (Prefs.useTor) { + updateNotification("Waiting for Tor...") + val ready = withTimeoutOrNull(60_000L) { + torServiceManager.torStatus + .filter { it is TorStatus.On || it is TorStatus.Verified } + .first() + } + if (ready == null) { + Timber.w("SnowbirdService: Tor wait timed out after 60 s — proceeding anyway") + } else { + Timber.d("SnowbirdService: Tor is ready, starting bridge") + } + } + + // Initialize bridge first to reduce startup race windows. + try { + withContext(Dispatchers.IO) { + SnowbirdBridge.getInstance().initialize() + } + } catch (e: Exception) { + Timber.e(e, "SnowbirdBridge.initialize() failed") + // Continue; startServer may still surface a clearer error. + } + val alreadyUp = isServerRunning() if (alreadyUp) { Timber.d("Snowbird server already running; skipping start()") @@ -132,7 +188,7 @@ class SnowbirdService : Service() { updateStatus(ServiceStatus.Stopped) Timber.d("Server shutdown complete") } catch (e: Exception) { - Timber.e(e, "Error stopping server") + AppLogger.e( "Error stopping server", e) updateStatus(ServiceStatus.Failed(e)) } } @@ -142,17 +198,8 @@ class SnowbirdService : Service() { override fun onBind(intent: Intent?): IBinder? = null - /** - * Checks if a web server is available and responding with a 200 OK status. - * Throws exceptions on failure for better integration with retry mechanisms. - * - * @param url The URL to check - * @param timeout Optional timeout in milliseconds (default 5000ms) - * @throws ConnectException if the server refuses connection - * @throws SocketTimeoutException if the connection times out - * @throws IOException for other network-related errors - */ - private suspend fun checkServerAvailability(url: String, timeout: Int = 1000) { + /** Checks if a URL is reachable and returns 2xx. Throws on failure. */ + private suspend fun checkServerAvailability(url: String, timeout: Int = 8000) { withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null try { @@ -164,7 +211,7 @@ class SnowbirdService : Service() { } when (connection.responseCode) { - HttpURLConnection.HTTP_OK -> return@withContext + in 200..299 -> return@withContext else -> throw IOException("Server returned ${connection.responseCode}") } } catch (e: Exception) { @@ -176,13 +223,19 @@ class SnowbirdService : Service() { } } + /** `/status` proves liveness; `/health/ready` proves backend initialization. */ + private suspend fun checkFullReadiness() { + checkServerAvailability("http://localhost:8080/status") + checkServerAvailability("http://localhost:8080/health/ready", timeout = 8000) + } + private fun createNotification(text: String, withSound: Boolean = false): Notification { val channelId = if (withSound) SaveApp.SNOWBIRD_SERVICE_CHANNEL_CHIME else SaveApp.SNOWBIRD_SERVICE_CHANNEL_SILENT val pendingIntent: PendingIntent = Intent( this, - MainActivity::class.java + HomeActivity::class.java ).let { notificationIntent -> PendingIntent.getActivity( this, @@ -208,7 +261,7 @@ class SnowbirdService : Service() { Timber.d("Starting polling") pollingJob?.cancel() // Cancel any existing polling - pollingJob = suspendToRetry { checkServerAvailability("http://localhost:8080/status") } + pollingJob = suspendToRetry { checkFullReadiness() } .retryWithScope( scope = serviceScope, config = RetryConfig( @@ -220,7 +273,8 @@ class SnowbirdService : Service() { shouldRetry = { error -> when (error) { is ConnectException, - is SocketTimeoutException -> true + is SocketTimeoutException, + is IOException -> true else -> false } @@ -245,7 +299,7 @@ class SnowbirdService : Service() { val errorMessage = attempt.error.message ?: "Unknown error" updateStatus(ServiceStatus.Failed(attempt.error)) updateNotification("Connection Failed: $errorMessage") - Timber.e(attempt.error) + AppLogger.e(attempt.error) stopPolling() } } @@ -306,4 +360,4 @@ sealed class ServiceStatus { data object Connecting : ServiceStatus() data object Connected : ServiceStatus() data class Failed(val error: Throwable) : ServiceStatus() -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdServiceController.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdServiceController.kt new file mode 100644 index 000000000..4546436a9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdServiceController.kt @@ -0,0 +1,24 @@ +package net.opendasharchive.openarchive.services.snowbird.service + +import android.content.Context +import android.content.Intent + +interface SnowbirdServiceController { + fun startService() + fun stopService() +} + +class SnowbirdServiceControllerImpl( + private val context: Context +) : SnowbirdServiceController { + + override fun startService() { + val intent = Intent(context, SnowbirdService::class.java) + context.startForegroundService(intent) + } + + override fun stopService() { + val intent = Intent(context, SnowbirdService::class.java) + context.stopService(intent) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdServiceStatus.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdServiceStatus.kt similarity index 95% rename from app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdServiceStatus.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdServiceStatus.kt index c1d362f12..3290eebf4 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdServiceStatus.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdServiceStatus.kt @@ -1,4 +1,4 @@ -package net.opendasharchive.openarchive.services.snowbird +package net.opendasharchive.openarchive.services.snowbird.service sealed class SnowbirdServiceStatus { data object BackendInitializing : SnowbirdServiceStatus() diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/UnixSocketAPI.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/UnixSocketAPI.kt deleted file mode 100644 index 8c17cbed6..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/UnixSocketAPI.kt +++ /dev/null @@ -1,131 +0,0 @@ -package net.opendasharchive.openarchive.services.snowbird.service - -import android.content.Context -import android.net.Uri -import kotlinx.serialization.Serializable -import net.opendasharchive.openarchive.db.CreateRepoResponse -import net.opendasharchive.openarchive.db.EmptyRequest -import net.opendasharchive.openarchive.db.FileUploadResult -import net.opendasharchive.openarchive.db.JoinGroupResponse -import net.opendasharchive.openarchive.db.MembershipRequest -import net.opendasharchive.openarchive.db.RefreshGroupResponse -import net.opendasharchive.openarchive.db.RequestName -import net.opendasharchive.openarchive.db.SerializableMarker -import net.opendasharchive.openarchive.db.SnowbirdFileList -import net.opendasharchive.openarchive.db.SnowbirdGroup -import net.opendasharchive.openarchive.db.SnowbirdGroupList -import net.opendasharchive.openarchive.db.SnowbirdRepo -import net.opendasharchive.openarchive.db.SnowbirdRepoList -import net.opendasharchive.openarchive.extensions.createInputStream -import net.opendasharchive.openarchive.extensions.getFilename -import net.opendasharchive.openarchive.features.main.HttpMethod -import net.opendasharchive.openarchive.features.main.UnixSocketClient -import net.opendasharchive.openarchive.features.main.downloadFile -import net.opendasharchive.openarchive.features.main.uploadFile -import java.io.FileNotFoundException -import java.io.IOException - -class UnixSocketAPI(private var context: Context, private var client: UnixSocketClient) : - ISnowbirdAPI { - - companion object { - private const val BASE_PATH = "/api" - const val MEMBERSHIPS_PATH = "$BASE_PATH/memberships" - const val GROUPS_PATH = "$BASE_PATH/groups" - const val REPOS_PATH = "$BASE_PATH/groups/%s/repos" - const val MEDIA_PATH = "$BASE_PATH/groups/%s/repos/%s/media" - const val MEDIA_PATH_UPLOAD = "$BASE_PATH/groups/%s/repos/%s/media/%s" - const val FORCE_REFRESH = "$BASE_PATH/groups/%s/refresh" - } - - // Media - - override suspend fun fetchFiles(groupKey: String, repoKey: String): SnowbirdFileList { - return client.sendRequest( - endpoint = MEDIA_PATH.format(groupKey, repoKey), - method = HttpMethod.GET - ) - } - - override suspend fun downloadFile( - groupKey: String, - repoKey: String, - filename: String - ): ByteArray { - return client.downloadFile( - endpoint = MEDIA_PATH_UPLOAD.format(groupKey, repoKey, filename) - ) - } - - override suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): FileUploadResult { - val inputStream = - uri.createInputStream(context) ?: throw IOException("Unable to create input stream") - val filename = uri.getFilename(context) - ?: throw FileNotFoundException("Unable to get filename from Uri") - - return client.uploadFile( - endpoint = MEDIA_PATH_UPLOAD.format(groupKey, repoKey, filename), - inputStream = inputStream - ) - } - - // Groups - - override suspend fun createGroup(groupName: RequestName): SnowbirdGroup { - return client.sendRequest( - endpoint = GROUPS_PATH, - method = HttpMethod.POST, - body = groupName - ) - } - - override suspend fun fetchGroup(key: String): SnowbirdGroup { - return client.sendRequest( - endpoint = "$GROUPS_PATH/$key", - method = HttpMethod.GET - ) - } - - override suspend fun fetchGroups(): SnowbirdGroupList { - return client.sendRequest( - endpoint = GROUPS_PATH, - method = HttpMethod.GET - ) - } - - override suspend fun joinGroup(request: MembershipRequest): JoinGroupResponse { - return client.sendRequest( - endpoint = MEMBERSHIPS_PATH, - method = HttpMethod.POST, - body = request - ) - } - - override suspend fun refreshGroupContent(groupKey: String): RefreshGroupResponse { - return client.sendRequest( - endpoint = FORCE_REFRESH.format(groupKey), - method = HttpMethod.POST, - ) - } - - override suspend fun createRepo(groupKey: String, repoName: RequestName): CreateRepoResponse { - return client.sendRequest( - endpoint = REPOS_PATH.format(groupKey), - HttpMethod.POST, - body = repoName - ) - } - - override suspend fun fetchRepos(groupKey: String): SnowbirdRepoList { - return client.sendRequest( - endpoint = REPOS_PATH.format(groupKey), - method = HttpMethod.GET - ) - } -} - -@Serializable -data class ErrorResponse( - val error: String, - val status: String -) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/MockSnowbirdRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/MockSnowbirdRepository.kt new file mode 100644 index 000000000..d2fa05d92 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/MockSnowbirdRepository.kt @@ -0,0 +1,299 @@ +package net.opendasharchive.openarchive.services.snowbird.service.repository + +import android.content.res.AssetManager +import android.net.Uri +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import net.opendasharchive.openarchive.core.config.AppConfig +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.DomainError +import net.opendasharchive.openarchive.core.domain.DomainResult +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.db.* +import net.opendasharchive.openarchive.services.snowbird.data.* +import net.opendasharchive.openarchive.util.DateUtils + +class MockSnowbirdGroupRepository( + private val assetManager: AssetManager, + private val config: AppConfig, + private val vaultDao: VaultDao, + private val dwebDao: DwebDao +) : ISnowbirdGroupRepository { + + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun createGroup(groupName: String): DomainResult { + delay(config.mockDelayMs) + if (config.simulateErrors) return DomainResult.Error(DomainError.Server("Simulated error creating group")) + + return try { + val content = assetManager.open("dweb/dweb_create_group_response.json").bufferedReader().use { it.readText() } + val dto = json.decodeFromString(content) + + // Persist to Room + val vaultEntity = dto.toVaultEntity() + val vaultId = vaultDao.upsert(vaultEntity) + val dwebEntity = dto.toDwebEntity(vaultId) + dwebDao.upsertVaultMetadata(dwebEntity) + + val savedVaultWithDweb = dwebDao.getVaultWithDwebById(vaultId) + val savedVault = savedVaultWithDweb?.toDomain() + + if (savedVault != null) { + DomainResult.Success(savedVault) + } else { + DomainResult.Error(DomainError.Unknown("Failed to retrieve saved mock group")) + } + } catch (e: Exception) { + DomainResult.Error(DomainError.Unknown("Failed to parse mock data: ${e.message}")) + } + } + + override suspend fun fetchGroups(forceRefresh: Boolean): DomainResult { + delay(config.mockDelayMs) + if (config.simulateErrors) return DomainResult.Error(DomainError.Network("Simulated network error fetching groups")) + + return try { + val content = assetManager.open("dweb/dweb_fetch_groups_response.json").bufferedReader().use { it.readText() } + val dtos = json.decodeFromString(content).groups + + dtos.forEach { dto -> + // Deduplication lookup STRICTLY by DWeb key + val existingVaultId = dwebDao.getVaultIdByKey(dto.key) + + // Map using the fixed ID (if exists) or 0 (for new) + val vaultEntity = dto.toVaultEntity(id = existingVaultId ?: 0L) + val upsertedId = vaultDao.upsert(vaultEntity) + val vaultId = existingVaultId ?: upsertedId + + val dwebEntity = dto.toDwebEntity(vaultId) + dwebDao.upsertVaultMetadata(dwebEntity) + } + DomainResult.Success(Unit) + } catch (e: Exception) { + e.printStackTrace() + DomainResult.Error(DomainError.Unknown("Failed to parse mock data: ${e.message}")) + } + } + + override fun observeGroups(): Flow> { + return dwebDao.observeVaultsWithDweb().map { vaultList -> + vaultList.map { it.toDomain() } + } + } + + override suspend fun getVaultIdByKey(groupKey: String): Long? { + return dwebDao.getVaultIdByKey(groupKey) + } + + override suspend fun joinGroup(uriString: String): DomainResult { + delay(config.mockDelayMs) + if (config.simulateErrors) return DomainResult.Error(DomainError.Unknown("Simulated error joining group")) + + val content = assetManager.open("dweb/dweb_create_group_response.json").bufferedReader().use { it.readText() } + val groupDto = json.decodeFromString(content).copy( + uri = uriString + ) + + val existingVaultId = dwebDao.getVaultIdByKey(groupDto.key) + val vaultEntity = groupDto.toVaultEntity(id = existingVaultId ?: 0L) + val upsertedId = vaultDao.upsert(vaultEntity) + val vaultId = existingVaultId ?: upsertedId + dwebDao.upsertVaultMetadata(groupDto.toDwebEntity(vaultId)) + + val savedVaultWithDweb = dwebDao.getVaultWithDwebById(vaultId) + val savedVault = savedVaultWithDweb?.toDomain() + + return if (savedVault != null) { + DomainResult.Success(savedVault) + } else { + DomainResult.Error(DomainError.Unknown("Failed to retrieve saved mock group")) + } + } +} + +class MockSnowbirdRepoRepository( + private val assetManager: AssetManager, + private val config: AppConfig, + private val archiveDao: ArchiveDao, + private val submissionDao: SubmissionDao, + private val dwebDao: DwebDao +) : ISnowbirdRepoRepository { + + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun createRepo(vaultId: Long, groupKey: String, repoName: String): DomainResult { + delay(config.mockDelayMs) + if (config.simulateErrors) return DomainResult.Error(DomainError.Server("Simulated error creating repo")) + + return try { + val content = assetManager.open("dweb/dweb_create_repo_response.json").bufferedReader().use { it.readText() } + val dto = json.decodeFromString(content) + + // First lookup by key + val existingArchiveId = dwebDao.getArchiveIdByKey(dto.key) + + // 1. Upsert Archive FIRST with placeholder submissionId to get its internal ID + val archiveEntityPlaceholder = dto.toArchiveEntity(vaultId, 0, id = existingArchiveId ?: 0L) + val upsertedArchiveId = archiveDao.upsert(archiveEntityPlaceholder) + val archiveId = existingArchiveId ?: upsertedArchiveId + + // 2. Now we have a valid archiveId, so we can create/update the Submission + val archive = archiveDao.getById(archiveId) + var submissionId = archive?.openSubmissionId ?: 0L + if (submissionId == 0L) { + submissionId = submissionDao.upsert(SubmissionEntity(archiveId = archiveId, uploadedAt = null, serverUrl = null)) + } + + // 3. Finally update Archive with the real submissionId + archiveDao.upsert(dto.toArchiveEntity(vaultId, submissionId, id = archiveId)) + + val dwebEntity = dto.toDwebEntity(archiveId) + dwebDao.upsertArchiveMetadata(dwebEntity) + + val savedArchiveWithDweb = dwebDao.getArchiveWithDwebById(archiveId) + val savedArchive = savedArchiveWithDweb?.toDomain() + + if (savedArchive != null) { + DomainResult.Success(savedArchive) + } else { + DomainResult.Error(DomainError.Unknown("Failed to retrieve saved mock repo")) + } + } catch (e: Exception) { + DomainResult.Error(DomainError.Unknown("Failed to parse mock data: ${e.message}")) + } + } + + override suspend fun fetchRepos(vaultId: Long, groupKey: String, forceRefresh: Boolean): DomainResult { + delay(config.mockDelayMs) + if (config.simulateErrors) return DomainResult.Error(DomainError.Network("Simulated error fetching repos")) + + return try { + val content = assetManager.open("dweb/dweb_fetch_repos_response.json").bufferedReader().use { it.readText() } + val dtos = json.decodeFromString(content).repos + + dtos.forEach { dto -> + // Deduplication lookup STRICTLY by DWeb key + val existingArchiveId = dwebDao.getArchiveIdByKey(dto.key) + + // If not found by key, fallback to finding by name (legacy or first-time sync) + val fallbackId = archiveDao.getByName(vaultId, dto.name ?: "")?.id + val currentId = existingArchiveId ?: fallbackId + + // 1. Upsert archive with placeholder to ensure it exists + val upsertedArchiveId = archiveDao.upsert(dto.toArchiveEntity(vaultId, 0, id = currentId ?: 0L)) + val archiveId = currentId ?: upsertedArchiveId + + // 2. Ensure there's an open submission for this archive + val archive = archiveDao.getById(archiveId) + var submissionId = archive?.openSubmissionId ?: 0L + if (submissionId == 0L) { + submissionId = submissionDao.upsert(SubmissionEntity(archiveId = archiveId, uploadedAt = null, serverUrl = null)) + } + + // 3. Now update with the correct submissionId and ID + archiveDao.upsert(dto.toArchiveEntity(vaultId, submissionId, id = archiveId)) + + val dwebEntity = dto.toDwebEntity(archiveId) + dwebDao.upsertArchiveMetadata(dwebEntity) + } + DomainResult.Success(Unit) + } catch (e: Exception) { + e.printStackTrace() + DomainResult.Error(DomainError.Unknown("Failed to parse mock data: ${e.message}")) + } + } + + override fun observeRepos(vaultId: Long, archived: Boolean): Flow> { + return dwebDao.observeArchivesWithDweb(vaultId, archived).map { archiveList -> + archiveList.map { it.toDomain() } + } + } + + override suspend fun refreshGroupContent(groupKey: String): DomainResult { + delay(config.mockDelayMs) + return DomainResult.Success(RefreshGroupResponse(success = true)) + } + + private suspend fun apiCreateRepoLikeBehavior(groupKey: String, repoName: String) { + assetManager.open("dweb/dweb_create_repo_response.json").bufferedReader().use { it.readText() } + } +} + +class MockSnowbirdFileRepository( + private val assetManager: AssetManager, + private val config: AppConfig, + private val evidenceDao: EvidenceDao, + private val archiveDao: ArchiveDao, + private val dwebDao: DwebDao +) : ISnowbirdFileRepository { + + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun fetchFiles(archiveId: Long, groupKey: String, repoKey: String, forceRefresh: Boolean): DomainResult { + delay(config.mockDelayMs) + if (config.simulateErrors) return DomainResult.Error(DomainError.Network("Simulated error fetching files")) + + return try { + val content = assetManager.open("dweb/dweb_fetch_medias_response.json").bufferedReader().use { it.readText() } + val dtos = json.decodeFromString(content).files + + dtos.forEach { dto -> + val archive = archiveDao.getById(archiveId) + val submissionId = archive?.openSubmissionId ?: 0L + + // Deduplication lookup STRICTLY by content hash + val existingEvidenceId = evidenceDao.getEvidenceIdByHash(archiveId, dto.hash) + + // Map using the fixed ID (if exists) or 0 (for new) + val evidenceEntity = dto.toEvidenceEntity(archiveId, submissionId, id = existingEvidenceId ?: 0L) + val upsertedId = evidenceDao.upsert(evidenceEntity) + val evidenceId = existingEvidenceId ?: upsertedId + + val dwebEntity = dto.toDwebEntity(evidenceId) + dwebDao.upsertEvidenceMetadata(dwebEntity) + } + DomainResult.Success(Unit) + } catch (e: Exception) { + e.printStackTrace() + DomainResult.Error(DomainError.Unknown("Failed to parse mock data: ${e.message}")) + } + } + + override fun observeFiles(archiveId: Long): Flow> { + return dwebDao.observeEvidenceWithDweb(archiveId).map { evidenceList -> + evidenceList.map { it.toDomain() } + } + } + + override suspend fun downloadFile(groupKey: String, repoKey: String, filename: String): DomainResult { + delay(config.mockDelayMs) + return DomainResult.Success(ByteArray(0)) + } + + override suspend fun markFileDownloaded( + evidenceId: Long, + localFilePath: String, + mimeType: String + ): DomainResult { + return try { + dwebDao.markEvidenceDownloaded( + evidenceId = evidenceId, + localFilePath = localFilePath, + mimeType = mimeType, + updatedAt = DateUtils.nowDateTime + ) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Error(DomainError.Unknown("Failed to mark file as downloaded: ${e.message}")) + } + } + + override suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): DomainResult { + delay(config.mockDelayMs) + return DomainResult.Success(FileUploadResult(name = "mock_file", updatedCollectionHash = "mock_hash")) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/SnowbirdFileRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/SnowbirdFileRepository.kt new file mode 100644 index 000000000..c7e326bc3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/SnowbirdFileRepository.kt @@ -0,0 +1,130 @@ +package net.opendasharchive.openarchive.services.snowbird.service.repository + +import android.net.Uri +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.DomainResult +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.db.ArchiveDao +import net.opendasharchive.openarchive.db.DwebDao +import net.opendasharchive.openarchive.db.EvidenceDao +import net.opendasharchive.openarchive.extensions.toDomainError +import net.opendasharchive.openarchive.util.DateUtils +import net.opendasharchive.openarchive.services.snowbird.data.toDwebEntity +import net.opendasharchive.openarchive.services.snowbird.data.toDomain +import net.opendasharchive.openarchive.services.snowbird.data.toEvidenceEntity +import net.opendasharchive.openarchive.services.snowbird.data.* +import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI + +interface ISnowbirdFileRepository { + suspend fun fetchFiles(archiveId: Long, groupKey: String, repoKey: String, forceRefresh: Boolean = false): DomainResult + fun observeFiles(archiveId: Long): Flow> + suspend fun downloadFile(groupKey: String, repoKey: String, filename: String): DomainResult + suspend fun markFileDownloaded(evidenceId: Long, localFilePath: String, mimeType: String): DomainResult + suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): DomainResult +} + +class SnowbirdFileRepository( + private val api: ISnowbirdAPI, + private val evidenceDao: EvidenceDao, + private val archiveDao: ArchiveDao, + private val dwebDao: DwebDao +) : ISnowbirdFileRepository { + + override fun observeFiles(archiveId: Long): Flow> { + return dwebDao.observeEvidenceWithDweb(archiveId).map { evidenceList -> + evidenceList.map { it.toDomain() } + } + } + + override suspend fun fetchFiles(archiveId: Long, groupKey: String, repoKey: String, forceRefresh: Boolean): DomainResult { + return try { + val response = api.fetchFiles(groupKey, repoKey) + response.files.forEach { fileDto -> + val archive = archiveDao.getById(archiveId) + val submissionId = archive?.openSubmissionId ?: 0L + + // Deduplication lookup STRICTLY by content hash + val existingEvidenceId = evidenceDao.getEvidenceIdByHash(archiveId, fileDto.hash) + + // Preserve local file linkage and best-known mime type for existing downloaded items. + val existingEvidence = existingEvidenceId?.let { evidenceDao.getById(it) } + val mappedEvidence = fileDto.toEvidenceEntity( + archiveId = archiveId, + submissionId = submissionId, + id = existingEvidenceId ?: 0L + ) + val preservedMimeType = when { + existingEvidence?.mimeType.isNullOrBlank() -> mappedEvidence.mimeType + mappedEvidence.mimeType == "application/octet-stream" -> existingEvidence!!.mimeType + else -> mappedEvidence.mimeType + } + val evidenceEntity = if (existingEvidence != null) { + mappedEvidence.copy( + originalFilePath = if (existingEvidence.originalFilePath.isNotBlank()) { + existingEvidence.originalFilePath + } else { + mappedEvidence.originalFilePath + }, + mimeType = preservedMimeType + ) + } else { + mappedEvidence + } + val upsertedId = evidenceDao.upsert(evidenceEntity) + val evidenceId = existingEvidenceId ?: upsertedId + + val existingDweb = dwebDao.getEvidenceWithDwebById(evidenceId)?.dwebMetadata + val dwebEntity = fileDto.toDwebEntity(evidenceId).copy( + isDownloaded = fileDto.isDownloaded || (existingDweb?.isDownloaded == true) + ) + dwebDao.upsertEvidenceMetadata(dwebEntity) + } + DomainResult.Success(Unit) + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } + + override suspend fun downloadFile(groupKey: String, repoKey: String, filename: String): DomainResult { + return try { + val response = api.downloadFile(groupKey, repoKey, filename) + DomainResult.Success(response) + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } + + override suspend fun markFileDownloaded( + evidenceId: Long, + localFilePath: String, + mimeType: String + ): DomainResult { + return try { + dwebDao.markEvidenceDownloaded( + evidenceId = evidenceId, + localFilePath = localFilePath, + mimeType = mimeType, + updatedAt = DateUtils.nowDateTime + ) + DomainResult.Success(Unit) + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } + + override suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): DomainResult { + return try { + val response = api.uploadFile(groupKey, repoKey, uri) + DomainResult.Success(response) + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } + +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/SnowbirdGroupRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/SnowbirdGroupRepository.kt new file mode 100644 index 000000000..dfaa31868 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/SnowbirdGroupRepository.kt @@ -0,0 +1,122 @@ +package net.opendasharchive.openarchive.services.snowbird.service.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.opendasharchive.openarchive.core.domain.DomainError +import net.opendasharchive.openarchive.core.domain.DomainResult +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.db.DwebDao +import net.opendasharchive.openarchive.db.VaultDao +import net.opendasharchive.openarchive.extensions.toDomainError +import net.opendasharchive.openarchive.services.snowbird.data.toDwebEntity +import net.opendasharchive.openarchive.services.snowbird.data.toDomain +import net.opendasharchive.openarchive.services.snowbird.data.toVaultEntity +import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI +import net.opendasharchive.openarchive.services.snowbird.data.* + +interface ISnowbirdGroupRepository { + suspend fun createGroup(groupName: String): DomainResult + suspend fun fetchGroups(forceRefresh: Boolean = false): DomainResult + suspend fun getVaultIdByKey(groupKey: String): Long? + fun observeGroups(): Flow> + suspend fun joinGroup(uriString: String): DomainResult +} + +class SnowbirdGroupRepository( + private val api: ISnowbirdAPI, + private val vaultDao: VaultDao, + private val dwebDao: DwebDao +) : ISnowbirdGroupRepository { + + override fun observeGroups(): Flow> { + return dwebDao.observeVaultsWithDweb().map { vaultList -> + vaultList.map { it.toDomain() } + } + } + + override suspend fun createGroup(groupName: String): DomainResult { + return try { + val response = api.createGroup(RequestName(groupName)) + + // Deduplication lookup STRICTLY by DWeb key + val existingVaultId = dwebDao.getVaultIdByKey(response.key) + + // Map SnowbirdGroup to Vault entity using the fixed ID (if exists) or 0 (for new) + val vaultEntity = response.toVaultEntity(id = existingVaultId ?: 0L) + val upsertedId = vaultDao.upsert(vaultEntity) + val vaultId = existingVaultId ?: upsertedId + + val dwebEntity = response.toDwebEntity(vaultId) + dwebDao.upsertVaultMetadata(dwebEntity) + + // Fetch the freshly saved vault with its metadata + val savedVaultWithDweb = dwebDao.getVaultWithDwebById(vaultId) + val savedVault = savedVaultWithDweb?.toDomain() + if (savedVault != null) { + DomainResult.Success(savedVault) + } else { + DomainResult.Error(DomainError.Unknown("Failed to retrieve saved group")) + } + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } + + override suspend fun fetchGroups(forceRefresh: Boolean): DomainResult { + return try { + val response = api.fetchGroups() + response.groups.forEach { groupDto -> + // Deduplication lookup STRICTLY by DWeb key + val existingVaultId = dwebDao.getVaultIdByKey(groupDto.key) + + // Map using the fixed ID (if exists) or 0 (for new) + val vaultEntity = groupDto.toVaultEntity(id = existingVaultId ?: 0L) + val upsertedId = vaultDao.upsert(vaultEntity) + val vaultId = existingVaultId ?: upsertedId + + val dwebEntity = groupDto.toDwebEntity(vaultId) + dwebDao.upsertVaultMetadata(dwebEntity) + } + DomainResult.Success(Unit) + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } + + override suspend fun getVaultIdByKey(groupKey: String): Long? { + return dwebDao.getVaultIdByKey(groupKey) + } + + override suspend fun joinGroup(uriString: String): DomainResult { + return try { + val response = api.joinGroup(MembershipRequest(uriString)) + val data = response.group + + // Deduplication lookup STRICTLY by DWeb key + val existingVaultId = dwebDao.getVaultIdByKey(data.key) + + // Map SnowbirdGroup to Vault entity using the fixed ID (if exists) or 0 (for new) + val vaultEntity = data.toVaultEntity(id = existingVaultId ?: 0L) + val upsertedId = vaultDao.upsert(vaultEntity) + val vaultId = existingVaultId ?: upsertedId + + val dwebEntity = data.toDwebEntity(vaultId) + dwebDao.upsertVaultMetadata(dwebEntity) + + // Fetch the freshly saved vault with its metadata + val savedVaultWithDweb = dwebDao.getVaultWithDwebById(vaultId) + val savedVault = savedVaultWithDweb?.toDomain() + if (savedVault != null) { + DomainResult.Success(savedVault) + } else { + DomainResult.Error(DomainError.Unknown("Failed to retrieve saved group")) + } + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/SnowbirdRepoRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/SnowbirdRepoRepository.kt new file mode 100644 index 000000000..eb7f15900 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/repository/SnowbirdRepoRepository.kt @@ -0,0 +1,124 @@ +package net.opendasharchive.openarchive.services.snowbird.service.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.opendasharchive.openarchive.core.domain.Archive +import net.opendasharchive.openarchive.core.domain.DomainError +import net.opendasharchive.openarchive.db.ArchiveDao +import net.opendasharchive.openarchive.core.domain.DomainResult +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.db.DwebDao +import net.opendasharchive.openarchive.db.SubmissionDao +import net.opendasharchive.openarchive.db.SubmissionEntity +import net.opendasharchive.openarchive.extensions.toDomainError +import net.opendasharchive.openarchive.services.snowbird.data.* +import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI + +interface ISnowbirdRepoRepository { + suspend fun createRepo(vaultId: Long, groupKey: String, repoName: String): DomainResult + suspend fun fetchRepos(vaultId: Long, groupKey: String, forceRefresh: Boolean = false): DomainResult + fun observeRepos(vaultId: Long, archived: Boolean = false): Flow> + suspend fun refreshGroupContent(groupKey: String): DomainResult +} + +class SnowbirdRepoRepository( + private val api: ISnowbirdAPI, + private val archiveDao: ArchiveDao, + private val submissionDao: SubmissionDao, + private val dwebDao: DwebDao +) : ISnowbirdRepoRepository { + + override fun observeRepos(vaultId: Long, archived: Boolean): Flow> { + return dwebDao.observeArchivesWithDweb(vaultId, archived).map { archiveList -> + archiveList.map { it.toDomain() } + } + } + + override suspend fun createRepo(vaultId: Long, groupKey: String, repoName: String): DomainResult { + return try { + val response = api.createRepo(groupKey, RequestName(repoName)) + + // Deduplication lookup STRICTLY by DWeb key + val existingArchiveId = dwebDao.getArchiveIdByKey(response.key) + + // Map SnowbirdRepo to Archive entity using the fixed ID (if exists) or 0 (for new) + val archiveEntity = response.toArchiveEntity( + vaultId = vaultId, + submissionId = 0L, + id = existingArchiveId ?: 0L + ) + val upsertedId = archiveDao.upsert(archiveEntity) + val archiveId = existingArchiveId ?: upsertedId + + + // For now, let's just fetch all repos after creation or handle it if we have the data. + fetchRepos(vaultId, groupKey, forceRefresh = true) + + val savedArchive = archiveDao.getByName(vaultId, repoName) + if (savedArchive != null) { + val archivedWithDweb = dwebDao.getArchiveWithDwebById(savedArchive.id) + if (archivedWithDweb != null) { + DomainResult.Success(archivedWithDweb.toDomain()) + } else { + DomainResult.Error(DomainError.Unknown("Failed to retrieve metadata")) + } + } else { + DomainResult.Error(DomainError.Unknown("Failed to retrieve saved repo")) + } + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } + + override suspend fun fetchRepos(vaultId: Long, groupKey: String, forceRefresh: Boolean): DomainResult { + return try { + val response = api.fetchRepos(groupKey) + response.repos.forEach { repoDto -> + // Deduplication lookup STRICTLY by DWeb key + val existingArchiveId = dwebDao.getArchiveIdByKey(repoDto.key) + + // If not found by key, fallback to finding by name (legacy or first-time sync) + val fallbackId = archiveDao.getByName(vaultId, repoDto.name ?: "")?.id + val currentId = existingArchiveId ?: fallbackId + + // 1. Upsert Archive FIRST with placeholder submissionId to get its internal ID + val upsertedId = archiveDao.upsert(repoDto.toArchiveEntity(vaultId, 0, id = currentId ?: 0L)) + val archiveId = currentId ?: upsertedId + + // 2. Ensure there's an open submission for this archive + val archive = archiveDao.getById(archiveId) + var submissionId = archive?.openSubmissionId ?: 0L + if (submissionId == 0L) { + submissionId = submissionDao.upsert( + SubmissionEntity( + archiveId = archiveId, + uploadedAt = null, + serverUrl = null + ) + ) + } + + // 3. Now update with the correct submissionId and ID + archiveDao.upsert(repoDto.toArchiveEntity(vaultId, submissionId, id = archiveId)) + + val dwebEntity = repoDto.toDwebEntity(archiveId) + dwebDao.upsertArchiveMetadata(dwebEntity) + } + DomainResult.Success(Unit) + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } + + override suspend fun refreshGroupContent(groupKey: String): DomainResult { + return try { + val response = api.refreshGroupContent(groupKey) + DomainResult.Success(response) + } catch (e: Exception) { + AppLogger.e(e) + DomainResult.Error(e.toDomainError()) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/util/SnowbirdFileStorage.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/util/SnowbirdFileStorage.kt new file mode 100644 index 000000000..280bc2627 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/util/SnowbirdFileStorage.kt @@ -0,0 +1,84 @@ +package net.opendasharchive.openarchive.services.snowbird.util + +import android.content.ContentValues +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +class SnowbirdFileStorage(private val context: Context) { + + data class SavedFile( + val shareUri: Uri, + val localFileUri: String + ) + + suspend fun saveByteArrayToFile(byteArray: ByteArray, filename: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val directory = File(context.filesDir, "files").apply { mkdirs() } + val file = File(directory, filename) + + file.outputStream().use { it.write(byteArray) } + + val shareUri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file + ) + val localFileUri = Uri.fromFile(file).toString() + SavedFile( + shareUri = shareUri, + localFileUri = localFileUri + ) + } + } + + suspend fun saveImageToGallery( + imageBytes: ByteArray, + displayName: String + ): Uri? = withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, displayName) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Save") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: return@withContext null + + resolver.openOutputStream(uri)?.use { it.write(imageBytes) } + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + + return@withContext uri + } + + val imagesDir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + "Save" + ).apply { if (!exists()) mkdirs() } + + val file = File(imagesDir, displayName) + FileOutputStream(file).use { it.write(imageBytes) } + + MediaScannerConnection.scanFile( + context, + arrayOf(file.absolutePath), + arrayOf("image/jpeg"), + null + ) + return@withContext Uri.fromFile(file) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/util/SnowbirdJoinCode.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/util/SnowbirdJoinCode.kt new file mode 100644 index 000000000..f1086baa5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/util/SnowbirdJoinCode.kt @@ -0,0 +1,27 @@ +package net.opendasharchive.openarchive.services.snowbird.util + +import net.opendasharchive.openarchive.extensions.urlEncode +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +object SnowbirdJoinCode { + private const val NAME_SEPARATOR = "&name=" + + fun build(groupUri: String, groupName: String): String { + val baseUri = groupUri.trim().substringBefore(NAME_SEPARATOR) + if (baseUri.isBlank()) return "" + return "$baseUri$NAME_SEPARATOR${groupName.urlEncode()}" + } + + fun extractGroupName(code: String): String? { + val value = code.trim() + val index = value.indexOf(NAME_SEPARATOR) + if (index == -1) return null + + val encodedName = value.substring(index + NAME_SEPARATOR.length) + if (encodedName.isBlank()) return null + + return URLDecoder.decode(encodedName, StandardCharsets.UTF_8.toString()) + } +} + diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/util/SnowbirdQRDecoder.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/util/SnowbirdQRDecoder.kt new file mode 100644 index 000000000..fe2af6d2e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/util/SnowbirdQRDecoder.kt @@ -0,0 +1,35 @@ +package net.opendasharchive.openarchive.services.snowbird.util + +import android.graphics.Bitmap +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer + +object SnowbirdQRDecoder { + + /** + * Decodes a QR code from a [Bitmap]. + * Returns the decoded text or null if no QR code was found. + */ + fun decodeFromBitmap(bitmap: Bitmap): String? { + val intArray = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + + val source = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + + val reader = MultiFormatReader() + val hints = mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)) + reader.setHints(hints) + + return try { + val result = reader.decode(binaryBitmap) + result.text + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorConstants.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorConstants.kt new file mode 100644 index 000000000..0a204341b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorConstants.kt @@ -0,0 +1,31 @@ +package net.opendasharchive.openarchive.services.tor + +/** + * Constants for Tor service configuration. + * + * SECURITY NOTE: The SOCKS port is dynamically allocated for security reasons. + * Never hardcode port 9050 as it's predictable and could be exploited by malicious apps. + * Always use TorServiceManager.socksPort.value to get the actual port. + */ +object TorConstants { + /** SOCKS5 proxy address (localhost) */ + const val SOCKS5_PROXY_ADDRESS = "127.0.0.1" + + /** Tor notification ID */ + const val TOR_NOTIFICATION_ID = 2602 + + /** Tor notification channel ID */ + const val TOR_NOTIFICATION_CHANNEL_ID = "tor_service_channel" + + /** + * Default torrc configuration for security. + * + * - SocksPort auto: Random port allocation (prevents port-squatting) + * - IsolateSOCKSAuth: Each unique username/password gets a separate circuit + * - IsolateClientAddr: Isolate by client address (additional protection) + */ + const val DEFAULT_TORRC_CONFIG = """ +SocksPort auto IsolateSOCKSAuth IsolateClientAddr +HTTPTunnelPort auto +""" +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt new file mode 100644 index 000000000..c26964931 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt @@ -0,0 +1,240 @@ +package net.opendasharchive.openarchive.services.tor + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.features.main.HomeActivity +import org.torproject.jni.TorService + +/** + * Foreground service that wraps the embedded Tor daemon. + * + * This service extends TorService directly from the tor-android library + * and adds foreground notification support to comply with Android's + * background service restrictions. + * + * SECURITY: Forces random SOCKS port allocation to prevent port-squatting attacks. + */ +class TorForegroundService : TorService() { + private var statusReceiver: BroadcastReceiver? = null + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onCreate() { + super.onCreate() + + // SECURITY: Force random SOCKS port (never use predictable 9050) + // This must be done before the service starts the Tor daemon + val torrcFile = getTorrc(this) + torrcFile.parentFile?.mkdirs() + torrcFile.writeText(TorConstants.DEFAULT_TORRC_CONFIG.trimIndent()) + + // Register for status updates to update notification + statusReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + if (intent?.action == ACTION_STATUS) { + val status = intent.getStringExtra(EXTRA_STATUS) + updateNotificationForStatus(status) + } + } + } + + val filter = IntentFilter(ACTION_STATUS) + ContextCompat.registerReceiver( + this, + statusReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + + // Workaround for tor-android 0.4.9.5.1 bug: + // The library's event handler (lambda$new$0) was changed to wait for + // keyword=="NOTICE" with "Bootstrapped 100%", but setEvents() still only + // subscribes to ["CIRC"]. Tor never sends NOTICE events unless subscribed, + // so STATUS_ON is never broadcast. We fix this by adding "NOTICE" to the + // subscription after the library's control port thread finishes its own setup. + serviceScope.launch { subscribeToBootstrapEvents() } + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + // Handle notification update intent - show simple "connected" message (no sensitive IP info) + if (intent?.action == ACTION_UPDATE_NOTIFICATION) { + updateNotification(getString(R.string.tor_notification_connected)) + return START_STICKY + } + + // Create and show foreground notification + val notification = createNotification(getString(R.string.tor_notification_connecting)) + + startForeground( + TorConstants.TOR_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + + return super.onStartCommand(intent, flags, startId) + } + + private fun updateNotificationForStatus(status: String?) { + val text = + when (status) { + STATUS_STARTING -> getString(R.string.tor_notification_connecting) + + STATUS_ON -> getString(R.string.tor_notification_connected) + + STATUS_STOPPING, STATUS_OFF -> return + + // Don't update notification when stopping + else -> return + } + try { + updateNotification(text) + } catch (e: Exception) { + // Ignore notification update failures (e.g., if service is stopping) + AppLogger.i("TorForegroundService", "Failed to update notification", e) + } + } + + private fun updateNotification(contentText: String) { + val notification = createNotification(contentText) + // Use startForeground to update notification - this maintains the foreground service binding + // and ensures the notification cannot be dismissed + startForeground( + TorConstants.TOR_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private fun createNotification(contentText: String): Notification { + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + Intent(this, HomeActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val builder = + NotificationCompat + .Builder(this, TorConstants.TOR_NOTIFICATION_CHANNEL_ID) + .setContentTitle(getString(R.string.tor_notification_title)) + .setContentText(contentText) + .setSmallIcon(R.drawable.ic_tor) + .setContentIntent(pendingIntent) + .setOngoing(false) // Allow dismissal for privacy + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setShowWhen(false) // Don't show timestamp + + // For Android 12+, ensure immediate foreground service notification + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + } + + return builder.build() + } + + /** + * Called when the app is swiped away from recent apps. + * Clean up Tor service and notification. + */ + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + // Stop the service and clear notification when app is removed from recents + clearNotificationAndStop() + } + + override fun onDestroy() { + serviceScope.cancel() + statusReceiver?.let { + try { + unregisterReceiver(it) + } catch (_: IllegalArgumentException) { + // Receiver not registered + } + } + clearNotificationAndStop() + super.onDestroy() + } + + /** + * Workaround for tor-android 0.4.9.5.1 bug: the library's bootstrap event handler + * checks for NOTICE events but the control port thread only subscribes to CIRC. + * + * We wait for the library's control port setup to complete (signalled by socksPort > 0, + * which is set AFTER setEvents in the control port thread), then re-subscribe with NOTICE + * added so Tor starts forwarding bootstrap log messages to the control connection. + * + * We also handle the edge case where Tor already reached 100% before we subscribed, + * by querying the bootstrap phase directly and sending the STATUS_ON broadcast manually. + */ + private suspend fun subscribeToBootstrapEvents() { + for (attempt in 1..120) { + delay(1_000L) + if (getSocksPort() <= 0) continue + + val conn = getTorControlConnection() ?: continue + + try { + conn.setEvents(listOf("CIRC", "NOTICE")) + AppLogger.d("TorForegroundService: Subscribed to CIRC+NOTICE events (attempt $attempt)") + + // Edge case: bootstrap already at 100% before we subscribed + val phase = getInfo("status/bootstrap-phase") + if (phase != null && phase.contains("PROGRESS=100")) { + AppLogger.d("TorForegroundService: Already bootstrapped — broadcasting STATUS_ON") + val intent = Intent(ACTION_STATUS).apply { + setPackage(packageName) + putExtra(EXTRA_STATUS, STATUS_ON) + } + sendBroadcast(intent) + } + return + } catch (e: Exception) { + AppLogger.w("TorForegroundService: setEvents attempt $attempt failed", e) + } + } + AppLogger.e("TorForegroundService: Timed out waiting to subscribe to NOTICE events") + } + + private fun clearNotificationAndStop() { + // Clear the notification + val notificationManager = + getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(TorConstants.TOR_NOTIFICATION_ID) + + // Stop foreground and remove notification + stopForeground(STOP_FOREGROUND_REMOVE) + } + + companion object { + const val ACTION_UPDATE_NOTIFICATION = + "net.opendasharchive.openarchive.tor.UPDATE_NOTIFICATION" + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorServiceManager.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorServiceManager.kt new file mode 100644 index 000000000..0335eecfc --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorServiceManager.kt @@ -0,0 +1,626 @@ +package net.opendasharchive.openarchive.services.tor + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.os.IBinder +import androidx.core.content.ContextCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.core.logger.AppLogger +import okhttp3.Authenticator +import okhttp3.CertificatePinner +import okhttp3.Credentials +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Route +import org.json.JSONObject +import org.torproject.jni.TorService +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.math.min +import kotlin.math.pow + +/** + * Singleton manager for the embedded Tor service. + * + * Provides: + * - StateFlow for observing Tor status changes + * - Dynamic SOCKS port retrieval (for security) + * - Service lifecycle management (start/stop) + * + * Usage: + * ``` + * val torManager: TorServiceManager = get() // from Koin + * + * // Observe status + * torManager.torStatus.collect { status -> + * when (status) { + * is TorStatus.On -> // Tor is ready + * is TorStatus.Starting -> // Still connecting + * ... + * } + * } + * + * // Get dynamic SOCKS port (only valid when status is On) + * val port = torManager.socksPort.value + * + * // Control service + * torManager.start() + * torManager.stop() + * ``` + */ +class TorServiceManager( + private val context: Context, + private val analyticsManager: AnalyticsManager, +) { + private val _torStatus = MutableStateFlow(TorStatus.Idle) + val torStatus: StateFlow = _torStatus.asStateFlow() + + private val _socksPort = MutableStateFlow(0) + val socksPort: StateFlow = _socksPort.asStateFlow() + + private val _httpTunnelPort = MutableStateFlow(0) + val httpTunnelPort: StateFlow = _httpTunnelPort.asStateFlow() + + private var torService: TorService? = null + private var isBound = false + + /** + * Circuit isolation purposes - each gets a unique credential for stream isolation. + * Tor's IsolateSOCKSAuth ensures different credentials use different circuits. + */ + enum class CircuitPurpose { + VERIFICATION, + GEOLOCATION, + UPLOAD, + GENERAL + } + + private val serviceConnection = + object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName?, + service: IBinder?, + ) { + AppLogger.d("TorServiceManager: Service connected") + torService = (service as? TorService.LocalBinder)?.service + isBound = true + + // Get the dynamically allocated ports + torService?.let { svc -> + _socksPort.value = svc.socksPort + _httpTunnelPort.value = svc.httpTunnelPort + AppLogger.d("TorServiceManager: SOCKS port = ${_socksPort.value}, HTTP tunnel port = ${_httpTunnelPort.value}") + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + AppLogger.d("TorServiceManager: Service disconnected") + torService = null + isBound = false + _socksPort.value = 0 + _httpTunnelPort.value = 0 + } + } + + private val torStatusReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + if (intent?.action != TorService.ACTION_STATUS) return + + val status = intent.getStringExtra(TorService.EXTRA_STATUS) + AppLogger.d("TorServiceManager: Received status broadcast: $status") + + when (status) { + TorService.STATUS_STARTING -> { + _torStatus.value = TorStatus.Starting + } + + TorService.STATUS_ON -> { + // Update ports when Tor is connected + torService?.let { svc -> + _socksPort.value = svc.socksPort + _httpTunnelPort.value = svc.httpTunnelPort + } + _torStatus.value = TorStatus.On + } + + TorService.STATUS_OFF -> { + _torStatus.value = TorStatus.Off + _socksPort.value = 0 + _httpTunnelPort.value = 0 + } + + TorService.STATUS_STOPPING -> { + _torStatus.value = TorStatus.Off + } + + else -> { + AppLogger.w("TorServiceManager: Unknown status: $status") + } + } + } + } + + init { + // Register broadcast receiver for status updates + val filter = IntentFilter(TorService.ACTION_STATUS) + ContextCompat.registerReceiver( + context, + torStatusReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + + /** + * Starts the Tor service. + * The service runs as a foreground service to comply with Android restrictions. + */ + fun start() { + AppLogger.d("TorServiceManager: Starting Tor service") + _torStatus.value = TorStatus.Starting + + val serviceIntent = Intent(context, TorForegroundService::class.java) + + // Start as foreground service + ContextCompat.startForegroundService(context, serviceIntent) + + // Bind to get service instance for port queries + context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE) + } + + /** + * Stops the Tor service. + */ + fun stop() { + AppLogger.d("TorServiceManager: Stopping Tor service") + + // Unbind if bound + if (isBound) { + try { + context.unbindService(serviceConnection) + } catch (e: IllegalArgumentException) { + AppLogger.w("TorServiceManager: Service not bound", e) + } + isBound = false + } + + // Stop the service + val serviceIntent = Intent(context, TorForegroundService::class.java) + context.stopService(serviceIntent) + + _torStatus.value = TorStatus.Off + _socksPort.value = 0 + _httpTunnelPort.value = 0 + torService = null + } + + /** + * Returns true if Tor is currently connected and ready for use. + */ + fun isReady(): Boolean { + val status = _torStatus.value + return (status == TorStatus.On || status is TorStatus.Verified) && _socksPort.value > 0 + } + + /** + * Fetches the country name for a given IP address using free geolocation APIs. + * Routes the request through Tor for privacy. + * Tries multiple HTTPS APIs as fallbacks since some have rate limits or block Tor. + * + * @param ip The IP address to look up + * @param torClient OkHttpClient configured to use Tor SOCKS proxy + * @return Country name or null if lookup fails + */ + private suspend fun getExitCountry( + ip: String, + torClient: OkHttpClient, + ): String? = + withContext(Dispatchers.IO) { + // Try primary API (ipwho.is - free, no rate limits, returns JSON) + try { + val request = Request.Builder().url("https://ipwho.is/$ip").build() + + val response = torClient.newCall(request).execute() + val body = response.body?.string() + + if (response.isSuccessful && body != null) { + val json = JSONObject(body) + if (json.optBoolean("success", true)) { + val country = json.optString("country", "") + if (country.isNotEmpty()) { + AppLogger.d("TorServiceManager: Exit country resolved: $country") + return@withContext country + } + } + } + AppLogger.d("TorServiceManager: Primary geolocation API failed: ${response.code}") + } catch (e: Exception) { + AppLogger.w("TorServiceManager: ipwho.is country lookup failed", e) + } + + // Try secondary API (freeipapi.com - free, generous limits) + try { + val request = Request.Builder().url("https://freeipapi.com/api/json/$ip").build() + + val response = torClient.newCall(request).execute() + val body = response.body?.string() + + if (response.isSuccessful && body != null) { + val json = JSONObject(body) + val country = json.optString("countryName", "") + if (country.isNotEmpty()) { + AppLogger.d("TorServiceManager: Exit country resolved (fallback 1): $country") + return@withContext country + } + } + AppLogger.d("TorServiceManager: Secondary geolocation API failed: ${response.code}") + } catch (e: Exception) { + AppLogger.w("TorServiceManager: freeipapi.com country lookup failed", e) + } + + // Try tertiary API (ipapi.co - returns plain text country name) + try { + val request = Request.Builder().url("https://ipapi.co/$ip/country_name/").build() + + val response = torClient.newCall(request).execute() + val body = response.body?.string()?.trim() + + if (response.isSuccessful && !body.isNullOrEmpty() && !body.startsWith("{") && + !body.contains( + "error", + ) + ) { + AppLogger.d("TorServiceManager: Exit country resolved (fallback 2): $body") + return@withContext body + } + AppLogger.d("TorServiceManager: Tertiary geolocation API failed: ${response.code}") + } catch (e: Exception) { + AppLogger.w("TorServiceManager: Tertiary country lookup failed", e) + } + + AppLogger.w("TorServiceManager: All geolocation APIs failed") + null + } + + /** + * Creates a SOCKS5 proxy authenticator for circuit isolation. + * + * SECURITY: Tor's IsolateSOCKSAuth feature ensures that connections with + * different username/password combinations use different circuits. This + * prevents traffic correlation between different app operations. + * + * @param purpose The purpose of this connection (determines circuit isolation) + * @return Authenticator that provides unique credentials per purpose + */ + private fun createCircuitIsolatingAuthenticator(purpose: CircuitPurpose): Authenticator { + // Use purpose + UUID for unique circuit per request type + // Same purpose reuses circuit, different purposes get different circuits + val username = "save-${purpose.name.lowercase()}" + val password = UUID.randomUUID().toString() + + return Authenticator { _: Route?, response: okhttp3.Response -> + if (response.request.header("Proxy-Authorization") != null) { + // Already attempted authentication, give up to avoid infinite loop + null + } else { + response.request.newBuilder() + .header("Proxy-Authorization", Credentials.basic(username, password)) + .build() + } + } + } + + /** + * Creates an OkHttpClient with circuit isolation and certificate pinning. + * + * SECURITY: + * - Certificate pinning for check.torproject.org prevents MITM attacks + * - Circuit isolation via IsolateSOCKSAuth prevents traffic correlation + * + * @param port The SOCKS5 proxy port + * @param purpose The purpose of this client (for circuit isolation) + * @return Configured OkHttpClient + */ + private fun createIsolatedClient(port: Int, purpose: CircuitPurpose): OkHttpClient { + // Certificate pinning for check.torproject.org + // These are SHA-256 hashes of the certificate public keys + val certificatePinner = + CertificatePinner + .Builder() + .add( + "check.torproject.org", + // Primary certificate pin (Let's Encrypt) + "sha256/jQJTbIh0grw0/1TkHSumWb+Fs0Ggogr621gT3PvPKG0=", + // Backup pin (ISRG Root X1) + "sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", + ).build() + + return OkHttpClient + .Builder() + .proxy( + Proxy( + Proxy.Type.SOCKS, + InetSocketAddress(TorConstants.SOCKS5_PROXY_ADDRESS, port), + ), + ) + .proxyAuthenticator(createCircuitIsolatingAuthenticator(purpose)) + .certificatePinner(certificatePinner) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + } + + /** + * Creates an OkHttpClient for geolocation lookups (no certificate pinning). + * + * @param port The SOCKS5 proxy port + * @return Configured OkHttpClient for geolocation APIs + */ + private fun createGeolocationClient(port: Int): OkHttpClient { + return OkHttpClient + .Builder() + .proxy( + Proxy( + Proxy.Type.SOCKS, + InetSocketAddress(TorConstants.SOCKS5_PROXY_ADDRESS, port), + ), + ) + .proxyAuthenticator(createCircuitIsolatingAuthenticator(CircuitPurpose.GEOLOCATION)) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + } + + /** + * Creates an OkHttpClient configured for Tor with circuit isolation. + * + * Use this method when making requests through Tor to ensure proper + * circuit isolation between different types of operations. + * + * @param purpose The purpose of this client (UPLOAD, GENERAL, etc.) + * @return Configured OkHttpClient, or null if Tor is not ready + */ + fun createTorClient(purpose: CircuitPurpose = CircuitPurpose.GENERAL): OkHttpClient? { + val port = _socksPort.value + if (port <= 0) return null + + return OkHttpClient + .Builder() + .proxy( + Proxy( + Proxy.Type.SOCKS, + InetSocketAddress(TorConstants.SOCKS5_PROXY_ADDRESS, port), + ), + ) + .proxyAuthenticator(createCircuitIsolatingAuthenticator(purpose)) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + } + + /** + * Verifies that traffic is actually routing through the Tor network. + * + * This makes a request to check.torproject.org through the SOCKS proxy + * to confirm the connection is working properly. + * + * Features: + * - Certificate pinning for check.torproject.org + * - Circuit isolation (verification uses dedicated circuit) + * - Exponential backoff retry on failure (up to MAX_VERIFICATION_RETRIES) + * - Metrics tracking for success/failure rates + * + * @return TorVerificationResult containing verification status and exit IP + */ + suspend fun verifyTorConnection(): TorVerificationResult = + withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + val port = _socksPort.value + if (port <= 0) { + trackVerificationMetrics( + success = false, + retryCount = 0, + durationMs = System.currentTimeMillis() - startTime, + errorType = "port_unavailable" + ) + return@withContext TorVerificationResult( + isUsingTor = false, + error = "Tor SOCKS port not available", + ) + } + + var lastError: String? = null + var attempt = 0 + + while (attempt < MAX_VERIFICATION_RETRIES) { + try { + val result = attemptVerification(port) + if (result.isUsingTor || result.error == null) { + trackVerificationMetrics( + success = result.isUsingTor, + retryCount = attempt, + durationMs = System.currentTimeMillis() - startTime, + errorType = if (result.isUsingTor) null else "not_using_tor" + ) + return@withContext result + } + lastError = result.error + } catch (e: Exception) { + lastError = e.message ?: "Unknown error" + AppLogger.w("TorServiceManager: Verification attempt ${attempt + 1} failed", e) + } + + attempt++ + if (attempt < MAX_VERIFICATION_RETRIES) { + // Exponential backoff: 1s, 2s, 4s, 8s... (capped at 30s) + val delayMs = + min( + INITIAL_RETRY_DELAY_MS * 2.0.pow(attempt - 1).toLong(), + MAX_RETRY_DELAY_MS, + ) + AppLogger.d( + "TorServiceManager: Retrying verification in ${delayMs}ms (attempt ${attempt + 1}/$MAX_VERIFICATION_RETRIES)", + ) + delay(delayMs) + } + } + + val errorType = categorizeError(lastError) + trackVerificationMetrics( + success = false, + retryCount = attempt, + durationMs = System.currentTimeMillis() - startTime, + errorType = errorType + ) + + AppLogger.e("TorServiceManager: Verification failed after $MAX_VERIFICATION_RETRIES attempts") + return@withContext TorVerificationResult( + isUsingTor = false, + error = lastError ?: "Verification failed after $MAX_VERIFICATION_RETRIES attempts", + ) + } + + /** + * Categorizes error messages for analytics (GDPR-safe, no sensitive data). + */ + private fun categorizeError(error: String?): String { + return when { + error == null -> "unknown" + error.contains("timeout", ignoreCase = true) -> "timeout" + error.contains("connect", ignoreCase = true) -> "connection_failed" + error.contains("certificate", ignoreCase = true) -> "certificate_error" + error.contains("SSL", ignoreCase = true) -> "ssl_error" + error.contains("proxy", ignoreCase = true) -> "proxy_error" + else -> "other" + } + } + + /** + * Tracks verification metrics via analytics. + */ + private suspend fun trackVerificationMetrics( + success: Boolean, + retryCount: Int, + durationMs: Long, + errorType: String? + ) { + try { + analyticsManager.trackEvent( + AnalyticsEvent.TorVerificationAttempt( + success = success, + retryCount = retryCount, + durationMs = durationMs, + errorType = errorType + ) + ) + } catch (e: Exception) { + AppLogger.w("TorServiceManager: Failed to track verification metrics", e) + } + } + + /** + * Single verification attempt with certificate pinning and circuit isolation. + */ + private suspend fun attemptVerification(port: Int): TorVerificationResult = + withContext(Dispatchers.IO) { + // Use isolated client for verification (dedicated circuit) + val verificationClient = createIsolatedClient(port, CircuitPurpose.VERIFICATION) + + val request = Request.Builder().url(TOR_CHECK_API_URL).build() + + val response = verificationClient.newCall(request).execute() + val body = response.body?.string() + + if (response.isSuccessful && body != null) { + val json = JSONObject(body) + val isTor = json.optBoolean("IsTor", false) + val ip = json.optString("IP", "") + + AppLogger.d("TorServiceManager: Verification result - IsTor: $isTor") + + if (isTor && ip.isNotEmpty()) { + // Lookup exit country using separate circuit (geolocation isolation) + val geolocationClient = createGeolocationClient(port) + val country = getExitCountry(ip, geolocationClient) + + // Create connection info with IP and country + val connectionInfo = + TorConnectionInfo( + exitIp = ip, + exitCountry = country, + ) + + // Update status to Verified with full connection info + _torStatus.value = TorStatus.Verified(connectionInfo) + + // Update the notification to show verified status + val updateIntent = + Intent(context, TorForegroundService::class.java).apply { + action = TorForegroundService.ACTION_UPDATE_NOTIFICATION + } + context.startService(updateIntent) + + return@withContext TorVerificationResult( + isUsingTor = true, + exitIp = ip, + exitCountry = country, + ) + } + + return@withContext TorVerificationResult( + isUsingTor = isTor, + exitIp = ip.ifEmpty { null } + ) + } else { + return@withContext TorVerificationResult( + isUsingTor = false, + error = "Verification request failed: ${response.code}", + ) + } + } + + /** + * Cleanup resources when the manager is no longer needed. + * Call this in Application.onTerminate() or when shutting down. + */ + fun cleanup() { + stop() + try { + context.unregisterReceiver(torStatusReceiver) + } catch (e: IllegalArgumentException) { + AppLogger.w("TorServiceManager: Receiver not registered", e) + } + } + + companion object { + /** Tor Project's official API to check if traffic is routing through Tor */ + private const val TOR_CHECK_API_URL = "https://check.torproject.org/api/ip" + + /** Maximum number of verification retry attempts */ + private const val MAX_VERIFICATION_RETRIES = 3 + + /** Initial delay between retries (milliseconds) */ + private const val INITIAL_RETRY_DELAY_MS = 1000L + + /** Maximum delay between retries (milliseconds) */ + private const val MAX_RETRY_DELAY_MS = 30000L + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt new file mode 100644 index 000000000..859ffdff7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt @@ -0,0 +1,45 @@ +package net.opendasharchive.openarchive.services.tor + +/** + * Connection information for a verified Tor connection. + * + * @property exitIp The IP address of the Tor exit node + * @property exitCountry The country of the exit node (from IP geolocation), null if lookup failed + */ +data class TorConnectionInfo( + val exitIp: String, + val exitCountry: String? = null +) + +/** + * Represents the current status of the embedded Tor service. + */ +sealed class TorStatus { + /** Tor service is idle and not running */ + object Idle : TorStatus() + + /** Tor service is starting up */ + object Starting : TorStatus() + + /** Tor service is connected and ready (not yet verified) */ + object On : TorStatus() + + /** Tor service is connected AND verified to be routing through Tor */ + data class Verified(val info: TorConnectionInfo) : TorStatus() + + /** Tor service is stopped/disabled */ + object Off : TorStatus() + + /** Tor service encountered an error */ + data class Error(val message: String) : TorStatus() +} + +/** + * Result of Tor connection verification. + */ +data class TorVerificationResult( + val isUsingTor: Boolean, + val exitIp: String? = null, + val exitCountry: String? = null, + val error: String? = null +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavFragment.kt deleted file mode 100644 index 0f7b04cc1..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavFragment.kt +++ /dev/null @@ -1,603 +0,0 @@ -package net.opendasharchive.openarchive.services.webdav - -import android.content.res.ColorStateList -import android.graphics.drawable.Drawable -import android.net.Uri -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import androidx.activity.addCallback -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Warning -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf -import androidx.core.view.MenuProvider -import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.databinding.FragmentWebDavBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.UiImage -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.asUiText -import net.opendasharchive.openarchive.features.core.dialog.ButtonData -import net.opendasharchive.openarchive.features.core.dialog.DialogConfig -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager -import net.opendasharchive.openarchive.services.SaveClient -import net.opendasharchive.openarchive.services.internetarchive.Util -import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets -import net.opendasharchive.openarchive.util.extensions.hide -import net.opendasharchive.openarchive.util.extensions.show -import okhttp3.Call -import okhttp3.Callback -import okhttp3.Request -import okhttp3.Response -import java.io.IOException -import kotlin.coroutines.suspendCoroutine -import androidx.core.net.toUri -import net.opendasharchive.openarchive.features.core.dialog.showErrorDialog -import com.google.android.material.textfield.TextInputLayout - -class WebDavFragment : BaseFragment() { - - private lateinit var mSpace: Space - - private var isLoading = false - private lateinit var binding: FragmentWebDavBinding - - private var originalName: String? = null - private var isNameChanged = false - - private val args: WebDavScreenFragmentArgs by navArgs() - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - // Inflate the layout for this fragment - binding = FragmentWebDavBinding.inflate(inflater) - - binding.buttonBar.applyEdgeToEdgeInsets( - typeMask = WindowInsetsCompat.Type.navigationBars() - ) { insets -> - - bottomMargin = insets.bottom - } - - if (args.spaceId != ARG_VAL_NEW_SPACE) { - // setup views for editing an existing space - - mSpace = Space.get(args.spaceId) ?: Space(Space.Type.WEBDAV) - - binding.header.visibility = View.GONE - binding.buttonBar.visibility = View.GONE - binding.buttonBarEdit.visibility = View.VISIBLE - - binding.server.isEnabled = false - binding.username.isEnabled = false - binding.password.isEnabled = false - - // Disable the password visibility toggle - binding.passwordLayout.isEndIconVisible = false - - binding.server.setText(mSpace.host) - binding.username.setText(mSpace.username) - binding.password.setText(mSpace.password) - - binding.name.setText(mSpace.name) - binding.layoutName.visibility = View.VISIBLE - -// mBinding.swChunking.isChecked = mSpace.useChunking -// mBinding.swChunking.setOnCheckedChangeListener { _, useChunking -> -// mSpace.useChunking = useChunking -// mSpace.save() -// } - - - binding.btRemove.setOnClickListener { - removeSpace() - } - - // swap webDavFragment with Creative Commons License Fragment -// binding.btLicense.setOnClickListener { -// setFragmentResult(RESP_LICENSE, bundleOf()) -// } - -// binding.name.setOnEditorActionListener { _, actionId, _ -> -// if (actionId == EditorInfo.IME_ACTION_DONE) { -// -// val enteredName = binding.name.text?.toString()?.trim() -// if (!enteredName.isNullOrEmpty()) { -// // Update the Space entity and save it using SugarORM -// mSpace.name = enteredName -// mSpace.save() // Save the entity using SugarORM -// -// // Hide the keyboard -// val imm = -// requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager -// imm.hideSoftInputFromWindow(binding.name.windowToken, 0) -// binding.name.clearFocus() // Clear focus from the input field -// -// // Optional: Provide feedback to the user -// Snackbar.make( -// binding.root, -// "Name saved successfully!", -// Snackbar.LENGTH_SHORT -// ).show() -// } else { -// // Notify the user that the name cannot be empty (optional) -// Snackbar.make(binding.root, "Name cannot be empty", Snackbar.LENGTH_SHORT) -// .show() -// } -// -// true // Consume the event -// } else { -// false // Pass the event to the next listener -// } -// } - - originalName = mSpace.name - - // Listen for name changes - binding.name.addTextChangedListener(object : android.text.TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - val enteredName = s?.toString()?.trim() - isNameChanged = enteredName != originalName - requireActivity().invalidateOptionsMenu() // Refresh menu to show confirm button - } - - override fun afterTextChanged(s: Editable?) {} - }) - - CreativeCommonsLicenseManager.initialize(binding.cc, mSpace.license) { - mSpace.license = it - mSpace.save() - } - - } else { - // setup views for creating a new space - mSpace = Space(Space.Type.WEBDAV) - binding.btRemove.visibility = View.GONE - binding.buttonBar.visibility = View.VISIBLE - binding.buttonBarEdit.visibility = View.GONE - binding.layoutName.visibility = View.GONE - binding.layoutLicense.visibility = View.GONE - - binding.btAuthenticate.isEnabled = false - setupTextWatchers() - - } - - binding.btAuthenticate.setOnClickListener { attemptLogin() } - - binding.btCancel.setOnClickListener { - findNavController().popBackStack() - } - - binding.server.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - binding.server.setText(fixSpaceUrl(binding.server.text)?.toString()) - } - } - - binding.password.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_NULL) { - //attemptLogin() - } - - false - } - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - if (args.spaceId != ARG_VAL_NEW_SPACE) { - val menuProvider = object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_confirm, menu) - } - - override fun onPrepareMenu(menu: Menu) { - super.onPrepareMenu(menu) - val btnConfirm = menu.findItem(R.id.action_confirm) - btnConfirm?.isVisible = isNameChanged - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_confirm -> { - //todo: save changes here and show success dialog - saveChanges() - true - } - android.R.id.home -> { - if(isNameChanged) { - AppLogger.e("unsaved changes") - showUnsavedChangesDialog() - false - } else { - findNavController().popBackStack() - } - } - else -> false - } - } - } - - requireActivity().addMenuProvider( - menuProvider, - viewLifecycleOwner, - Lifecycle.State.RESUMED - ) - - - requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { - if (isNameChanged) { - showUnsavedChangesDialog() - } else { - findNavController().popBackStack() - } - } - } - - } - - private fun saveChanges() { - val enteredName = binding.name.text?.toString()?.trim().orEmpty() - - mSpace.name = enteredName - mSpace.save() - originalName = enteredName - isNameChanged = false - requireActivity().invalidateOptionsMenu() //Refresh menu to hide confirm btn again - showSuccessDialog() - } - - private fun showSuccessDialog() { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Success - title = R.string.label_success_title.asUiText() - message = R.string.msg_edit_server_success.asUiText() - icon = UiImage.DrawableResource(R.drawable.ic_done) - positiveButton { - text = UiText.StringResource(R.string.lbl_got_it) - action = { - findNavController().popBackStack() - } - } - } - } - - private fun showUnsavedChangesDialog() { - dialogManager.showDialog(DialogConfig( - type = DialogType.Warning, - title = UiText.StringResource(R.string.unsaved_changes), - message = UiText.StringResource(R.string.do_you_want_to_save), - icon = UiImage.DynamicVector(Icons.Default.Warning), - positiveButton = ButtonData( - text = UiText.StringResource(R.string.lbl_save), - action = { saveChanges() } - ), - neutralButton = ButtonData( - text = UiText.StringResource(R.string.lbl_discard), - action = { findNavController().popBackStack() } - ) - )) - } - - private fun fixSpaceUrl(url: CharSequence?): Uri? { - if (url.isNullOrBlank()) return null - - val uri = url.toString().toUri() - val builder = uri.buildUpon() - - if (uri.scheme != "https") { - builder.scheme("https") - } - - if (uri.authority.isNullOrBlank()) { - builder.authority(uri.path) - builder.path(REMOTE_PHP_ADDRESS) - } else if (uri.path.isNullOrBlank() || uri.path == "/") { - builder.path(REMOTE_PHP_ADDRESS) - } - - return builder.build() - } - - /** - * Attempts to sign in or register the account specified by the login form. - * If there are form errors (invalid email, missing fields, etc.), the - * errors are presented and no actual login attempt is made. - */ - private fun attemptLogin() { - // Reset errors. - binding.username.error = null - binding.password.error = null - - // Store values at the time of the login attempt. - var errorView: View? = null - - mSpace.host = fixSpaceUrl(binding.server.text)?.toString() ?: "" - binding.server.setText(mSpace.host) - - mSpace.username = binding.username.text?.toString() ?: "" - mSpace.password = binding.password.text?.toString() ?: "" - - if (mSpace.host.isEmpty()) { - binding.server.error = getString(R.string.error_field_required) - errorView = binding.server - } else if (mSpace.username.isEmpty()) { - binding.username.error = getString(R.string.error_field_required) - errorView = binding.username - } else if (mSpace.password.isEmpty()) { - binding.password.error = getString(R.string.error_field_required) - errorView = binding.password - } - - if (errorView != null) { - // There was an error; don't attempt login and focus the first - // form field with an error. - errorView.requestFocus() - - return - } - - val other = Space.get(Space.Type.WEBDAV, mSpace.host, mSpace.username) - - if (other.isNotEmpty() && other[0].id != mSpace.id) { - return showError(getString(R.string.you_already_have_a_server_with_these_credentials)) - } - - // Show loading overlay and make screen non-interactable - showLoadingOverlay(true) - - lifecycleScope.launch(Dispatchers.IO) { - try { - testConnection() - mSpace.save() - Space.current = mSpace - -// CleanInsightsManager.getConsent(requireActivity()) { -// CleanInsightsManager.measureEvent("backend", "new", Space.Type.WEBDAV.friendlyName) -// } - - // Hide loading overlay on success and navigate - requireActivity().runOnUiThread { - showLoadingOverlay(false) - } - navigate(mSpace.id) - } catch (exception: IOException) { - when { - exception.message?.startsWith("401") == true -> { - showInvalidCredentialsError() - } - exception.message?.contains("Unable to resolve host", ignoreCase = true) == true -> { - showError("A server with the specified hostname could not be found") - } - else -> { - showError(exception.localizedMessage ?: getString(R.string.error)) - } - } - } - } - } - - private fun showInvalidCredentialsError() { - requireActivity().runOnUiThread { - showLoadingOverlay(false) - binding.errorHint.text = getString(R.string.error_incorrect_username_or_password) - binding.errorHint.show() - // Set error state on username and password fields - binding.usernameLayout.error = " " - binding.passwordLayout.error = " " - } - } - - private fun dismissCredentialsError() { - binding.errorHint.hide() - // Clear error states from TextFields - binding.username.error = null - binding.usernameLayout.error = null - binding.password.error = null - binding.passwordLayout.error = null - } - - private fun showLoadingOverlay(show: Boolean) { - isLoading = show - binding.loadingOverlay.visibility = if (show) View.VISIBLE else View.GONE - - if (show) { - // Disable all interactive elements during loading - binding.server.isEnabled = false - binding.username.isEnabled = false - binding.password.isEnabled = false - binding.btAuthenticate.isEnabled = false - binding.btCancel.isEnabled = false - if (args.spaceId != ARG_VAL_NEW_SPACE) { - binding.btRemove.isEnabled = false - } - } else { - // Re-enable elements based on original state - if (args.spaceId != ARG_VAL_NEW_SPACE) { - // For existing spaces, keep server/username/password disabled - binding.server.isEnabled = false - binding.username.isEnabled = false - binding.password.isEnabled = false - binding.btRemove.isEnabled = true - } else { - // For new spaces, enable all fields - binding.server.isEnabled = true - binding.username.isEnabled = true - binding.password.isEnabled = true - // Update authenticate button state based on form content - updateAuthenticateButtonState() - } - binding.btCancel.isEnabled = true - } - } - - private fun navigate(spaceId: Long) = CoroutineScope(Dispatchers.Main).launch { - val action = - WebDavScreenFragmentDirections.actionFragmentWebDavToFragmentSetupLicense( - spaceId = spaceId, - spaceType = Space.Type.WEBDAV - ) - findNavController().navigate(action) - } - - private suspend fun testConnection() { - val url = mSpace.hostUrl ?: throw IOException("400 Bad Request") - - val client = SaveClient.get(requireContext(), mSpace.username, mSpace.password) - - val request = - Request.Builder().url(url).method("GET", null).addHeader("OCS-APIRequest", "true") - .addHeader("Accept", "application/json").build() - - return suspendCoroutine { - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - it.resumeWith(Result.failure(e)) - } - - override fun onResponse(call: Call, response: Response) { - val code = response.code - val message = response.message - - response.close() - - if (code != 200 && code != 204) { - return it.resumeWith(Result.failure(IOException("$code $message"))) - } - - it.resumeWith(Result.success(Unit)) - } - }) - } - } - - private fun showError(text: CharSequence, onForm: Boolean = false) { - requireActivity().runOnUiThread { - showLoadingOverlay(false) - - if (onForm) { - binding.errorHint.text = text - binding.errorHint.show() - binding.password.requestFocus() - } else { - // Show error dialog for server errors - dialogManager.showErrorDialog( - message = text.toString(), - title = getString(R.string.error), - onDismiss = { binding.server.requestFocus() } - ) - } - } - } - - override fun onStop() { - super.onStop() - if (isNameChanged) { - binding.name.requestFocus() - } - - // Hide loading overlay when fragment isn't on display anymore - showLoadingOverlay(false) - // also hide keyboard when fragment isn't on display anymore - Util.hideSoftKeyboard(requireActivity()) - } - - private fun removeSpace() { - val config = DialogConfig( - type = DialogType.Warning, - title = R.string.remove_from_app.asUiText(), - message = R.string.are_you_sure_you_want_to_remove_this_server_from_the_app.asUiText(), - icon = UiImage.DrawableResource(R.drawable.ic_trash), - destructiveButton = ButtonData( - text = UiText.StringResource(R.string.lbl_remove), - action = { - mSpace.delete() - findNavController().popBackStack() - } - ), - neutralButton = ButtonData( - text = UiText.StringResource(R.string.lbl_Cancel), - action = {} - ) - ) - dialogManager.showDialog(config) - } - - private fun setupTextWatchers() { - // Create a common TextWatcher for all three fields - val textWatcher = object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - updateAuthenticateButtonState() - } - - override fun afterTextChanged(s: Editable?) { - dismissCredentialsError() - } - } - - binding.server.addTextChangedListener(textWatcher) - binding.username.addTextChangedListener(textWatcher) - binding.password.addTextChangedListener(textWatcher) - } - - private fun updateAuthenticateButtonState() { - // Don't update button state if loading - if (isLoading) return - - val url = binding.server.text?.toString()?.trim().orEmpty() - val username = binding.username.text?.toString()?.trim().orEmpty() - val password = binding.password.text?.toString()?.trim().orEmpty() - - // Enable the button only if none of the fields are empty and not loading - binding.btAuthenticate.isEnabled = - url.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty() - } - - companion object { - const val ARG_VAL_NEW_SPACE = -1L - - // other internal constants - const val REMOTE_PHP_ADDRESS = "/remote.php/webdav/" - } - - override fun getToolbarTitle(): String = if (args.spaceId == ARG_VAL_NEW_SPACE) { - "Private Server" - } else { - val space = Space.get(args.spaceId) - when { - space?.name?.isNotBlank() == true -> space.name - space?.friendlyName?.isNotBlank() == true -> space.friendlyName - else -> "Private Server" - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavRepository.kt deleted file mode 100644 index ebbb3c8ef..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavRepository.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.opendasharchive.openarchive.services.webdav - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.services.SaveClientFactory -import okhttp3.Request -import java.io.IOException -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine - -class WebDavRepository( - private val saveClientFactory: SaveClientFactory -) { - suspend fun testConnection(space: Space) = withContext(Dispatchers.IO) { - val url = space.hostUrl ?: throw IOException("400 Bad Request") - - val client = saveClientFactory.createClient(space.username, space.password) - - val request = Request.Builder() - .url(url) - .method("GET", null) - .addHeader("OCS-APIRequest", "true") - .addHeader("Accept", "application/json") - .build() - - suspendCoroutine { continuation -> - client.newCall(request).enqueue(object : okhttp3.Callback { - override fun onFailure(call: okhttp3.Call, e: IOException) { - continuation.resumeWithException(e) - } - - override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) { - val code = response.code - val message = response.message - response.close() - - if (code != 200 && code != 204) { - continuation.resumeWithException(IOException("$code $message")) - } else { - continuation.resume(Unit) - } - } - }) - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavScreen.kt deleted file mode 100644 index f56fd8232..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavScreen.kt +++ /dev/null @@ -1,674 +0,0 @@ -package net.opendasharchive.openarchive.services.webdav - -import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.activity.addCallback -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors -import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.ToolbarConfigurable -import net.opendasharchive.openarchive.features.core.UiImage -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.ButtonData -import net.opendasharchive.openarchive.features.core.dialog.DialogConfig -import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.features.core.dialog.showErrorDialog -import net.opendasharchive.openarchive.features.internetarchive.presentation.login.CustomSecureField -import net.opendasharchive.openarchive.features.internetarchive.presentation.login.CustomTextField -import net.opendasharchive.openarchive.util.NetworkUtils -import org.koin.androidx.compose.koinViewModel - -class WebDavScreenFragment : BaseFragment(), ToolbarConfigurable { - - private val args: WebDavScreenFragmentArgs by navArgs() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setContent { - SaveAppTheme { - WebDavScreen( - onNavigateToLicenseSetup = { spaceId -> - val action = WebDavScreenFragmentDirections - .actionFragmentWebDavToFragmentSetupLicense( - spaceId = spaceId, - spaceType = Space.Type.WEBDAV - ) - findNavController().navigate(action) - }, - onNavigateBack = { - findNavController().popBackStack() - } - ) - } - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // Handle back press with unsaved changes check - if (args.spaceId != WebDavViewModel.ARG_VAL_NEW_SPACE) { - requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { - // This will be handled by the Compose screen via events - findNavController().popBackStack() - } - } - } - - override fun getToolbarTitle(): String { - return if (args.spaceId == WebDavViewModel.ARG_VAL_NEW_SPACE) { - getString(R.string.private_server) - } else { - val space = Space.get(args.spaceId) - when { - space?.name?.isNotBlank() == true -> space.name - space?.friendlyName?.isNotBlank() == true -> space.friendlyName - else -> getString(R.string.private_server) - } - } - } - - override fun shouldShowBackButton() = true -} - -@Composable -private fun WebDavScreen( - viewModel: WebDavViewModel = koinViewModel(), - onNavigateToLicenseSetup: (Long) -> Unit, - onNavigateBack: () -> Unit -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - - val context = LocalContext.current - val activity = context as FragmentActivity - val dialogManager = (activity as BaseActivity).dialogManager - - LaunchedEffect(Unit) { - viewModel.events.collect { event -> - when (event) { - is WebDavEvent.NavigateToLicenseSetup -> { - onNavigateToLicenseSetup(event.spaceId) - } - - is WebDavEvent.NavigateBack -> { - onNavigateBack() - } - - is WebDavEvent.ShowUnsavedChangesDialog -> { - dialogManager.showDialog( - DialogConfig( - type = DialogType.Warning, - title = UiText.StringResource(R.string.unsaved_changes), - message = UiText.StringResource(R.string.do_you_want_to_save), - icon = UiImage.DynamicVector(Icons.Default.Warning), - positiveButton = ButtonData( - text = UiText.StringResource(R.string.lbl_save), - action = { viewModel.onAction(WebDavAction.SaveChanges) } - ), - neutralButton = ButtonData( - text = UiText.StringResource(R.string.lbl_discard), - action = { viewModel.onAction(WebDavAction.DiscardChanges) } - ) - ) - ) - } - - is WebDavEvent.ShowRemoveConfirmationDialog -> { - dialogManager.showDialog( - DialogConfig( - type = DialogType.Warning, - title = UiText.StringResource(R.string.remove_from_app), - message = UiText.StringResource(R.string.are_you_sure_you_want_to_remove_this_server_from_the_app), - icon = UiImage.DrawableResource(R.drawable.ic_trash), - destructiveButton = ButtonData( - text = UiText.StringResource(R.string.lbl_remove), - action = { viewModel.onAction(WebDavAction.ConfirmRemoveSpace) } - ), - neutralButton = ButtonData( - text = UiText.StringResource(R.string.lbl_Cancel), - action = {} - ) - ) - ) - } - - is WebDavEvent.ShowSuccessDialog -> { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Success - title = UiText.StringResource(R.string.label_success_title) - message = UiText.StringResource(R.string.msg_edit_server_success) - icon = UiImage.DrawableResource(R.drawable.ic_done) - positiveButton { - text = UiText.StringResource(R.string.lbl_got_it) - action = { onNavigateBack() } - } - } - } - - is WebDavEvent.ShowError -> { - dialogManager.showErrorDialog( - message = event.message.asString(context), - title = context.getString(R.string.error) - ) - } - } - } - } - - WebDavContent( - state = state, - onAction = viewModel::onAction, - ) -} - -@Composable -private fun WebDavContent( - state: WebDavState, - onAction: (WebDavAction) -> Unit, -) { - val context = LocalContext.current - val scrollState = rememberScrollState() - val focusManager = LocalFocusManager.current - val usernameFocusRequester = remember { FocusRequester() } - val passwordFocusRequester = remember { FocusRequester() } - - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 100.dp) - ) { - // Header section (only for new server) - if (!state.isEditMode) { - WebDavHeader( - modifier = Modifier - .padding(top = 48.dp, bottom = 24.dp) - .padding(end = 24.dp) - ) - } - - // Server Info Section - Text( - text = stringResource(R.string.server_info), - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 18.sp - ), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) - ) - - // Server URL field - CustomTextField( - value = state.serverUrl, - onValueChange = { - onAction(WebDavAction.ClearError) - onAction(WebDavAction.UpdateServerUrl(it)) - }, - label = stringResource(R.string.enter_url), - placeholder = stringResource(R.string.enter_url), - enabled = !state.isEditMode, - isError = state.serverError != null, - isLoading = state.isLoading, - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Next, - onImeAction = { - usernameFocusRequester.requestFocus() - }, - onFocusChange = { isFocused -> - if (!isFocused && !state.isEditMode) { - onAction(WebDavAction.FixServerUrl) - } - } - ) - - Spacer(modifier = Modifier.height(ThemeDimensions.spacing.medium)) - - // Name field (only in edit mode) - if (state.isEditMode) { - CustomTextField( - value = state.name, - onValueChange = { onAction(WebDavAction.UpdateName(it)) }, - label = stringResource(R.string.server_name_optional), - placeholder = stringResource(R.string.server_name_optional), - enabled = true, - isLoading = state.isLoading, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, - onImeAction = { - // Trigger save when user presses Done on keyboard - if (state.isNameChanged) { - onAction(WebDavAction.SaveChanges) - } - } - ) - - Spacer(modifier = Modifier.height(ThemeDimensions.spacing.large)) - } - - // Account Section - Text( - text = stringResource(R.string.account), - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 18.sp - ), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp, top = 16.dp) - ) - - // Username field - CustomTextField( - value = state.username, - onValueChange = { - onAction(WebDavAction.ClearError) - onAction(WebDavAction.UpdateUsername(it)) - }, - label = stringResource(R.string.prompt_username), - placeholder = stringResource(R.string.prompt_username), - enabled = !state.isEditMode, - isError = state.usernameError != null || state.isCredentialsError, - isLoading = state.isLoading, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - onImeAction = { - passwordFocusRequester.requestFocus() - }, - modifier = Modifier.focusRequester(usernameFocusRequester) - ) - - Spacer(modifier = Modifier.height(ThemeDimensions.spacing.medium)) - - // Password field - CustomSecureField( - value = state.password, - onValueChange = { - onAction(WebDavAction.ClearError) - onAction(WebDavAction.UpdatePassword(it)) - }, - label = stringResource(R.string.prompt_password), - placeholder = stringResource(R.string.prompt_password), - isError = state.passwordError != null || state.isCredentialsError, - isLoading = state.isLoading || state.isEditMode, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - onImeAction = { - focusManager.clearFocus() - }, - modifier = Modifier.focusRequester(passwordFocusRequester) - ) - - // Error hint - AnimatedVisibility( - visible = state.isCredentialsError, - enter = fadeIn(), - exit = fadeOut() - ) { - Text( - text = stringResource(R.string.error_incorrect_username_or_password), - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp) - ) - } - - // License Section (only in edit mode) - if (state.isEditMode) { - Spacer(modifier = Modifier.height(ThemeDimensions.spacing.large)) - - Text( - text = stringResource(R.string.license_label), - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 18.sp - ), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) - ) - - CreativeCommonsLicenseContent( - licenseState = LicenseState( - ccEnabled = state.ccEnabled, - allowRemix = state.allowRemix, - requireShareAlike = state.requireShareAlike, - allowCommercial = state.allowCommercial, - cc0Enabled = state.cc0Enabled, - licenseUrl = state.licenseUrl - ), - licenseCallbacks = object : LicenseCallbacks { - override fun onCcEnabledChange(enabled: Boolean) { - onAction(WebDavAction.UpdateCcEnabled(enabled)) - } - - override fun onAllowRemixChange(allowed: Boolean) { - onAction(WebDavAction.UpdateAllowRemix(allowed)) - } - - override fun onRequireShareAlikeChange(required: Boolean) { - onAction(WebDavAction.UpdateRequireShareAlike(required)) - } - - override fun onAllowCommercialChange(allowed: Boolean) { - onAction(WebDavAction.UpdateAllowCommercial(allowed)) - } - - override fun onCc0EnabledChange(enabled: Boolean) { - onAction(WebDavAction.UpdateCc0Enabled(enabled)) - } - }, - ccLabelText = stringResource(R.string.set_creative_commons_license_for_all_folders_on_this_server) - ) - - // Remove button (edit mode) - Spacer(modifier = Modifier.height(ThemeDimensions.spacing.large)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - TextButton( - onClick = { onAction(WebDavAction.RemoveSpace) }, - enabled = !state.isLoading, - colors = ButtonDefaults.textButtonColors( - contentColor = colorResource(R.color.red_bg) - ) - ) { - Text( - text = stringResource(R.string.remove_from_app), - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 18.sp - ) - ) - } - } - } - } - - // Button bar (only for new server) - if (!state.isEditMode) { - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - // Back button - TextButton( - modifier = Modifier - .heightIn(ThemeDimensions.touchable) - .weight(1f), - colors = ButtonDefaults.textButtonColors( - contentColor = colorResource(R.color.colorOnBackground) - ), - enabled = !state.isLoading, - shape = RoundedCornerShape(ThemeDimensions.roundedCorner), - onClick = { onAction(WebDavAction.Cancel) } - ) { - Text( - stringResource(R.string.back), - style = MaterialTheme.typography.titleLarge - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Next/Authenticate button - Button( - modifier = Modifier - .heightIn(ThemeDimensions.touchable) - .weight(1f), - enabled = !state.isLoading && state.isFormValid, - shape = RoundedCornerShape(ThemeDimensions.roundedCorner), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - disabledContainerColor = colorResource(R.color.grey_50), - disabledContentColor = colorResource(R.color.black), - contentColor = colorResource(R.color.black) - ), - onClick = { - if (NetworkUtils.isNetworkAvailable(context)) { - onAction(WebDavAction.Authenticate) - } else { - Toast.makeText(context, R.string.error_no_internet, Toast.LENGTH_LONG) - .show() - } - } - ) { - if (state.isLoading) { - CircularProgressIndicator( - color = ThemeColors.material.primary, - modifier = Modifier.size(24.dp) - ) - } else { - Text( - stringResource(R.string.action_next), - style = MaterialTheme.typography.titleLarge - ) - } - } - } - } - - // Loading overlay - if (state.isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(colorResource(R.color.transparent_loading_overlay)), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - } -} - -@Composable -private fun WebDavHeader(modifier: Modifier = Modifier) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(colorResource(R.color.colorBackgroundSpaceIcon)) - .padding(8.dp), - contentAlignment = Alignment.Center - ) { - Image( - modifier = Modifier.size(32.dp), - painter = painterResource(id = R.drawable.ic_private_server), - contentDescription = stringResource(R.string.private_server), - colorFilter = ColorFilter.tint(colorResource(R.color.colorTertiary)) - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Text( - text = stringResource(R.string.save_connects_to_webdav_compatible_servers_only_such_as_nextcloud_and_owncloud), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 32.dp) - ) - } -} - -// Previews -@Preview(showBackground = true, name = "WebDav New Server") -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "WebDav New Server Dark") -@Composable -private fun WebDavNewServerPreview() { - DefaultScaffoldPreview { - WebDavContent( - state = WebDavState( - isEditMode = false, - serverUrl = "", - username = "", - password = "" - ), - onAction = {} - ) - } -} - -//@Preview(showBackground = true, name = "WebDav New Server Filled") -@Composable -private fun WebDavNewServerFilledPreview() { - DefaultScaffoldPreview { - WebDavContent( - state = WebDavState( - isEditMode = false, - serverUrl = "https://cloud.example.com", - username = "user@example.com", - password = "password123" - ), - onAction = {} - ) - } -} - -//@Preview(showBackground = true, name = "WebDav New Server Error") -@Composable -private fun WebDavNewServerErrorPreview() { - DefaultScaffoldPreview { - WebDavContent( - state = WebDavState( - isEditMode = false, - serverUrl = "https://cloud.example.com", - username = "user@example.com", - password = "wrongpassword", - isCredentialsError = true, - usernameError = UiText.DynamicString(" "), - passwordError = UiText.DynamicString(" ") - ), - onAction = {} - ) - } -} - -//@Preview(showBackground = true, name = "WebDav Edit Mode") -//@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "WebDav Edit Mode Dark") -@Composable -private fun WebDavEditModePreview() { - DefaultScaffoldPreview { - WebDavContent( - state = WebDavState( - isEditMode = true, - spaceId = 1L, - serverUrl = "https://cloud.example.com/remote.php/webdav/", - username = "user@example.com", - password = "password123", - name = "My Cloud Server", - originalName = "My Cloud Server", - ccEnabled = true, - allowRemix = true, - requireShareAlike = true, - allowCommercial = false, - licenseUrl = "https://creativecommons.org/licenses/by-nc-sa/4.0/" - ), - onAction = {} - ) - } -} - -//@Preview(showBackground = true, name = "WebDav Loading") -@Composable -private fun WebDavLoadingPreview() { - DefaultScaffoldPreview { - WebDavContent( - state = WebDavState( - isEditMode = false, - serverUrl = "https://cloud.example.com", - username = "user@example.com", - password = "password123", - isLoading = true - ), - onAction = {} - ) - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavState.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavState.kt deleted file mode 100644 index c0018d1d9..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavState.kt +++ /dev/null @@ -1,83 +0,0 @@ -package net.opendasharchive.openarchive.services.webdav - -import androidx.compose.runtime.Immutable -import net.opendasharchive.openarchive.features.core.UiText - -@Immutable -data class WebDavState( - // Form fields - val serverUrl: String = "", - val username: String = "", - val password: String = "", - val name: String = "", - - // Mode flags - val isEditMode: Boolean = false, - val spaceId: Long = -1L, - - // Field errors - val serverError: UiText? = null, - val usernameError: UiText? = null, - val passwordError: UiText? = null, - - // UI state - val isLoading: Boolean = false, - val isCredentialsError: Boolean = false, - val errorMessage: UiText? = null, - val isNameChanged: Boolean = false, - val originalName: String = "", - val isPasswordVisible: Boolean = false, - - // Creative Commons License state (for edit mode) - val ccEnabled: Boolean = false, - val allowRemix: Boolean = false, - val requireShareAlike: Boolean = false, - val allowCommercial: Boolean = false, - val cc0Enabled: Boolean = false, - val licenseUrl: String? = null -) { - val isFormValid: Boolean - get() = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank() - - val hasUnsavedChanges: Boolean - get() = isEditMode && isNameChanged -} - -sealed interface WebDavAction { - // Form updates - data class UpdateServerUrl(val url: String) : WebDavAction - data class UpdateUsername(val username: String) : WebDavAction - data class UpdatePassword(val password: String) : WebDavAction - data class UpdateName(val name: String) : WebDavAction - data object FixServerUrl : WebDavAction - - // UI actions - data object TogglePasswordVisibility : WebDavAction - data object ClearError : WebDavAction - - // Authentication - data object Authenticate : WebDavAction - data object Cancel : WebDavAction - - // Edit mode actions - data object SaveChanges : WebDavAction - data object RemoveSpace : WebDavAction - data object ConfirmRemoveSpace : WebDavAction - data object DiscardChanges : WebDavAction - - // Creative Commons License actions - data class UpdateCcEnabled(val enabled: Boolean) : WebDavAction - data class UpdateAllowRemix(val allowed: Boolean) : WebDavAction - data class UpdateRequireShareAlike(val required: Boolean) : WebDavAction - data class UpdateAllowCommercial(val allowed: Boolean) : WebDavAction - data class UpdateCc0Enabled(val enabled: Boolean) : WebDavAction -} - -sealed interface WebDavEvent { - data class NavigateToLicenseSetup(val spaceId: Long) : WebDavEvent - data object NavigateBack : WebDavEvent - data object ShowUnsavedChangesDialog : WebDavEvent - data object ShowRemoveConfirmationDialog : WebDavEvent - data object ShowSuccessDialog : WebDavEvent - data class ShowError(val message: UiText) : WebDavEvent -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt deleted file mode 100644 index a065ad333..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt +++ /dev/null @@ -1,450 +0,0 @@ -package net.opendasharchive.openarchive.services.webdav - -import androidx.core.net.toUri -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager -import net.opendasharchive.openarchive.analytics.api.AnalyticsManager -import java.io.IOException - -class WebDavViewModel( - private val repository: WebDavRepository, - savedStateHandle: SavedStateHandle, - private val analyticsManager: AnalyticsManager -) : ViewModel() { - - companion object { - const val ARG_VAL_NEW_SPACE = -1L - private const val REMOTE_PHP_ADDRESS = "/remote.php/webdav/" - } - - private val spaceId: Long = savedStateHandle.get("space_id") ?: ARG_VAL_NEW_SPACE - - private var space: Space = if (spaceId != ARG_VAL_NEW_SPACE) { - Space.get(spaceId) ?: Space(Space.Type.WEBDAV) - } else { - Space(Space.Type.WEBDAV) - } - - private val _uiState = MutableStateFlow(WebDavState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _events = Channel() - val events = _events.receiveAsFlow() - - init { - loadSpaceData() - } - - private fun loadSpaceData() { - val isEditMode = spaceId != ARG_VAL_NEW_SPACE - - if (isEditMode) { - _uiState.update { currentState -> - val newState = currentState.copy( - isEditMode = true, - spaceId = spaceId, - serverUrl = space.host, - username = space.username, - password = space.password, - name = space.name, - originalName = space.name - ) - initializeLicenseState(newState, space.license) - } - } else { - _uiState.update { it.copy(isEditMode = false, spaceId = ARG_VAL_NEW_SPACE) } - } - } - - fun onAction(action: WebDavAction) { - when (action) { - is WebDavAction.UpdateServerUrl -> { - _uiState.update { - it.copy( - serverUrl = action.url, - serverError = null, - isCredentialsError = false - ) - } - } - - is WebDavAction.FixServerUrl -> { - val currentUrl = _uiState.value.serverUrl - if (currentUrl.isNotBlank()) { - val fixedUrl = fixSpaceUrl(currentUrl) - if (fixedUrl != null && fixedUrl.toString() != currentUrl) { - _uiState.update { - it.copy( - serverUrl = fixedUrl.toString(), - serverError = null - ) - } - } - } - } - - is WebDavAction.UpdateUsername -> { - _uiState.update { - it.copy( - username = action.username, - usernameError = null, - isCredentialsError = false - ) - } - } - - is WebDavAction.UpdatePassword -> { - _uiState.update { - it.copy( - password = action.password, - passwordError = null, - isCredentialsError = false - ) - } - } - - is WebDavAction.UpdateName -> { - val isChanged = action.name.trim() != _uiState.value.originalName - _uiState.update { it.copy(name = action.name, isNameChanged = isChanged) } - } - - is WebDavAction.TogglePasswordVisibility -> { - _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) } - } - - is WebDavAction.ClearError -> { - _uiState.update { - it.copy( - isCredentialsError = false, - serverError = null, - usernameError = null, - passwordError = null, - errorMessage = null - ) - } - } - - is WebDavAction.Authenticate -> { - performAuthentication() - } - - is WebDavAction.Cancel -> { - viewModelScope.launch { - if (_uiState.value.hasUnsavedChanges) { - _events.send(WebDavEvent.ShowUnsavedChangesDialog) - } else { - _events.send(WebDavEvent.NavigateBack) - } - } - } - - is WebDavAction.SaveChanges -> { - saveChanges() - } - - is WebDavAction.RemoveSpace -> { - viewModelScope.launch { - _events.send(WebDavEvent.ShowRemoveConfirmationDialog) - } - } - - is WebDavAction.ConfirmRemoveSpace -> { - removeSpace() - } - - is WebDavAction.DiscardChanges -> { - viewModelScope.launch { - _events.send(WebDavEvent.NavigateBack) - } - } - - // Creative Commons License actions - is WebDavAction.UpdateCcEnabled -> { - _uiState.update { currentState -> - if (action.enabled) { - currentState.copy( - ccEnabled = true, - cc0Enabled = false, - allowRemix = false, - requireShareAlike = false, - allowCommercial = false, - licenseUrl = null - ) - } else { - currentState.copy( - ccEnabled = false, - allowRemix = false, - requireShareAlike = false, - allowCommercial = false, - cc0Enabled = false, - licenseUrl = null - ) - } - } - generateAndUpdateLicense() - } - - is WebDavAction.UpdateAllowRemix -> { - _uiState.update { currentState -> - currentState.copy( - allowRemix = action.allowed, - cc0Enabled = if (action.allowed) false else currentState.cc0Enabled, - requireShareAlike = if (!action.allowed) false else currentState.requireShareAlike - ) - } - generateAndUpdateLicense() - } - - is WebDavAction.UpdateRequireShareAlike -> { - _uiState.update { currentState -> - currentState.copy( - requireShareAlike = action.required, - cc0Enabled = if (action.required) false else currentState.cc0Enabled - ) - } - generateAndUpdateLicense() - } - - is WebDavAction.UpdateAllowCommercial -> { - _uiState.update { currentState -> - currentState.copy( - allowCommercial = action.allowed, - cc0Enabled = if (action.allowed) false else currentState.cc0Enabled - ) - } - generateAndUpdateLicense() - } - - is WebDavAction.UpdateCc0Enabled -> { - _uiState.update { currentState -> - if (action.enabled) { - currentState.copy( - cc0Enabled = true, - allowRemix = false, - requireShareAlike = false, - allowCommercial = false - ) - } else { - currentState.copy(cc0Enabled = false) - } - } - generateAndUpdateLicense() - } - } - } - - private fun performAuthentication() { - val currentState = _uiState.value - - // Validate fields - var hasError = false - var updatedState = currentState - - val fixedUrl = fixSpaceUrl(currentState.serverUrl) - if (fixedUrl == null) { - updatedState = updatedState.copy(serverError = UiText.StringResource(R.string.error_field_required)) - hasError = true - } - - if (currentState.username.isBlank()) { - updatedState = updatedState.copy(usernameError = UiText.StringResource(R.string.error_field_required)) - hasError = true - } - - if (currentState.password.isBlank()) { - updatedState = updatedState.copy(passwordError = UiText.StringResource(R.string.error_field_required)) - hasError = true - } - - if (hasError) { - _uiState.update { updatedState } - return - } - - // Update space with form values - space.host = fixedUrl.toString() - space.username = currentState.username - space.password = currentState.password - - // Check for duplicate credentials - val existing = Space.get(Space.Type.WEBDAV, space.host, space.username) - if (existing.isNotEmpty() && existing[0].id != space.id) { - viewModelScope.launch { - _events.send(WebDavEvent.ShowError(UiText.StringResource(R.string.you_already_have_a_server_with_these_credentials))) - } - return - } - - _uiState.update { it.copy(isLoading = true, serverUrl = space.host) } - - viewModelScope.launch { - try { - repository.testConnection(space) - - // Check if this is a new backend or existing one - val isNewBackend = space.id == null || space.id == 0L - - space.save() - Space.current = space - - // Track backend configuration - analyticsManager.trackBackendConfigured( - backendType = Space.Type.WEBDAV.friendlyName, - isNew = isNewBackend - ) - - _uiState.update { it.copy(isLoading = false) } - _events.send(WebDavEvent.NavigateToLicenseSetup(space.id)) - } catch (e: IOException) { - _uiState.update { it.copy(isLoading = false) } - e.printStackTrace() - when { - e.message?.startsWith("401") == true -> { - _uiState.update { - it.copy( - isCredentialsError = true, - usernameError = UiText.DynamicString(" "), - passwordError = UiText.DynamicString(" ") - ) - } - } - - // Invalid server URL errors (unable to resolve, 404, 400, etc.) - e.message?.contains("Unable to resolve host", ignoreCase = true) == true || - e.message?.startsWith("404") == true || - e.message?.startsWith("400") == true || - e.message?.startsWith("403") == true -> { - _uiState.update { it.copy(serverError = UiText.DynamicString(" ")) } - _events.send(WebDavEvent.ShowError(UiText.StringResource(R.string.web_dav_host_error))) - } - - else -> { - // Other server errors (500, etc.) - _uiState.update { it.copy(serverError = UiText.DynamicString(" ")) } - _events.send(WebDavEvent.ShowError(UiText.DynamicString(e.localizedMessage ?: "An error occurred"))) - } - } - } - } - } - - private fun fixSpaceUrl(url: String?): android.net.Uri? { - if (url.isNullOrBlank()) return null - - val uri = url.toUri() - val builder = uri.buildUpon() - - if (uri.scheme != "https") { - builder.scheme("https") - } - - if (uri.authority.isNullOrBlank()) { - builder.authority(uri.path) - builder.path(REMOTE_PHP_ADDRESS) - } else if (uri.path.isNullOrBlank() || uri.path == "/") { - builder.path(REMOTE_PHP_ADDRESS) - } - - return builder.build() - } - - private fun saveChanges() { - val enteredName = _uiState.value.name.trim() - space.name = enteredName - space.save() - - _uiState.update { - it.copy( - originalName = enteredName, - isNameChanged = false - ) - } - - viewModelScope.launch { - _events.send(WebDavEvent.ShowSuccessDialog) - } - } - - private fun removeSpace() { - viewModelScope.launch { - space.delete() - _events.send(WebDavEvent.NavigateBack) - } - } - - private fun initializeLicenseState(currentState: WebDavState, currentLicense: String?): WebDavState { - val isCc0 = currentLicense?.contains("publicdomain/zero", true) ?: false - val isCC = currentLicense?.contains("creativecommons.org/licenses", true) ?: false - - return if (isCc0) { - currentState.copy( - ccEnabled = true, - cc0Enabled = true, - allowRemix = false, - allowCommercial = false, - requireShareAlike = false, - licenseUrl = currentLicense - ) - } else if (isCC && currentLicense != null) { - currentState.copy( - ccEnabled = true, - cc0Enabled = false, - allowRemix = !(currentLicense.contains("-nd", true)), - allowCommercial = !(currentLicense.contains("-nc", true)), - requireShareAlike = !(currentLicense.contains("-nd", true)) && currentLicense.contains("-sa", true), - licenseUrl = currentLicense - ) - } else { - currentState.copy( - ccEnabled = false, - cc0Enabled = false, - allowRemix = false, - allowCommercial = false, - requireShareAlike = false, - licenseUrl = null - ) - } - } - - private fun generateAndUpdateLicense() { - val currentState = _uiState.value - val newLicense = CreativeCommonsLicenseManager.generateLicenseUrl( - ccEnabled = currentState.ccEnabled, - allowRemix = currentState.allowRemix, - requireShareAlike = currentState.requireShareAlike, - allowCommercial = currentState.allowCommercial, - cc0Enabled = currentState.cc0Enabled - ) - - _uiState.update { it.copy(licenseUrl = newLicense) } - - if (_uiState.value.isEditMode) { - space.license = newLicense - space.save() - } - } - - fun getToolbarTitle(): String { - return if (!_uiState.value.isEditMode) { - "Private Server" - } else { - when { - space.name.isNotBlank() -> space.name - space.friendlyName.isNotBlank() -> space.friendlyName - else -> "Private Server" - } - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavAuthenticator.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavAuthenticator.kt new file mode 100644 index 000000000..bd4270376 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavAuthenticator.kt @@ -0,0 +1,98 @@ +package net.opendasharchive.openarchive.services.webdav.data + +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Credentials +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultAuthenticator +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.services.SaveClientFactory +import okhttp3.Request +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class WebDavAuthenticator( + private val saveClientFactory: SaveClientFactory +) : VaultAuthenticator { + + companion object { + private const val REMOTE_PHP_ADDRESS = "/remote.php/webdav/" + } + + override suspend fun authenticate(credentials: Credentials): Result { + if (credentials !is Credentials.WebDav) { + return Result.failure(IllegalArgumentException("Invalid credentials type")) + } + + val fixedUrl = fixSpaceUrl(credentials.url) ?: return Result.failure(IllegalArgumentException("Invalid URL")) + + val tempVault = Vault( + type = VaultType.PRIVATE_SERVER, + host = fixedUrl.toString(), + username = credentials.user, + password = credentials.pass + ) + + return testConnection(tempVault).map { tempVault } + } + + override suspend fun testConnection(vault: Vault): Result = withContext(Dispatchers.IO) { + try { + val url = vault.host + val client = saveClientFactory.createClient(vault.username, vault.password) + + val request = Request.Builder() + .url(url) + .method("GET", null) + .addHeader("OCS-APIRequest", "true") + .addHeader("Accept", "application/json") + .build() + + suspendCoroutine { continuation -> + client.newCall(request).enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) { + val code = response.code + val message = response.message + response.close() + + if (code != 200 && code != 204) { + continuation.resumeWithException(IOException("$code $message")) + } else { + continuation.resume(Unit) + } + } + }) + } + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + fun fixSpaceUrl(url: String?): android.net.Uri? { + if (url.isNullOrBlank()) return null + + val uri = url.toUri() + val builder = uri.buildUpon() + + if (uri.scheme != "https") { + builder.scheme("https") + } + + if (uri.authority.isNullOrBlank()) { + builder.authority(uri.path) + builder.path(REMOTE_PHP_ADDRESS) + } else if (uri.path.isNullOrBlank() || uri.path == "/") { + builder.path(REMOTE_PHP_ADDRESS) + } + + return builder.build() + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt similarity index 51% rename from app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavConduit.kt rename to app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt index 9826d2c5d..b3c7ff2fb 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavConduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt @@ -1,88 +1,91 @@ -package net.opendasharchive.openarchive.services.webdav +package net.opendasharchive.openarchive.services.webdav.data import android.content.Context import com.thegrizzlylabs.sardineandroid.SardineListener import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.db.Media +import net.opendasharchive.openarchive.core.domain.Evidence import net.opendasharchive.openarchive.services.Conduit import net.opendasharchive.openarchive.services.SaveClient import okhttp3.HttpUrl import java.io.IOException -class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { +class WebDavConduit(evidence: Evidence, context: Context) : Conduit(evidence, context) { private lateinit var mClient: OkHttpSardine override suspend fun upload(): Boolean { - val space = mMedia.space ?: return false - val base = space.hostUrl ?: return false - val path = getPath() ?: return false + try { + val vault = spaceRepository.getSpaceById(mEvidence.vaultId) ?: return false + val auth = spaceRepository.getVaultAuth(mEvidence.vaultId) ?: return false + val base = vault.hostUrl ?: return false + val path = getPath() ?: return false - mClient = SaveClient.getSardine(mContext, space) + mClient = SaveClient.getSardine(mContext, auth.username, auth.secret) - sanitize() + sanitize() - val fileName = getUploadFileName(mMedia) + val fileName = getUploadFileName(mEvidence) - try { - createFolders(base, path) + try { + val archive = projectRepository.getProject(mEvidence.archiveId) + createFolders(base, path, archive?.isRemote ?: false) - uploadMetadata(base, path, fileName) - } - catch (e: Throwable) { - jobFailed(e) + uploadMetadata(base, path, fileName) + } catch (e: Throwable) { + jobFailed(e) - return false - } + return false + } -// if (space.useChunking && mMedia.contentLength > CHUNK_FILESIZE_THRESHOLD) { -// return uploadChunked(base, path, fileName) -// } + AppLogger.i("Begin media file upload...") + if (mEvidence.contentLength > CHUNK_FILESIZE_THRESHOLD) { + return uploadChunked(base, path, fileName) + } - AppLogger.i("Begin media file upload...") - if (mMedia.contentLength > CHUNK_FILESIZE_THRESHOLD) { - return uploadChunked(base, path, fileName) - } + val fullPath = construct(base, path, fileName) + AppLogger.i("Uploading started for single file upload...", "filePath: $fullPath") + + try { + mClient.put( + mContext.contentResolver, + fullPath, + mEvidence.fileUri, + mEvidence.contentLength, + mEvidence.mimeType, + false, + object : SardineListener { + var lastBytes: Long = 0 + + override fun transferred(bytes: Long) { + if (bytes > lastBytes) { + jobProgress(bytes) + lastBytes = bytes + } + AppLogger.i("Bytes transferred for for ${mEvidence.id}: ", "$bytes") + } - val fullPath = construct(base, path, fileName) - AppLogger.i("Uploading started for single file upload...", "filePath: $fullPath") + override fun continueUpload(): Boolean { + AppLogger.i("Should continue upload for ${mEvidence.id}?", "$mCancelled") + return !mCancelled + } + }) + } catch (e: Throwable) { + jobFailed(e) - try { - mClient.put(mContext.contentResolver, - fullPath, - mMedia.fileUri, - mMedia.contentLength, - mMedia.mimeType, - false, - object : SardineListener { - var lastBytes: Long = 0 + return false + } - override fun transferred(bytes: Long) { - if (bytes > lastBytes) { - jobProgress(bytes) - lastBytes = bytes - } - AppLogger.i("Bytes transferred for for ${mMedia.id}: ", "$bytes") - } + mEvidence = mEvidence.copy(serverUrl = fullPath) + jobSucceeded() - override fun continueUpload(): Boolean { - AppLogger.i("Should continue upload for ${mMedia.id}?", "$mCancelled") - return !mCancelled - } - }) - } - catch (e: Throwable) { + return true + } catch (e: Throwable) { jobFailed(e) - - return false } - mMedia.serverUrl = fullPath - jobSucceeded() - - return true + return false } override suspend fun createFolder(url: String) { @@ -96,8 +99,8 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { @Throws(IOException::class) private suspend fun uploadChunked(base: HttpUrl, path: List, fileName: String): Boolean { AppLogger.i("Uploading started as chunked upload...") - val space = mMedia.space ?: return false - val url = space.hostUrl ?: return false + val vault = spaceRepository.getSpaceById(mEvidence.vaultId) ?: return false + val url = vault.hostUrl ?: return false val tmpBase = HttpUrl.Builder() .scheme(url.scheme) @@ -111,7 +114,7 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { .addPathSegment("dav") .build() - val tmpPath = listOf("uploads", space.username, fileName) + val tmpPath = listOf("uploads", vault.username, fileName) return try { createFolders(tmpBase, tmpPath) @@ -121,8 +124,8 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { var offset = 0 - mMedia.file.inputStream().use { inputStream -> - while (!mCancelled && offset < mMedia.contentLength) { + mEvidence.file.inputStream().use { inputStream -> + while (!mCancelled && offset < mEvidence.contentLength) { var buffer = ByteArray(CHUNK_SIZE.toInt()) val length = inputStream.read(buffer) @@ -147,7 +150,7 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { mClient.put( chunkPath, buffer, - mMedia.mimeType, + mEvidence.mimeType, object : SardineListener { override fun transferred(bytes: Long) { jobProgress(offset.toLong() + bytes) @@ -166,25 +169,24 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { if (mCancelled) throw Exception("Cancelled") - val dest = mutableListOf("files", space.username) + val dest = mutableListOf("files", vault.username) dest.addAll(path) mClient.move(construct(tmpBase, tmpPath, ".file"), construct(tmpBase, dest, fileName)) - mMedia.serverUrl = construct(base, path, fileName) + mEvidence = mEvidence.copy(serverUrl = construct(base, path, fileName)) jobSucceeded() true - } - catch (e: Throwable) { + } catch (e: Throwable) { jobFailed(e) false } } - private fun uploadMetadata(base: HttpUrl, path: List, fileName: String) { + private suspend fun uploadMetadata(base: HttpUrl, path: List, fileName: String) { AppLogger.i("Uploading metadata....") val metadata = getMetadata() @@ -197,13 +199,19 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { null ) - /// Upload ProofMode metadata, if enabled and successfully created. - for (file in getProof()) { + /// Upload C2PA manifest, if enabled and successfully created. + val c2paManifest = getC2paManifest() + if (c2paManifest != null) { if (mCancelled) throw Exception("Cancelled") + AppLogger.d("Uploading C2PA manifest: ${c2paManifest.name}") mClient.put( - construct(base, path, file.name), file, "text/plain", - false, null) + construct(base, path, c2paManifest.name), + c2paManifest, + "application/json", + false, + null + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavRepository.kt new file mode 100644 index 000000000..dedd80b16 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavRepository.kt @@ -0,0 +1,32 @@ +package net.opendasharchive.openarchive.services.webdav.data + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.folders.Folder +import net.opendasharchive.openarchive.services.SaveClient +import net.opendasharchive.openarchive.util.DateUtils +import net.opendasharchive.openarchive.util.toKotlinLocalDateTime +import java.io.IOException + +class WebDavRepository( + private val context: Context, + private val spaceRepository: SpaceRepository +) { + @Throws(IOException::class) + suspend fun getFolders(vault: Vault): List = withContext(Dispatchers.IO) { + val auth = spaceRepository.getVaultAuth(vault.id) + ?: throw IOException("Credentials unavailable for selected server") + val root = vault.hostUrl?.encodedPath + + SaveClient.getSardine(context, auth.username, auth.secret).list(vault.host)?.mapNotNull { + if (it?.isDirectory == true && it.path != root) { + Folder(it.name, it.modified?.toKotlinLocalDateTime() ?: DateUtils.nowDateTime) + } else { + null + } + } ?: emptyList() + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/detail/WebDavDetailScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/detail/WebDavDetailScreen.kt new file mode 100644 index 000000000..8cd8aa871 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/detail/WebDavDetailScreen.kt @@ -0,0 +1,281 @@ +package net.opendasharchive.openarchive.services.webdav.presentation.detail + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomSecureField +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomTextField +import net.opendasharchive.openarchive.services.common.license.CreativeCommonsLicenseContent +import net.opendasharchive.openarchive.services.common.license.LicenseCallbacks +import net.opendasharchive.openarchive.services.common.license.LicenseState + +@Composable +fun WebDavDetailScreen( + viewModel: WebDavDetailViewModel, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + + } + } + + WebDavContent( + state = state, + onAction = viewModel::onAction, + ) +} + +@Composable +private fun WebDavContent( + state: WebDavDetailState, + onAction: (WebDavDetailAction) -> Unit, +) { + val scrollState = rememberScrollState() + val focusManager = LocalFocusManager.current + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 100.dp) + ) { + + // Server Info Section + Text( + text = stringResource(R.string.server_info), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Server URL field + CustomTextField( + value = state.serverUrl, + onValueChange = { + // Do Nothing + }, + placeholder = stringResource(R.string.enter_url), + enabled = false, + ) + + Spacer(modifier = Modifier.height(ThemeDimensions.spacing.medium)) + + // Name field (only in edit mode) + + CustomTextField( + value = state.name, + onValueChange = { onAction(WebDavDetailAction.UpdateName(it)) }, + placeholder = stringResource(R.string.server_name_optional), + enabled = true, + isLoading = state.isLoading, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + onImeAction = { + focusManager.clearFocus() + // Trigger save when user presses Done on keyboard if there are any unsaved changes + if (state.hasUnsavedChanges) { + onAction(WebDavDetailAction.SaveChanges) + } + } + ) + + Spacer(modifier = Modifier.height(ThemeDimensions.spacing.large)) + + + // Account Section + Text( + text = stringResource(R.string.account), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp, top = 16.dp) + ) + + // Username field + CustomTextField( + value = state.username, + onValueChange = { + // Do nothing + }, + placeholder = stringResource(R.string.prompt_username), + enabled = false, + ) + + + Spacer(modifier = Modifier.height(ThemeDimensions.spacing.medium)) + + // Password field + CustomSecureField( + value = "••••••••", + onValueChange = { + // Do nothing + }, + placeholder = stringResource(R.string.prompt_password), + enabled = false, + showToggle = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ) + + // License Section (only in edit mode) + Spacer(modifier = Modifier.height(ThemeDimensions.spacing.large)) + + Text( + text = stringResource(R.string.license_label), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + + CreativeCommonsLicenseContent( + licenseState = LicenseState( + ccEnabled = state.ccEnabled, + allowRemix = state.allowRemix, + requireShareAlike = state.requireShareAlike, + allowCommercial = state.allowCommercial, + cc0Enabled = state.cc0Enabled, + licenseUrl = state.licenseUrl + ), + licenseCallbacks = object : + LicenseCallbacks { + override fun onCcEnabledChange(enabled: Boolean) { + focusManager.clearFocus() + onAction(WebDavDetailAction.UpdateCcEnabled(enabled)) + } + + override fun onAllowRemixChange(allowed: Boolean) { + focusManager.clearFocus() + onAction(WebDavDetailAction.UpdateAllowRemix(allowed)) + } + + override fun onRequireShareAlikeChange(required: Boolean) { + focusManager.clearFocus() + onAction(WebDavDetailAction.UpdateRequireShareAlike(required)) + } + + override fun onAllowCommercialChange(allowed: Boolean) { + focusManager.clearFocus() + onAction(WebDavDetailAction.UpdateAllowCommercial(allowed)) + } + + override fun onCc0EnabledChange(enabled: Boolean) { + focusManager.clearFocus() + onAction(WebDavDetailAction.UpdateCc0Enabled(enabled)) + } + }, + ccLabelText = stringResource(R.string.set_creative_commons_license_for_all_folders_on_this_server) + ) + + // Remove button (edit mode) + Spacer(modifier = Modifier.height(ThemeDimensions.spacing.large)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + TextButton( + onClick = { onAction(WebDavDetailAction.RemoveSpace) }, + enabled = !state.isLoading, + colors = ButtonDefaults.textButtonColors( + contentColor = colorResource(R.color.red_bg) + ) + ) { + Text( + text = stringResource(R.string.remove_from_app), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + ) + } + } + + } + + // Loading overlay + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(colorResource(R.color.transparent_loading_overlay)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } +} + +// Previews +@Preview(showBackground = true, name = "WebDav Edit Mode") +@Preview( + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, + name = "WebDav Edit Mode Dark" +) +@Composable +private fun WebDavEditModePreview() { + DefaultScaffoldPreview { + WebDavContent( + state = WebDavDetailState( + spaceId = 1L, + serverUrl = "https://cloud.example.com/remote.php/webdav/", + username = "user@example.com", + password = "password123", + name = "My Cloud Server", + originalName = "My Cloud Server", + ccEnabled = true, + allowRemix = true, + requireShareAlike = true, + allowCommercial = false, + licenseUrl = "https://creativecommons.org/licenses/by-nc-sa/4.0/" + ), + onAction = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/detail/WebDavDetailState.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/detail/WebDavDetailState.kt new file mode 100644 index 000000000..abd974e61 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/detail/WebDavDetailState.kt @@ -0,0 +1,63 @@ +package net.opendasharchive.openarchive.services.webdav.presentation.detail + +import androidx.compose.runtime.Immutable +import net.opendasharchive.openarchive.features.core.UiText + +@Immutable +data class WebDavDetailState( + + val spaceId: Long, + + // Form fields + val serverUrl: String = "", + val username: String = "", + val password: String = "", + val name: String = "", + + // UI state + val isLoading: Boolean = false, + val errorMessage: UiText? = null, + val isNameChanged: Boolean = false, + val originalName: String = "", + + // Creative Commons License state (for edit mode) + val ccEnabled: Boolean = false, + val allowRemix: Boolean = false, + val requireShareAlike: Boolean = false, + val allowCommercial: Boolean = false, + val cc0Enabled: Boolean = false, + val licenseUrl: String? = null, + + // Original license values (for tracking changes) + val originalLicenseUrl: String? = null +) { + + val hasUnsavedChanges: Boolean + get() = isNameChanged || licenseUrl != originalLicenseUrl +} + +sealed interface WebDavDetailAction { + + // Form updates + data class UpdateName(val name: String) : WebDavDetailAction + + // Authentication + data object Cancel : WebDavDetailAction + + // Edit mode actions + data object SaveChanges : WebDavDetailAction + data object RemoveSpace : WebDavDetailAction + data object ConfirmRemoveSpace : WebDavDetailAction + data object NavigateBack : WebDavDetailAction + + // Creative Commons License actions + data class UpdateCcEnabled(val enabled: Boolean) : WebDavDetailAction + data class UpdateAllowRemix(val allowed: Boolean) : WebDavDetailAction + data class UpdateRequireShareAlike(val required: Boolean) : WebDavDetailAction + data class UpdateAllowCommercial(val allowed: Boolean) : WebDavDetailAction + data class UpdateCc0Enabled(val enabled: Boolean) : WebDavDetailAction +} + +sealed interface WebDavDetailEvent { + +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/detail/WebDavDetailViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/detail/WebDavDetailViewModel.kt new file mode 100644 index 000000000..10f8d2348 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/detail/WebDavDetailViewModel.kt @@ -0,0 +1,310 @@ +package net.opendasharchive.openarchive.services.webdav.presentation.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.features.core.UiImage +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.ButtonData +import net.opendasharchive.openarchive.features.core.dialog.DialogConfig +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.features.core.dialog.showWarningDialog +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager + +class WebDavDetailViewModel( + private val route: AppRoute.WebDavDetailRoute, + private val navigator: Navigator, + private val spaceRepository: SpaceRepository, + private val dialogManager: DialogStateManager, +) : ViewModel() { + + private lateinit var vault: Vault + + private val _uiState = MutableStateFlow( + WebDavDetailState( + spaceId = route.spaceId, + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + init { + loadSpaceData() + } + + private fun loadSpaceData() = viewModelScope.launch { + vault = spaceRepository.getSpaceById(route.spaceId) ?: run { + navigator.navigateBack() + return@launch + } + + _uiState.update { currentState -> + val newState = currentState.copy( + serverUrl = vault.host, + username = vault.username, + password = "", + name = vault.name, + originalName = vault.name, + originalLicenseUrl = vault.licenseUrl + ) + + initializeLicenseState(newState, vault.licenseUrl) + } + } + + fun onAction(action: WebDavDetailAction) { + + when (action) { + + is WebDavDetailAction.UpdateName -> { + val isChanged = action.name.trim() != _uiState.value.originalName + _uiState.update { it.copy(name = action.name, isNameChanged = isChanged) } + } + + is WebDavDetailAction.Cancel -> { + viewModelScope.launch { + if (_uiState.value.hasUnsavedChanges) { + showUnsavedChangeWarning() + } else { + navigator.navigateBack() + } + } + } + + is WebDavDetailAction.SaveChanges -> { + saveChanges() + } + + is WebDavDetailAction.RemoveSpace -> removeConfirmDialog() + + is WebDavDetailAction.ConfirmRemoveSpace -> removeSpace() + + is WebDavDetailAction.NavigateBack -> { + viewModelScope.launch { + navigator.navigateBack() + } + } + + // Creative Commons License actions + is WebDavDetailAction.UpdateCcEnabled -> { + _uiState.update { currentState -> + if (action.enabled) { + currentState.copy( + ccEnabled = true, + cc0Enabled = false, + allowRemix = false, + requireShareAlike = false, + allowCommercial = false, + licenseUrl = null + ) + } else { + currentState.copy( + ccEnabled = false, + allowRemix = false, + requireShareAlike = false, + allowCommercial = false, + cc0Enabled = false, + licenseUrl = null + ) + } + } + generateAndUpdateLicense() + } + + is WebDavDetailAction.UpdateAllowRemix -> { + _uiState.update { currentState -> + currentState.copy( + allowRemix = action.allowed, + cc0Enabled = if (action.allowed) false else currentState.cc0Enabled, + requireShareAlike = if (!action.allowed) false else currentState.requireShareAlike + ) + } + generateAndUpdateLicense() + } + + is WebDavDetailAction.UpdateRequireShareAlike -> { + _uiState.update { currentState -> + currentState.copy( + requireShareAlike = action.required, + cc0Enabled = if (action.required) false else currentState.cc0Enabled + ) + } + generateAndUpdateLicense() + } + + is WebDavDetailAction.UpdateAllowCommercial -> { + _uiState.update { currentState -> + currentState.copy( + allowCommercial = action.allowed, + cc0Enabled = if (action.allowed) false else currentState.cc0Enabled + ) + } + generateAndUpdateLicense() + } + + is WebDavDetailAction.UpdateCc0Enabled -> { + _uiState.update { currentState -> + if (action.enabled) { + currentState.copy( + cc0Enabled = true, + allowRemix = false, + requireShareAlike = false, + allowCommercial = false + ) + } else { + currentState.copy(cc0Enabled = false) + } + } + generateAndUpdateLicense() + } + } + } + + private fun saveChanges() = viewModelScope.launch { + val currentState = _uiState.value + val enteredName = currentState.name.trim() + + // Update both name and license (license is already set in generateAndUpdateLicense) + val updatedVault = vault.copy(name = enteredName) + spaceRepository.updateSpace(route.spaceId, updatedVault) + + _uiState.update { + it.copy( + originalName = enteredName, + isNameChanged = false, + originalLicenseUrl = currentState.licenseUrl + ) + } + + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Success + title = UiText.Resource(R.string.label_success_title) + message = UiText.Resource(R.string.msg_edit_server_success) + icon = UiImage.DrawableResource(R.drawable.ic_done) + positiveButton { + text = UiText.Resource(R.string.lbl_got_it) + action = { + navigator.navigateBack() + } + } + } + } + + private fun removeSpace() { + viewModelScope.launch { + val isSuccess = spaceRepository.deleteSpace(route.spaceId) + if (isSuccess) { + navigator.navigateBack() + } + } + } + + private fun initializeLicenseState( + currentState: WebDavDetailState, + currentLicense: String? + ): WebDavDetailState { + val isCc0 = currentLicense?.contains("publicdomain/zero", true) ?: false + val isCC = currentLicense?.contains("creativecommons.org/licenses", true) ?: false + + return if (isCc0) { + currentState.copy( + ccEnabled = true, + cc0Enabled = true, + allowRemix = false, + allowCommercial = false, + requireShareAlike = false, + licenseUrl = currentLicense + ) + } else if (isCC) { + currentState.copy( + ccEnabled = true, + cc0Enabled = false, + allowRemix = !(currentLicense.contains("-nd", true)), + allowCommercial = !(currentLicense.contains("-nc", true)), + requireShareAlike = !(currentLicense.contains( + "-nd", + true + )) && currentLicense.contains("-sa", true), + licenseUrl = currentLicense + ) + } else { + currentState.copy( + ccEnabled = false, + cc0Enabled = false, + allowRemix = false, + allowCommercial = false, + requireShareAlike = false, + licenseUrl = null + ) + } + } + + private fun generateAndUpdateLicense() { + val currentState = _uiState.value + val newLicense = CreativeCommonsLicenseManager.generateLicenseUrl( + ccEnabled = currentState.ccEnabled, + allowRemix = currentState.allowRemix, + requireShareAlike = currentState.requireShareAlike, + allowCommercial = currentState.allowCommercial, + cc0Enabled = currentState.cc0Enabled + ) + + _uiState.update { it.copy(licenseUrl = newLicense) } + + // Don't save immediately - let saveChanges() handle persistence + // This prevents duplicate space records when both name and license are changed + + vault = vault.copy(licenseUrl = newLicense) + + viewModelScope.launch { + spaceRepository.updateSpace(route.spaceId, vault) + } + } + + private fun showUnsavedChangeWarning() { + dialogManager.showWarningDialog( + title = UiText.Resource(R.string.unsaved_changes), + message = UiText.Resource(R.string.do_you_want_to_save), + onDone = { + saveChanges() + }, + onCancel = { + navigator.navigateBack() + } + ) + } + + private fun removeConfirmDialog() { + val config = DialogConfig( + type = DialogType.Warning, + title = UiText.Resource(R.string.remove_from_app), + message = UiText.Resource(R.string.are_you_sure_you_want_to_remove_this_server_from_the_app), + icon = UiImage.DrawableResource(R.drawable.ic_trash), + destructiveButton = ButtonData( + text = UiText.Resource(R.string.lbl_remove), + action = { removeSpace() } + ), + neutralButton = ButtonData( + text = UiText.Resource(R.string.lbl_Cancel), + action = {} + ) + ) + + dialogManager.showDialog(config) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/login/WebDavLoginScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/login/WebDavLoginScreen.kt new file mode 100644 index 000000000..f8469ac07 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/login/WebDavLoginScreen.kt @@ -0,0 +1,393 @@ +package net.opendasharchive.openarchive.services.webdav.presentation.login + +import android.content.res.Configuration +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLight +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomSecureField +import net.opendasharchive.openarchive.services.internetarchive.presentation.login.CustomTextField +import net.opendasharchive.openarchive.util.NetworkUtils + +@Composable +fun WebDavLoginScreen( + viewModel: WebDavLoginViewModel, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + + } + } + + WebDavContent( + state = state, + onAction = viewModel::onAction, + ) +} + +@Composable +private fun WebDavContent( + state: WebDavLoginState, + onAction: (WebDavLoginAction) -> Unit, +) { + val context = LocalContext.current + val scrollState = rememberScrollState() + val focusManager = LocalFocusManager.current + val usernameFocusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 100.dp) + ) { + + // Header section + WebDavHeader( + modifier = Modifier + .padding(top = 48.dp, bottom = 24.dp) + .padding(end = 24.dp) + ) + + + // Server Info Section + Text( + text = stringResource(R.string.server_info), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Server URL field + CustomTextField( + value = state.serverUrl, + onValueChange = { + onAction(WebDavLoginAction.ClearError) + onAction(WebDavLoginAction.UpdateServerUrl(it)) + }, + placeholder = stringResource(R.string.enter_url), + isError = state.serverError != null, + isLoading = state.isLoading, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + onImeAction = { + usernameFocusRequester.requestFocus() + }, + onFocusChange = { isFocused -> + if (!isFocused) { + onAction(WebDavLoginAction.FixServerUrl) + } + } + ) + + Spacer(modifier = Modifier.height(ThemeDimensions.spacing.medium)) + + // Account Section + Text( + text = stringResource(R.string.account), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp, top = 16.dp) + ) + + // Username field + CustomTextField( + value = state.username, + onValueChange = { + onAction(WebDavLoginAction.ClearError) + onAction(WebDavLoginAction.UpdateUsername(it)) + }, + placeholder = stringResource(R.string.prompt_username), + isError = state.usernameError != null || state.isCredentialsError, + isLoading = state.isLoading, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + onImeAction = { + passwordFocusRequester.requestFocus() + }, + modifier = Modifier.focusRequester(usernameFocusRequester) + ) + + Spacer(modifier = Modifier.height(ThemeDimensions.spacing.medium)) + + // Password field + CustomSecureField( + value = state.password, + onValueChange = { + onAction(WebDavLoginAction.ClearError) + onAction(WebDavLoginAction.UpdatePassword(it)) + }, + placeholder = stringResource(R.string.prompt_password), + isError = state.passwordError != null || state.isCredentialsError, + isLoading = state.isLoading, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + onImeAction = { + focusManager.clearFocus() + }, + modifier = Modifier.focusRequester(passwordFocusRequester) + ) + + // Error hint + AnimatedVisibility( + visible = state.isCredentialsError, + enter = fadeIn(), + exit = fadeOut() + ) { + Text( + text = stringResource(R.string.error_incorrect_username_or_password), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + + // Button bar + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Back button + TextButton( + modifier = Modifier + .heightIn(ThemeDimensions.touchable) + .weight(1f), + colors = ButtonDefaults.textButtonColors( + contentColor = colorResource(R.color.colorOnBackground) + ), + enabled = !state.isLoading, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { onAction(WebDavLoginAction.Cancel) } + ) { + Text( + stringResource(R.string.back), + style = MaterialTheme.typography.titleLarge + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Next/Authenticate button + Button( + modifier = Modifier + .heightIn(ThemeDimensions.touchable) + .weight(1f), + enabled = !state.isLoading && state.isFormValid, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + disabledContainerColor = colorResource(R.color.grey_50), + disabledContentColor = colorResource(R.color.black), + contentColor = colorResource(R.color.black) + ), + onClick = { + if (NetworkUtils.isNetworkAvailable(context)) { + onAction(WebDavLoginAction.Authenticate) + } else { + Toast.makeText(context, R.string.error_no_internet, Toast.LENGTH_LONG) + .show() + } + } + ) { + if (state.isLoading) { + CircularProgressIndicator( + color = ThemeColors.material.primary, + modifier = Modifier.size(24.dp) + ) + } else { + Text( + stringResource(R.string.action_next), + style = MaterialTheme.typography.titleLarge + ) + } + } + } + + + // Loading overlay + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(colorResource(R.color.transparent_loading_overlay)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } +} + +@Composable +private fun WebDavHeader(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(colorResource(R.color.colorBackgroundSpaceIcon)) + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.size(32.dp), + painter = painterResource(id = R.drawable.ic_private_server), + contentDescription = stringResource(R.string.private_server), + colorFilter = ColorFilter.tint(colorResource(R.color.colorTertiary)) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(R.string.save_connects_to_webdav_compatible_servers_only_such_as_nextcloud_and_owncloud), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 32.dp) + ) + } +} + +// Previews +@PreviewLightDark +@Composable +private fun WebDavNewServerPreview() { + DefaultScaffoldPreview { + WebDavContent( + state = WebDavLoginState( + serverUrl = "", + username = "", + password = "" + ), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun WebDavNewServerFilledPreview() { + DefaultScaffoldPreview { + WebDavContent( + state = WebDavLoginState( + serverUrl = "https://cloud.example.com", + username = "user@example.com", + password = "password123" + ), + onAction = {} + ) + } +} + +@PreviewLight +@Composable +private fun WebDavNewServerErrorPreview() { + DefaultScaffoldPreview { + WebDavContent( + state = WebDavLoginState( + serverUrl = "https://cloud.example.com", + username = "user@example.com", + password = "wrongpassword", + isCredentialsError = true, + usernameError = UiText.Dynamic(" "), + passwordError = UiText.Dynamic(" ") + ), + onAction = {} + ) + } +} + + +@PreviewLight +@Composable +private fun WebDavLoadingPreview() { + DefaultScaffoldPreview { + WebDavContent( + state = WebDavLoginState( + serverUrl = "https://cloud.example.com", + username = "user@example.com", + password = "password123", + isLoading = true + ), + onAction = {} + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/login/WebDavLoginState.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/login/WebDavLoginState.kt new file mode 100644 index 000000000..f26e6131d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/login/WebDavLoginState.kt @@ -0,0 +1,57 @@ +package net.opendasharchive.openarchive.services.webdav.presentation.login + +import androidx.compose.runtime.Immutable +import net.opendasharchive.openarchive.features.core.UiText + +@Immutable +data class WebDavLoginState( + // Form fields + val serverUrl: String = "", + val username: String = "", + val password: String = "", +// val serverUrl: String = "https://nx27277.your-storageshare.de/", +// val username: String = "Prathieshna", +// val password: String = "J7wc(ka_4#9!13h&", + val name: String = "", + + // Field errors + val serverError: UiText? = null, + val usernameError: UiText? = null, + val passwordError: UiText? = null, + + // UI state + val isLoading: Boolean = false, + val isCredentialsError: Boolean = false, + val errorMessage: UiText? = null, + val isNameChanged: Boolean = false, + val originalName: String = "", + val isPasswordVisible: Boolean = false, + + // Original license values (for tracking changes) + val originalLicenseUrl: String? = null +) { + val isFormValid: Boolean + get() = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank() + +} + +sealed interface WebDavLoginAction { + // Form updates + data class UpdateServerUrl(val url: String) : WebDavLoginAction + data class UpdateUsername(val username: String) : WebDavLoginAction + data class UpdatePassword(val password: String) : WebDavLoginAction + data class UpdateName(val name: String) : WebDavLoginAction + data object FixServerUrl : WebDavLoginAction + + // UI actions + data object TogglePasswordVisibility : WebDavLoginAction + data object ClearError : WebDavLoginAction + + // Authentication + data object Authenticate : WebDavLoginAction + data object Cancel : WebDavLoginAction +} + +sealed interface WebDavLoginEvent { + +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/login/WebDavLoginViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/login/WebDavLoginViewModel.kt new file mode 100644 index 000000000..16cb97cf2 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/presentation/login/WebDavLoginViewModel.kt @@ -0,0 +1,256 @@ +package net.opendasharchive.openarchive.services.webdav.presentation.login + +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.core.domain.Vault +import net.opendasharchive.openarchive.core.domain.VaultType +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.showErrorDialog +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.main.ui.AppRoute +import net.opendasharchive.openarchive.features.main.ui.Navigator +import net.opendasharchive.openarchive.core.domain.Credentials +import net.opendasharchive.openarchive.services.webdav.data.WebDavAuthenticator +import net.opendasharchive.openarchive.services.webdav.data.WebDavRepository +import java.io.IOException + +class WebDavLoginViewModel( + private val navigator: Navigator, + private val authenticator: WebDavAuthenticator, + private val spaceRepository: SpaceRepository, + private val dialogManager: DialogStateManager, + private val analyticsManager: AnalyticsManager +) : ViewModel() { + + companion object Companion { + const val ARG_VAL_NEW_SPACE = -1L + private const val REMOTE_PHP_ADDRESS = "/remote.php/webdav/" + } + + private var spaceId: Long = 0L + + private val _uiState = MutableStateFlow(WebDavLoginState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun onAction(action: WebDavLoginAction) { + when (action) { + is WebDavLoginAction.UpdateServerUrl -> { + _uiState.update { + it.copy( + serverUrl = action.url, + serverError = null, + isCredentialsError = false + ) + } + } + + is WebDavLoginAction.FixServerUrl -> { + val currentUrl = _uiState.value.serverUrl + if (currentUrl.isNotBlank()) { + val fixedUrl = authenticator.fixSpaceUrl(currentUrl) + if (fixedUrl != null && fixedUrl.toString() != currentUrl) { + _uiState.update { + it.copy( + serverUrl = fixedUrl.toString(), + serverError = null + ) + } + } + } + } + + is WebDavLoginAction.UpdateUsername -> { + _uiState.update { + it.copy( + username = action.username, + usernameError = null, + isCredentialsError = false + ) + } + } + + is WebDavLoginAction.UpdatePassword -> { + _uiState.update { + it.copy( + password = action.password, + passwordError = null, + isCredentialsError = false + ) + } + } + + is WebDavLoginAction.UpdateName -> { + val isChanged = action.name.trim() != _uiState.value.originalName + _uiState.update { it.copy(name = action.name, isNameChanged = isChanged) } + } + + is WebDavLoginAction.TogglePasswordVisibility -> { + _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) } + } + + is WebDavLoginAction.ClearError -> { + _uiState.update { + it.copy( + isCredentialsError = false, + serverError = null, + usernameError = null, + passwordError = null, + errorMessage = null + ) + } + } + + is WebDavLoginAction.Authenticate -> { + performAuthentication() + } + + is WebDavLoginAction.Cancel -> { + viewModelScope.launch { + navigator.navigateBack() + } + } + } + } + + private fun performAuthentication() { + val currentState = _uiState.value + + // Validate fields + var hasError = false + var updatedState = currentState + + if (currentState.serverUrl.isBlank()) { + updatedState = updatedState.copy(serverError = UiText.Resource(R.string.error_field_required)) + hasError = true + } + + if (currentState.username.isBlank()) { + updatedState = updatedState.copy(usernameError = UiText.Resource(R.string.error_field_required)) + hasError = true + } + + if (currentState.password.isBlank()) { + updatedState = updatedState.copy(passwordError = UiText.Resource(R.string.error_field_required)) + hasError = true + } + + if (hasError) { + _uiState.update { updatedState } + return + } + + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + val credentials = Credentials.WebDav( + url = currentState.serverUrl, + user = currentState.username, + pass = currentState.password + ) + + authenticator.authenticate(credentials) + .onSuccess { tempVault -> + val fixedUrl = tempVault.host + + // Check for duplicate credentials + val spaces = spaceRepository.getSpaces() + val existing = spaces.filter { + it.type == VaultType.PRIVATE_SERVER && it.host == fixedUrl && it.username == currentState.username + } + if (existing.isNotEmpty() && existing[0].id != spaceId) { + _uiState.update { it.copy(isLoading = false) } + showError(UiText.Resource(R.string.you_already_have_a_server_with_these_credentials)) + return@launch + } + + // Check if this is a new backend or existing one + val isNewBackend = spaceId == 0L + + val vaultToSave = if (isNewBackend) { + tempVault + } else { + spaceRepository.getSpaceById(spaceId)?.copy( + host = tempVault.host, + username = tempVault.username, + password = tempVault.password + ) ?: tempVault + } + + val savedId = if (isNewBackend) { + spaceRepository.addSpace(vaultToSave) + } else { + spaceRepository.updateSpace(spaceId, vaultToSave) + spaceId + } + + spaceRepository.setCurrentSpace(savedId) + + // Track backend configuration + analyticsManager.trackBackendConfigured( + backendType = VaultType.PRIVATE_SERVER.name, + isNew = isNewBackend + ) + + _uiState.update { it.copy(isLoading = false, serverUrl = fixedUrl) } + + // Navigate to Setup License + navigator.navigateTo( + AppRoute.SetupLicenseRoute( + spaceId = savedId, + spaceType = VaultType.PRIVATE_SERVER + ) + ) + } + .onFailure { e -> + _uiState.update { it.copy(isLoading = false) } + when { + e.message?.contains("401") == true -> { + _uiState.update { + it.copy( + isCredentialsError = true, + usernameError = UiText.Dynamic(" "), + passwordError = UiText.Dynamic(" ") + ) + } + } + + // Invalid server URL errors + e.message?.contains("Unable to resolve host", ignoreCase = true) == true || + e.message?.contains("404") == true || + e.message?.contains("400") == true || + e.message?.contains("403") == true -> { + _uiState.update { it.copy(serverError = UiText.Dynamic(" ")) } + showError(UiText.Resource(R.string.web_dav_host_error)) + } + + else -> { + _uiState.update { it.copy(serverError = UiText.Dynamic(" ")) } + showError(UiText.Dynamic(e.localizedMessage ?: "An error occurred")) + } + } + } + } + } + + + private fun showError(error: UiText) { + dialogManager.showErrorDialog( + message = error + ) + } + +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/SKBottomSheetDialogFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/SKBottomSheetDialogFragment.kt deleted file mode 100644 index a1c26a062..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/SKBottomSheetDialogFragment.kt +++ /dev/null @@ -1,69 +0,0 @@ -package net.opendasharchive.openarchive.upload - -import android.app.Dialog -import android.content.res.Resources -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.fragment.app.activityViewModels -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager - -open class SKBottomSheetDialogFragment : BottomSheetDialogFragment() { - - protected val dialogManager: DialogStateManager by activityViewModels() - - override fun onStart() { - super.onStart() - val sheetContainer = requireView().parent as? ViewGroup ?: return - sheetContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - dialog.setOnShowListener { dialogInterface -> - (dialogInterface as? BottomSheetDialog)?.let { bottomSheetDialog -> - (bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) - as? FrameLayout)?.let { frameLayout -> - - val behavior = BottomSheetBehavior.from(frameLayout) - - // Set behavior attributes to allow collapsing and dismissing - behavior.peekHeight = Resources.getSystem().displayMetrics.heightPixels -// behavior.peekHeight = 0 // Start from full-screen - behavior.state = BottomSheetBehavior.STATE_EXPANDED // Initially expanded - behavior.isDraggable = false // Allow dragging - behavior.skipCollapsed = false // Enable collapse - behavior.isHideable = false // Allow dismissing - - // Dismiss the dialog when hidden - behavior.addBottomSheetCallback(object : - BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_HIDDEN) { - dismiss() - } - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) { - // Handle sliding behavior (optional) - } - }) - - // Handle edge-to-edge behavior - ViewCompat.setOnApplyWindowInsetsListener(frameLayout) { view, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.setPadding(0, systemBars.top, 0, systemBars.bottom) - insets - } - } - } - } - return dialog - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/SwipeToDeleteCallback.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/SwipeToDeleteCallback.kt deleted file mode 100644 index c3cf500cb..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/SwipeToDeleteCallback.kt +++ /dev/null @@ -1,73 +0,0 @@ -package net.opendasharchive.openarchive.upload - -import android.content.Context -import android.graphics.Canvas -import android.graphics.PorterDuff -import android.graphics.drawable.ColorDrawable -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.R -import kotlin.math.max -import kotlin.math.roundToInt - -abstract class SwipeToDeleteCallback(context: Context?): ItemTouchHelper.Callback() { - - private val mBackground = if (context != null) - ColorDrawable(ContextCompat.getColor(context, R.color.colorDanger)) else null - - private val mIcon = if (context != null) - ContextCompat.getDrawable(context, R.drawable.ic_delete) else null - - private val mIconColor = if (context != null) - ContextCompat.getColor(context, R.color.colorOnBackground) else 0 - - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ): Int { - - return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) - - } - - override fun onChildDraw( - c: Canvas, - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - dX: Float, - dY: Float, - actionState: Int, - isCurrentlyActive: Boolean - ) { - val iv = viewHolder.itemView - - val cancelled = dX == 0f && !isCurrentlyActive - if (cancelled) { - return super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, false) - } - - mBackground?.setBounds(iv.right + dX.toInt(), iv.top, iv.right, iv.bottom) - mBackground?.draw(c) - - val height = ((mIcon?.intrinsicHeight ?: 0) * 0.75).roundToInt() - val width = ((mIcon?.intrinsicWidth ?: 0) * 0.75).roundToInt() - val margin = (iv.height - height) / 2 - val left = max(iv.right + dX.toInt(), iv.right - margin - width) - val top = iv.top + (iv.height - height) / 2 - val right = iv.right - margin - val bottom = top + height - - @Suppress("DEPRECATION") - mIcon?.setColorFilter(mIconColor, PorterDuff.Mode.SRC_IN) - - mIcon?.setBounds(left, top, right, bottom) - mIcon?.draw(c) - - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) - } - - override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { - return 0.7f - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadEventBus.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadEventBus.kt new file mode 100644 index 000000000..c12033ee4 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadEventBus.kt @@ -0,0 +1,59 @@ +package net.opendasharchive.openarchive.upload + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +sealed class UploadEvent { + data class Changed( + val projectId: Long, + val collectionId: Long, + val mediaId: Long, + val progress: Int = -1, + val isUploaded: Boolean = false + ) : UploadEvent() + + data class Deleted( + val projectId: Long, + val collectionId: Long, + val mediaId: Long + ) : UploadEvent() +} + +object UploadEventBus { + private val _events = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 64 + ) + val events: SharedFlow = _events.asSharedFlow() + + fun tryEmit(event: UploadEvent): Boolean = _events.tryEmit(event) + + fun emitChanged( + projectId: Long, + collectionId: Long, + mediaId: Long, + progress: Int = -1, + isUploaded: Boolean = false + ): Boolean = tryEmit( + UploadEvent.Changed( + projectId = projectId, + collectionId = collectionId, + mediaId = mediaId, + progress = progress, + isUploaded = isUploaded + ) + ) + + fun emitDeleted( + projectId: Long, + collectionId: Long, + mediaId: Long + ): Boolean = tryEmit( + UploadEvent.Deleted( + projectId = projectId, + collectionId = collectionId, + mediaId = mediaId + ) + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadJobScheduler.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadJobScheduler.kt new file mode 100644 index 000000000..51abbfd6d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadJobScheduler.kt @@ -0,0 +1,41 @@ +package net.opendasharchive.openarchive.upload + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context +import android.os.Build +import androidx.core.content.ContextCompat + +interface UploadJobScheduler { + fun schedule() + fun cancel() +} + +object UploadJobConfig { + const val JOB_ID = 7918 +} + +class JobSchedulerUploadJobScheduler( + private val appContext: Context +) : UploadJobScheduler { + + override fun schedule() { + val jobScheduler = + ContextCompat.getSystemService(appContext, JobScheduler::class.java) ?: return + var jobBuilder = JobInfo.Builder( + UploadJobConfig.JOB_ID, + ComponentName(appContext, UploadService::class.java) + ).setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + jobBuilder = jobBuilder.setUserInitiated(true) + } + jobScheduler.schedule(jobBuilder.build()) + } + + override fun cancel() { + val jobScheduler = + ContextCompat.getSystemService(appContext, JobScheduler::class.java) ?: return + jobScheduler.cancel(UploadJobConfig.JOB_ID) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerFragment.kt deleted file mode 100644 index 6771391d0..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerFragment.kt +++ /dev/null @@ -1,167 +0,0 @@ -package net.opendasharchive.openarchive.upload - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentUploadManagerBinding -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.db.UploadMediaAdapter -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.features.main.MainActivity - -open class UploadManagerFragment : SKBottomSheetDialogFragment() { - - companion object { - const val TAG = "ModalBottomSheet-UploadManagerFragment" - private val STATUSES = - listOf(Media.Status.Uploading, Media.Status.Queued, Media.Status.Error) - } - - private lateinit var uploadMediaAdapter: UploadMediaAdapter - - private lateinit var binding: FragmentUploadManagerBinding - - private lateinit var mItemTouchHelper: ItemTouchHelper - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - binding = FragmentUploadManagerBinding.inflate(inflater, container, false) - - binding.uploadList.layoutManager = LinearLayoutManager(activity) - - val decorator = - DividerItemDecoration(binding.uploadList.context, DividerItemDecoration.VERTICAL) - val divider = ContextCompat.getDrawable(binding.uploadList.context, R.drawable.divider) - if (divider != null) decorator.setDrawable(divider) - - binding.uploadList.addItemDecoration(decorator) - binding.uploadList.setHasFixedSize(true) - - uploadMediaAdapter = UploadMediaAdapter( - activity = activity, - mediaItems = Media.getByStatus(STATUSES, Media.ORDER_PRIORITY), - recyclerView = binding.uploadList, - onDeleteClick = { mediaItem, position -> - showDeleteConfirmationDialog( - mediaItem = mediaItem, - onDeleteItem = { - uploadMediaAdapter.deleteItem(position) - } - ) - } - ) - - uploadMediaAdapter.doImageFade = false - binding.uploadList.adapter = uploadMediaAdapter - - mItemTouchHelper = ItemTouchHelper(object : SwipeToDeleteCallback(context) { - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - uploadMediaAdapter.onItemMove( - viewHolder.bindingAdapterPosition, - target.bindingAdapterPosition - ) - - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - // Do nothing - } - }) - - mItemTouchHelper.attachToRecyclerView(binding.uploadList) - - binding.root.findViewById(R.id.done_button)?.setOnClickListener { - dismiss() // Close the bottom sheet when clicked - } - - return binding.root - } - - override fun onResume() { - super.onResume() - refresh() - } - - override fun onDestroy() { - super.onDestroy() - - // Notify MainActivity that this fragment is dismissed - (activity as? MainActivity)?.uploadManagerFragment = null - } - - open fun updateItem(mediaId: Long) { - uploadMediaAdapter.updateItem(mediaId, -1) - } - - open fun removeItem(mediaId: Long) { - uploadMediaAdapter.removeItem(mediaId) - } - - open fun refresh() { - uploadMediaAdapter.updateData(Media.getByStatus(STATUSES, Media.ORDER_PRIORITY)) - } - - open fun getUploadingCounter(): Int { - return uploadMediaAdapter.media?.size ?: 0 - } - - private fun showDeleteConfirmationDialog(mediaItem: Media, onDeleteItem: () -> Unit) { - - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Error - title = UiText.StringResource(R.string.upload_unsuccessful) - message = UiText.StringResource(R.string.upload_unsuccessful_description) - positiveButton { - text = UiText.StringResource(R.string.lbl_retry) - action = { - mediaItem.apply { - sStatus = Media.Status.Queued - uploadPercentage = 0 - statusMessage = "" - save() - BroadcastManager.postChange( - requireActivity(), - mediaItem.collectionId, - mediaItem.id - ) - } - //TODO: refresh UploadMediaAdapter here for retry item - uploadMediaAdapter.updateItem(mediaItem.id, progress = -1, isUploaded = false) - //UploadService.startUploadService(requireActivity()) - - // Notify parent that retry was selected - val resultBundle = Bundle().apply { - putLong("mediaId", mediaItem.id) - putInt("progress", 0) - } - parentFragmentManager.setFragmentResult("uploadRetry", resultBundle) - } - } - - destructiveButton { - text = UiText.StringResource(R.string.btn_lbl_remove_media) - action = { - onDeleteItem.invoke() - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerScreen.kt new file mode 100644 index 000000000..3cba48bf0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerScreen.kt @@ -0,0 +1,389 @@ +package net.opendasharchive.openarchive.upload + +import android.content.res.Configuration +import android.text.format.Formatter +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import net.opendasharchive.openarchive.core.presentation.theme.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.collectLatest +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.core.presentation.media.MediaStatusOverlay +import net.opendasharchive.openarchive.core.presentation.media.MediaThumbnail +import net.opendasharchive.openarchive.core.presentation.theme.MontserratFontFamily +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@Composable +fun UploadManagerScreen( + viewModel: UploadManagerViewModel, + onClose: () -> Unit +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.events.collectLatest { event -> + when (event) { + is UploadManagerEvent.Close -> onClose() + is UploadManagerEvent.ShowRetryDialog -> { + // Logic moved to ViewModel action but we keep the event for compatibility/other uses if needed + // In this case, we prefer calling the action directly from the Screen to the ViewModel + } + } + } + } + + // Auto-dismiss when all items are deleted + // UPDATED: Only auto-dismiss if we had items and now they are gone. + // We'll use a local state to track if we've ever seen items. + var hadItems by remember { mutableStateOf(false) } + LaunchedEffect(state.mediaList) { + if (state.mediaList.isNotEmpty()) { + hadItems = true + } else if (hadItems) { + onClose() + } + } + + UploadManagerContent( + state = state, + onAction = viewModel::onAction + ) +} + +@Composable +private fun UploadManagerContent( + state: UploadManagerState, + onAction: (UploadManagerAction) -> Unit +) { + val hapticFeedback = LocalHapticFeedback.current + val lazyListState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> + onAction(UploadManagerAction.MoveItem(from.index, to.index)) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background( + color = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) + .windowInsetsPadding(WindowInsets.navigationBars) + ) { + // REMOVED custom drag handle - we'll use the one from ModalBottomSheet + + // Top Bar + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.edit_queue), + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.uploading_is_paused), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Medium + ), + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + + TextButton( + onClick = { onAction(UploadManagerAction.Close) }, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Text( + text = stringResource(R.string.done).uppercase(), + style = MaterialTheme.typography.labelLarge.copy( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onBackground + ) + } + } + + // Reorderable List + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + itemsIndexed( + items = state.mediaList, + key = { _, evidence -> evidence.id } + ) { index, evidence -> + ReorderableItem(reorderableState, key = evidence.id) { isDragging -> + Column { + UploadEvidenceItem( + evidence = evidence, + isDragging = isDragging, + onDelete = { + if (evidence.status == EvidenceStatus.ERROR) { + onAction(UploadManagerAction.RequestRetry(evidence, index)) + } else { + onAction(UploadManagerAction.DeleteItem(index)) + } + }, + dragHandleModifier = Modifier + .draggableHandle() + .longPressDraggableHandle() + ) + + // Divider between items + if (index < state.mediaList.size - 1) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(0.5.dp) + .background(colorResource(R.color.light_grey)) + ) + } + } + } + } + } + } +} + +@Composable +private fun UploadEvidenceItem( + evidence: Evidence, + isDragging: Boolean, + onDelete: () -> Unit, + modifier: Modifier = Modifier, + dragHandleModifier: Modifier = Modifier +) { + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + val alpha = if (!isDragging) 1f else 0.7f + + Surface( + shadowElevation = elevation, + color = MaterialTheme.colorScheme.background + ) { + Row( + modifier = modifier + .fillMaxWidth() + .height(70.dp) + .padding(5.dp) + .background( + color = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(4.dp) + ), + verticalAlignment = Alignment.CenterVertically + ) { + // Delete Button + Box( + modifier = Modifier + .size(50.dp), + contentAlignment = Alignment.Center + ) { + IconButton( + onClick = onDelete, + modifier = Modifier.size(24.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_trash), + contentDescription = stringResource(R.string.menu_delete), + tint = colorResource(R.color.colorDanger), + modifier = Modifier.size(24.dp) + ) + } + } + + // Thumbnail Container - 80dp square with 8dp padding = 64dp actual image + Box( + modifier = Modifier + .size(80.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) { + MediaThumbnail( + evidence = evidence, + alpha = alpha, + placeholderPadding = 12.dp, + pdfMaxDimensionPx = 400, + showStatusOverlay = false + ) + + // Overlay for status - show only Error, not Queued/Uploading (queue is paused) + MediaStatusOverlay( + evidence = evidence, + showProgressText = false, + backgroundColor = colorResource(R.color.transparent_black), + progressIndicatorSize = 32, + showQueuedState = false, + showUploadingState = false + ) + } + } + + // Title and File Info + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp, end = 8.dp), + verticalArrangement = Arrangement.Center + ) { + val titleText = buildString { + if (evidence.status == EvidenceStatus.ERROR) { + append(stringResource(R.string.error)) + append(": ") + } + append(evidence.title) + } + + if (titleText.isNotBlank()) { + Text( + text = titleText, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + + val fileInfoText = getFileInfoText(evidence) + if (fileInfoText.isNotBlank()) { + Text( + text = fileInfoText, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = MontserratFontFamily + ), + maxLines = 1, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + // Drag Handle + Icon( + painter = painterResource(id = R.drawable.ic_reorder_black_24dp), + contentDescription = stringResource(R.string.uploads), + tint = MaterialTheme.colorScheme.onSurface, + modifier = dragHandleModifier.padding(end = 8.dp) + ) + } + } +} + +// MediaThumbnail, PlaceholderIcon, PdfThumbnail, and MediaStatusOverlay +// have been moved to shared components in core.presentation.media package + +@Composable +private fun getFileInfoText(evidence: Evidence): String { + val context = LocalContext.current + + if (evidence.status == EvidenceStatus.ERROR && evidence.statusMessage.isNotBlank()) { + return evidence.statusMessage + } + + val file = evidence.file + return if (file.exists()) { + Formatter.formatShortFileSize(context, file.length()) + } else { + if (evidence.contentLength > 0) { + Formatter.formatShortFileSize(context, evidence.contentLength) + } else { + evidence.title // Fallback + } + } +} + +@PreviewLightDark +@Composable +private fun UploadManagerContentPreview() { + val sampleMedia = listOf( + Evidence( + originalFilePath = "", + mimeType = "image/jpeg", + title = "Image 1.jpg", + status = EvidenceStatus.UPLOADING + ).apply { + // uploadPercentage is 0 by default in Evidence, let's keep it simple + }, + Evidence(originalFilePath = "", mimeType = "video/mp4", title = "Video 1.mp4", status = EvidenceStatus.QUEUED), + Evidence( + originalFilePath = "", + mimeType = "application/pdf", + title = "Document 1.pdf", + status = EvidenceStatus.ERROR, + statusMessage = "Upload failed" + ) + ) + + SaveAppTheme { + UploadManagerContent( + state = UploadManagerState(mediaList = sampleMedia), + onAction = {}, + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerViewModel.kt new file mode 100644 index 000000000..17c2d62f2 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerViewModel.kt @@ -0,0 +1,255 @@ +package net.opendasharchive.openarchive.upload + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.core.repositories.InvalidationBus +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.UiImage +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus +import net.opendasharchive.openarchive.features.core.dialog.ButtonData + +data class UploadManagerState( + val mediaList: List = emptyList(), + val isLoading: Boolean = false +) + +sealed class UploadManagerAction { + data object Refresh : UploadManagerAction() + data class UpdateItem(val mediaId: Long, val progress: Int = -1, val isUploaded: Boolean = false) : UploadManagerAction() + data class RemoveItem(val mediaId: Long) : UploadManagerAction() + data class DeleteItem(val position: Int) : UploadManagerAction() + data class RequestRetry(val evidence: Evidence, val position: Int) : UploadManagerAction() + data class RetryItem(val evidence: Evidence) : UploadManagerAction() + data class MoveItem(val fromPosition: Int, val toPosition: Int) : UploadManagerAction() + data object Close : UploadManagerAction() +} + +sealed class UploadManagerEvent { + data object Close : UploadManagerEvent() + data class ShowRetryDialog(val evidence: Evidence, val position: Int) : UploadManagerEvent() +} + +class UploadManagerViewModel( + private val application: Application, + private val mediaRepository: MediaRepository, + private val dialogManager: DialogStateManager, + private val uploadJobScheduler: UploadJobScheduler +) : ViewModel() { + + private val _uiState = MutableStateFlow(UploadManagerState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + init { + observeQueue() + observeUploadEvents() + } + + /** + * Reactively re-queries getQueue() whenever any media is written to the DB. + * This is the primary mechanism for keeping the list correct: every + * updateEvidence() call (including upload completion) fires invalidateMedia(), + * so UPLOADED items disappear from this list automatically without needing + * event-based removeItem() calls. + */ + private fun observeQueue() { + InvalidationBus.media + .map { mediaRepository.getQueue() } + .distinctUntilChanged() + .onEach { queue -> _uiState.update { it.copy(mediaList = queue) } } + .launchIn(viewModelScope) + } + + private fun observeUploadEvents() { + UploadEventBus.events + .onEach { event -> + when (event) { + is UploadEvent.Changed -> { + if (!event.isUploaded) { + updateItem(event.mediaId, event.progress, event.isUploaded) + } + // isUploaded=true: observeQueue() handles removal reactively + } + + is UploadEvent.Deleted -> { + removeItem(event.mediaId) + } + } + } + .launchIn(viewModelScope) + } + + fun onAction(action: UploadManagerAction) { + when (action) { + is UploadManagerAction.Refresh -> refresh() + is UploadManagerAction.UpdateItem -> updateItem(action.mediaId, action.progress, action.isUploaded) + is UploadManagerAction.RemoveItem -> removeItem(action.mediaId) + is UploadManagerAction.DeleteItem -> deleteItem(action.position) + is UploadManagerAction.RequestRetry -> showRetryDialog(action.evidence, action.position) + is UploadManagerAction.RetryItem -> retryItem(action.evidence) + is UploadManagerAction.MoveItem -> moveItem(action.fromPosition, action.toPosition) + is UploadManagerAction.Close -> close() + } + } + + private fun refresh() { + viewModelScope.launch { + _uiState.update { currentState -> + currentState.copy( + mediaList = mediaRepository.getQueue() + ) + } + } + } + + private fun updateItem(mediaId: Long, progress: Int, isUploaded: Boolean) { + _uiState.update { currentState -> + val updatedList = currentState.mediaList.toMutableList() + val index = updatedList.indexOfFirst { it.id == mediaId } + + if (index >= 0) { + val item = updatedList[index] + val updatedItem = when { + isUploaded -> { + item.copy(status = EvidenceStatus.UPLOADED) + } + + progress >= 0 -> { + item.copy(uploadPercentage = progress, status = EvidenceStatus.UPLOADING) + } + + else -> { + item.copy(status = EvidenceStatus.QUEUED) + } + } + updatedList[index] = updatedItem + } + + currentState.copy(mediaList = updatedList) + } + } + + private fun removeItem(mediaId: Long) { + _uiState.update { currentState -> + currentState.copy( + mediaList = currentState.mediaList.filter { it.id != mediaId } + ) + } + } + + private fun deleteItem(position: Int) { + viewModelScope.launch { + val currentList = _uiState.value.mediaList + if (position < 0 || position >= currentList.size) return@launch + + val item = currentList[position] + + // Delete the media item (repository handles collection deletion if empty) + mediaRepository.deleteMedia(item.id) + + removeItem(item.id) + + // Broadcast the delete action to notify MainMediaFragment + BroadcastManager.postDelete(application, item.id) + UploadEventBus.emitDeleted( + projectId = item.archiveId, + collectionId = item.submissionId, + mediaId = item.id + ) + } + } + + private fun showRetryDialog(evidence: Evidence, position: Int) { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Error + title = UiText.Resource(R.string.upload_unsuccessful) + message = UiText.Resource(R.string.upload_unsuccessful_description) + icon = UiImage.DrawableResource(R.drawable.ic_error) + positiveButton { + text = UiText.Resource(R.string.lbl_retry) + action = { + retryItem(evidence) + // Resume service since we have a new item to upload + uploadJobScheduler.schedule() + } + } + + destructiveButton { + text = UiText.Resource(R.string.btn_lbl_remove_media) + action = { + deleteItem(position) + } + } + } + } + + private fun retryItem(evidence: Evidence) { + viewModelScope.launch { + mediaRepository.retryMedia(evidence.id) + + updateItem(evidence.id, progress = -1, isUploaded = false) + + // Broadcast the change to notify MainMediaFragment + BroadcastManager.postChange(application, evidence.submissionId, evidence.id) + UploadEventBus.emitChanged( + projectId = evidence.archiveId, + collectionId = evidence.submissionId, + mediaId = evidence.id, + progress = -1, + isUploaded = false + ) + } + } + + private fun moveItem(fromPosition: Int, toPosition: Int) { + viewModelScope.launch { + val updatedList = _uiState.value.mediaList.toMutableList() + + if (fromPosition < 0 || fromPosition >= updatedList.size || + toPosition < 0 || toPosition >= updatedList.size + ) { + return@launch + } + + val movedItem = updatedList.removeAt(fromPosition) + updatedList.add(toPosition, movedItem) + + _uiState.update { it.copy(mediaList = updatedList) } + + // Batch update priorities with single invalidation + var priority = updatedList.size + val priorities = updatedList.map { it.id to priority-- } + mediaRepository.updatePriorities(priorities) + } + } + + private fun close() { + viewModelScope.launch { + _events.send(UploadManagerEvent.Close) + } + } + + fun getUploadingCounter(): Int { + return _uiState.value.mediaList.size + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt index a6ee85b56..343d87ff4 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt @@ -12,19 +12,26 @@ import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat import androidx.work.Configuration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.CleanInsightsManager +import net.opendasharchive.openarchive.util.CleanInsightsManager import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.core.domain.Evidence +import net.opendasharchive.openarchive.core.domain.EvidenceStatus import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.features.main.MainActivity +import net.opendasharchive.openarchive.core.repositories.CollectionRepository +import net.opendasharchive.openarchive.core.repositories.FileCleanupHelper +import net.opendasharchive.openarchive.core.repositories.MediaRepository +import net.opendasharchive.openarchive.core.repositories.ProjectRepository +import net.opendasharchive.openarchive.core.repositories.SpaceRepository +import net.opendasharchive.openarchive.features.main.HomeActivity import net.opendasharchive.openarchive.services.Conduit +import net.opendasharchive.openarchive.util.DateUtils import net.opendasharchive.openarchive.util.Prefs import org.koin.android.ext.android.inject import java.io.IOException @@ -34,34 +41,21 @@ class UploadService : JobService() { // Inject analytics manager private val analyticsManager: AnalyticsManager by inject() + private val mediaRepository: MediaRepository by inject() + private val projectRepository: ProjectRepository by inject() + private val collectionRepository: CollectionRepository by inject() + private val spaceRepository: SpaceRepository by inject() + private val fileCleanupHelper: FileCleanupHelper by inject() companion object { - private const val MY_BACKGROUND_JOB = 0 private const val NOTIFICATION_CHANNEL_ID = "oasave_channel_1" - - fun startUploadService(activity: Activity) { - val jobScheduler = - ContextCompat.getSystemService(activity, JobScheduler::class.java) ?: return - var jobBuilder = JobInfo.Builder( - MY_BACKGROUND_JOB, - ComponentName(activity, UploadService::class.java) - ).setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - jobBuilder = jobBuilder.setUserInitiated(true) - } - jobScheduler.schedule(jobBuilder.build()) - } - - fun stopUploadService(context: Context) { - val jobScheduler = - ContextCompat.getSystemService(context, JobScheduler::class.java) ?: return - jobScheduler.cancel(MY_BACKGROUND_JOB) - } } private var mRunning = false private var mKeepUploading = true private val mConduits = ArrayList() + private var serviceJob = SupervisorJob() + private var serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) override fun onCreate() { super.onCreate() @@ -70,7 +64,21 @@ class UploadService : JobService() { } override fun onStartJob(params: JobParameters): Boolean { - CoroutineScope(Dispatchers.IO).launch { + mKeepUploading = true + serviceJob.cancel() + serviceJob = SupervisorJob() + serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + + // Monitor for deletions to cancel active conduits + serviceScope.launch { + UploadEventBus.events.collect { event -> + if (event is UploadEvent.Deleted) { + cancelConduitForMedia(event.mediaId) + } + } + } + + serviceScope.launch { upload { jobFinished(params, false) } @@ -90,8 +98,11 @@ class UploadService : JobService() { override fun onStopJob(params: JobParameters): Boolean { mKeepUploading = false - for (conduit in mConduits) conduit.cancel() - mConduits.clear() + synchronized(mConduits) { + for (conduit in mConduits) conduit.cancel() + mConduits.clear() + } + serviceJob.cancel() return true } @@ -115,17 +126,14 @@ class UploadService : JobService() { return completed() } - // Get all media items that are set into queued state. - var results = emptyList() + // Get items that are set into queued state. + var results = emptyList() var successCount = 0 var failedCount = 0 var totalCount = 0 // Get initial batch - val initialBatch = Media.getByStatus( - listOf(Media.Status.Queued, Media.Status.Uploading), - Media.ORDER_PRIORITY - ) + val initialBatch = mediaRepository.getQueue() if (initialBatch.isNotEmpty()) { // Track upload session started (1+ files) @@ -140,37 +148,53 @@ class UploadService : JobService() { } while (mKeepUploading && - Media.getByStatus( - listOf(Media.Status.Queued, Media.Status.Uploading), - Media.ORDER_PRIORITY - ) + mediaRepository.getQueue() .also { results = it } .isNotEmpty() ) { - val datePublish = Date() + val datePublish = DateUtils.nowDateTime + + // Skip items already in ERROR state — they need explicit user retry via the Edit Queue. + // Without this filter, a failed item would be reset to UPLOADING each iteration, + // fail again, and loop indefinitely. + val uploadableResults = results.filter { it.status != EvidenceStatus.ERROR } + if (uploadableResults.isEmpty()) break - for (media in results) { + for (media in uploadableResults) { totalCount++ - if (media.sStatus != Media.Status.Uploading) { - media.uploadDate = datePublish - media.progress = 0 // Should we reset this? - media.sStatus = Media.Status.Uploading - media.statusMessage = "" + var updatedMedia = media + if (updatedMedia.status != EvidenceStatus.UPLOADING) { + updatedMedia = updatedMedia.copy( + uploadedAt = datePublish, + progress = 0, + status = EvidenceStatus.UPLOADING, + statusMessage = "" + ) } - media.licenseUrl = media.project?.licenseUrl + val vault = spaceRepository.getSpaceById(media.vaultId) + updatedMedia = updatedMedia.copy( + licenseUrl = vault?.licenseUrl, + ) - val collection = media.collection + // Persist updated state before starting upload + mediaRepository.updateEvidence(updatedMedia) - if (collection?.uploadDate == null) { - collection?.uploadDate = datePublish - collection?.save() + // Update the submission upload date if not already set. + // This "closes" the submission bucket in the repository, + // ensuring that any following imports start a new submission. + val submission = collectionRepository.getCollection(updatedMedia.submissionId) + if (submission != null && submission.uploadDate == null) { + collectionRepository.updateCollection(submission.copy(uploadDate = datePublish)) } try { - AppLogger.i("Started uploading", media) - val uploadSuccess = upload(media) + AppLogger.i("Started uploading", updatedMedia) + val uploadSuccess = upload(updatedMedia) if (uploadSuccess) { + serviceScope.launch { + fileCleanupHelper.deleteUploadedMediaFiles(updatedMedia) + } successCount++ } else { failedCount++ @@ -178,9 +202,19 @@ class UploadService : JobService() { } catch (ioe: IOException) { AppLogger.e(ioe) - media.statusMessage = "error in uploading media: " + ioe.message - media.sStatus = Media.Status.Error - media.save() + updatedMedia = updatedMedia.copy( + statusMessage = "error in uploading media: " + ioe.message, + status = EvidenceStatus.ERROR + ) + mediaRepository.updateEvidence(updatedMedia) + + UploadEventBus.emitChanged( + projectId = updatedMedia.archiveId, + collectionId = updatedMedia.submissionId, + mediaId = updatedMedia.id, + progress = -1, + isUploaded = false + ) failedCount++ } @@ -208,31 +242,71 @@ class UploadService : JobService() { } @Throws(IOException::class) - private suspend fun upload(media: Media): Boolean { - media.sStatus = Media.Status.Uploading - AppLogger.i("${media.id} - media status changed to uploading") - media.save() - BroadcastManager.postChange(this, media.collectionId, media.id) + private suspend fun upload(media: Evidence): Boolean { + val updatedMedia = media.copy(status = EvidenceStatus.UPLOADING) + AppLogger.i("${updatedMedia.id} - media status changed to uploading") + mediaRepository.updateEvidence(updatedMedia) + + BroadcastManager.postChange(this, updatedMedia.submissionId, updatedMedia.id) + UploadEventBus.emitChanged( + projectId = updatedMedia.archiveId, + collectionId = updatedMedia.submissionId, + mediaId = updatedMedia.id, + progress = 0, + isUploaded = false + ) - val conduit = Conduit.get(media, this) + val conduit = Conduit.get(updatedMedia, this) if (conduit == null) { AppLogger.e("Conduit is null") return false } - CleanInsightsManager.measureEvent("upload", "try_upload", media.space?.tType?.friendlyName) + // Final check: if it was deleted from DB, don't start the upload + if (mediaRepository.getEvidence(updatedMedia.id) == null) { + AppLogger.i("Media ${updatedMedia.id} was deleted from database, skipping upload") + return false + } + + val vault = spaceRepository.getSpaceById(updatedMedia.vaultId) + CleanInsightsManager.measureEvent("upload", "try_upload", vault?.type?.friendlyName) - mConduits.add(conduit) + synchronized(mConduits) { + mConduits.add(conduit) + } + conduit.upload() - mConduits.remove(conduit) + + synchronized(mConduits) { + mConduits.remove(conduit) + } return true } + private fun cancelConduitForMedia(mediaId: Long) { + synchronized(mConduits) { + val iterator = mConduits.iterator() + while (iterator.hasNext()) { + val conduit = iterator.next() + if (conduit.id == mediaId) { + AppLogger.i("Cancelling active conduit for media $mediaId due to deletion") + conduit.cancel() + iterator.remove() + } + } + } + } + /** * Check if online, and connected to the appropriate network type. */ private fun shouldUpload(): Boolean { + if (Prefs.isMigrationInProgress) { + AppLogger.i("migration in progress, upload paused") + return false + } + val requireUnmetered = Prefs.uploadWifiOnly if (isNetworkAvailable(requireUnmetered)) return true @@ -242,7 +316,7 @@ class UploadService : JobService() { // Try again when there is a network. val job = JobInfo.Builder( - MY_BACKGROUND_JOB, + UploadJobConfig.JOB_ID, ComponentName(this, UploadService::class.java) ) .setRequiredNetworkType(type) @@ -297,7 +371,7 @@ class UploadService : JobService() { val pendingIntent = PendingIntent.getActivity( this, 0, - Intent(this, MainActivity::class.java), + Intent(this, HomeActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) @@ -308,4 +382,4 @@ class UploadService : JobService() { .setContentIntent(pendingIntent) .build() } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/AlertHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/util/AlertHelper.kt deleted file mode 100644 index da34903f3..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/util/AlertHelper.kt +++ /dev/null @@ -1,96 +0,0 @@ -package net.opendasharchive.openarchive.util - -import android.content.Context -import android.content.DialogInterface -import android.view.ContextThemeWrapper -import androidx.appcompat.app.AlertDialog -import net.opendasharchive.openarchive.R - -@Deprecated("Move to common BaseDialog implementation using Jetpack Compose") -class AlertHelper { - - class Button( - val type: Type = Type.Positive, - val title: Int = R.string.lbl_ok, - val listener: ((DialogInterface, Int) -> Unit)? = null - ) { - enum class Type { - Positive, Negative, Neutral - } - } - - companion object { - fun show( - context: Context, message: Int?, title: Int? = R.string.error, - icon: Int? = null, buttons: List