diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 42c583e18..000000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-version: 2
-jobs:
- build:
- working_directory: ~/code
- docker:
- - image: cimg/android:2024.01
- environment:
- JVM_OPTS: -Xmx3200m
- steps:
- - checkout
- - restore_cache:
- key: jars-{{ checksum "build.gradle.kts" }}-{{ checksum "app/build.gradle.kts" }}
- # - run:
- # name: Chmod permissions #if permission for Gradlew Dependencies fail, use this.
- # command: sudo chmod +x ./gradlew
- - run:
- name: Create local.properties
- command: touch local.properties
- - run:
- name: Add dummy api Key
- command: echo "dropbox_key=\"foo\"" >> local.properties
- - save_cache:
- paths:
- - ~/.gradle
- key: jars-{{ checksum "build.gradle.kts" }}-{{ checksum "app/build.gradle.kts" }}
- - run:
- name: Run Tests
- command: ./gradlew lint test
- - store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
- path: app/build/reports
- destination: reports
- - store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
- path: app/build/test-results
- # See https://circleci.com/docs/2.0/deployment-integrations/ for deploy examples
diff --git a/.github/workflows/detekt.yml b/.github/workflows/detekt.yml
index 974e48a90..d7af6e729 100644
--- a/.github/workflows/detekt.yml
+++ b/.github/workflows/detekt.yml
@@ -1,116 +1,76 @@
-# This workflow uses actions that are not certified by GitHub.
-# They are provided by a third-party and are governed by
-# separate terms of service, privacy policy, and support
-# documentation.
-
-# This workflow performs a static analysis of your Kotlin source code using
-# Detekt.
-#
-# Scans are triggered:
-# 1. On every push to default and protected branches
-# 2. On every Pull Request targeting the default branch
-# 3. On a weekly schedule
-# 4. Manually, on demand, via the "workflow_dispatch" event
-#
-# The workflow should work with no modifications, but you might like to use a
-# later version of the Detekt CLI by modifying the $DETEKT_RELEASE_TAG
-# environment variable.
-name: Scan with Detekt
+name: Detekt Analysis
on:
- # Triggers the workflow on push or pull request events but only for default and protected branches
- push:
- branches: [ "master" ]
+ # Triggers on push to key branches
+# push:
+# branches: #[ "master", "stable", "next", "feature/major-refactor-ui-changes" ]
+# - '**' # Triggers on pull requests to any branch
pull_request:
- branches: [ "master" ]
- schedule:
+ branches:
+ - '**' # Triggers on pull requests to any branch
+ schedule: # Scheduled weekly scan
- cron: '35 5 * * 0'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
-env:
- # Release tag associated with version of Detekt to be installed
- # SARIF support (required for this workflow) was introduced in Detekt v1.15.0
- DETEKT_RELEASE_TAG: v1.22.0
- DETEKT_RELEASE: 1.22.0
-
-# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
- # This workflow contains a single job called "scan"
- scan:
- name: Scan
- # The type of runner that the job will run on
+ detekt:
+ name: Static Code Analysis with Detekt
runs-on: ubuntu-latest
- # Steps represent a sequence of tasks that will be executed as part of the job
steps:
- # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- - uses: actions/checkout@v3
+ # ✅ Step 1: Checkout Repository
+ - name: Check out code
+ uses: actions/checkout@v5
- # Gets the download URL associated with the $DETEKT_RELEASE_TAG
- - name: Get Detekt download URL
- id: detekt_info
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh api graphql --field tagName=$DETEKT_RELEASE_TAG --raw-field query='
- query getReleaseAssetDownloadUrl($tagName: String!) {
- repository(name: "detekt", owner: "detekt") {
- release(tagName: $tagName) {
- releaseAssets(name: "detekt", first: 1) {
- nodes {
- downloadUrl
- }
- }
- tagCommit {
- oid
- }
- }
- }
- }
- ' 1> gh_response.json
+ # ✅ Step 2: Set up Java 17
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: 'temurin'
+ java-version: '17'
- DETEKT_RELEASE_SHA=$(jq --raw-output '.data.repository.release.releaseAssets' gh_response.json)
- if [ $DETEKT_RELEASE_SHA != "4b1da0d5feb53d9ae9b80193ad49c5597d7c4b42" ]; then
- echo "Release tag doesn't match expected commit SHA"
- exit 1
- fi
- cat gh_response.json
- DETEKT_DOWNLOAD_URL=https://github.com/detekt/detekt/releases/download/$DETEKT_RELEASE_TAG/detekt-cli-$DETEKT_RELEASE-all.jar
- echo $DETEKT_DOWNLOAD_URL
- echo "download_url=$DETEKT_DOWNLOAD_URL" >> $GITHUB_OUTPUT
+ # ✅ Step 3: Install Ruby and Bundler (Required for Fastlane)
+ - name: Install Ruby and Bundler
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.3'
+ bundler-cache: true
- # Sets up and runs the detekt cli
- - name: Setup and Run Detekt
- continue-on-error: true
- id: detekt_setup_and_run
+ - name: Create local.properties file
run: |
- curl --request GET \
- --url ${{ steps.detekt_info.outputs.download_url }} \
- --silent \
- --location \
- --output detekt.jar
- chmod a+x detekt.jar
- # Performs static analysis using Detekt
- java -jar "detekt.jar" --input ${{ github.workspace }} --all-rules --report sarif:${{ github.workspace }}/detekt.sarif.json
+ echo "MIXPANEL_KEY=${{ secrets.MIXPANEL_KEY }}" >> local.properties
+ echo "STOREFILE=${{ secrets.STOREFILE }}" >> local.properties
+ echo "STOREPASSWORD=${{ secrets.STOREPASSWORD }}" >> local.properties
+ echo "KEYALIAS=${{ secrets.KEYALIAS }}" >> local.properties
+ echo "KEYPASSWORD=${{ secrets.KEYPASSWORD }}" >> local.properties
+
- # Modifies the SARIF output produced by Detekt so that absolute URIs are relative
- # This is so we can easily map results onto their source files
- # This can be removed once relative URI support lands in Detekt: https://git.io/JLBbA
- - name: Make artifact location URIs relative
- continue-on-error: true
+ # ✅ Step 4: Install Fastlane Dependencies
+ - name: Install Fastlane dependencies
run: |
- echo "$(
- jq \
- --arg github_workspace ${{ github.workspace }} \
- '. | ( .runs[].results[].locations[].physicalLocation.artifactLocation.uri |= if test($github_workspace) then .[($github_workspace | length | . + 1):] else . end )' \
- ${{ github.workspace }}/detekt.sarif.json
- )" > ${{ github.workspace }}/detekt.sarif.json
+ bundle config path vendor/bundle
+ bundle install --jobs 4 --retry 3
+
+ # ✅ Step 5: Run Detekt via Fastlane
+ - name: Run Detekt
+ run: |
+ bundle exec fastlane detekt
+
+ # ✅ Step 6: Upload SARIF report for GitHub Security Code Scanning
+ - name: Upload SARIF report for GitHub Code Scanning
+ uses: github/codeql-action/upload-sarif@v4
+ with:
+ sarif_file: "app/build/reports/detekt/detekt.sarif"
+ category: detekt-analysis
- # Uploads results to GitHub repository using the upload-sarif action
- - uses: github/codeql-action/upload-sarif@v2
+ # ✅ Step 7: Upload Detekt Reports as Artifacts for Download
+ - name: Upload Detekt Reports as Artifacts
+ uses: actions/upload-artifact@v4
with:
- # Path to SARIF file relative to the root of the repository
- sarif_file: ${{ github.workspace }}/detekt.sarif.json
- checkout_path: ${{ github.workspace }}
+ name: detekt-reports
+ path: |
+ */build/reports/detekt/*.html
+ */build/reports/detekt/*.md
+ */build/reports/detekt/*.xml
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 7c0199cf8..faec1dec9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,3 +55,9 @@ fastlane/.env
/app/release/baselineProfiles/0/save-unspecified-release.dm
/app/release/baselineProfiles/1/save-unspecified-release.dm
/app/release/output-metadata.json
+/app/src/main/assets/.env
+/.kotlin/sessions/kotlin-compiler-1215430679833621634.salive
+/.kotlin/
+/app/release/
+/app/prod/
+/app/prod/release/save-unspecified-prod-release.aab
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 823bbcdc0..4bad9a32f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,34 +1,71 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
+import java.io.FileInputStream
import java.util.Properties
plugins {
- id("com.android.application")
- id("org.jetbrains.kotlin.android")
- id("org.jetbrains.kotlin.plugin.compose")
- id("org.jetbrains.kotlin.plugin.serialization")
- id("com.google.devtools.ksp")
+ 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.detekt.plugin)
+ alias(libs.plugins.google.gms.google.services)
+ alias(libs.plugins.google.firebase.crashlytics)
}
-android {
- compileSdk = 34
+fun loadLocalProperties(): Properties = Properties().apply {
+ val localPropsFile = rootProject.file("local.properties")
+ if (localPropsFile.exists()) {
+ FileInputStream(localPropsFile).use { load(it) }
+ } else {
+ setProperty("MIXPANELKEY", System.getenv("MIXPANEL_KEY") ?: "")
+ setProperty("STOREFILE", System.getenv("STOREFILE") ?: "")
+ setProperty("STOREPASSWORD", System.getenv("STOREPASSWORD") ?: "")
+ setProperty("KEYALIAS", System.getenv("KEYALIAS") ?: "")
+ setProperty("KEYPASSWORD", System.getenv("KEYPASSWORD") ?: "")
+ }
+}
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
+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)
}
+}
+
+android {
+
+ namespace = "net.opendasharchive.openarchive"
- kotlinOptions {
- jvmTarget = "11"
+ compileSdk = 36
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "net.opendasharchive.openarchive"
minSdk = 29
- targetSdk = 34
- versionCode = 30006
- versionName = "0.7.8"
+ targetSdk = 36
+ versionCode = 30020
+ versionName = "4.0.2"
multiDexEnabled = true
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ val localProps = loadLocalProperties()
+ resValue("string", "mixpanel_key", localProps.getProperty("MIXPANELKEY") ?: "")
}
base {
@@ -47,29 +84,44 @@ android {
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = false
isShrinkResources = false
- applicationIdSuffix = ".release"
- proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
getByName("debug") {
signingConfig = signingConfigs.getByName("debug")
- applicationIdSuffix = ".debug"
isMinifyEnabled = false
}
}
+ flavorDimensions += "env"
+
+ productFlavors {
+
+ create("dev") {
+ dimension = "env"
+ versionNameSuffix = "-dev"
+ applicationIdSuffix = ".debug"
+ }
+
+ create("staging") {
+ dimension = "env"
+ versionNameSuffix = "-staging"
+ applicationIdSuffix = ".debug"
+ }
+
+ create("prod") {
+ dimension = "env"
+ applicationIdSuffix = ".release"
+ }
+ }
+
signingConfigs {
getByName("debug") {
- val props = Properties()
- val localPropsFile = rootProject.file("local.properties")
- if (localPropsFile.exists()) {
- localPropsFile.inputStream().use { props.load(it) }
- }
-
- storeFile = file(props["storeFile"] as? String ?: "")
- storePassword = props["storePassword"] as? String ?: ""
- keyAlias = props["keyAlias"] as? String ?: ""
- keyPassword = props["keyPassword"] as? String ?: ""
+ val props = loadLocalProperties()
+ storeFile = file(props["STOREFILE"] as? String ?: "")
+ storePassword = props["STOREPASSWORD"] as? String ?: ""
+ keyAlias = props["KEYALIAS"] as? String ?: ""
+ keyPassword = props["KEYPASSWORD"] as? String ?: ""
}
}
@@ -88,7 +140,12 @@ android {
abortOnError = false
}
- namespace = "net.opendasharchive.openarchive"
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+
configurations.all {
resolutionStrategy {
@@ -100,120 +157,124 @@ android {
dependencies {
- val composeVersion = "1.7.7"
- val material = "1.12.0"
- val material3 = "1.3.1"
-
- // Core Kotlin and Coroutines
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
- implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
-
-
- // AndroidX Libraries
- implementation("androidx.appcompat:appcompat:1.7.0")
- implementation("androidx.recyclerview:recyclerview:1.3.2")
- implementation("androidx.recyclerview:recyclerview-selection:1.1.0")
- implementation("androidx.constraintlayout:constraintlayout:2.2.0")
- implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0")
- implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
- implementation("androidx.core:core-splashscreen:1.0.1")
-
- implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.7")
- implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
- implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
- implementation("androidx.navigation:navigation-fragment-ktx:2.8.6")
- implementation("androidx.navigation:navigation-ui-ktx:2.8.6")
-
- implementation("androidx.preference:preference-ktx:1.2.1")
- implementation("androidx.biometric:biometric:1.1.0")
- implementation("androidx.work:work-runtime-ktx:2.9.1")
- implementation("androidx.security:security-crypto-ktx:1.1.0-alpha06")
-
- // Compose Preferences
- implementation("me.zhanghai.compose.preference:library:1.1.1")
+ // Kotlin Core
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.serialization.json)
+
+ // AndroidX Core
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.core.splashscreen)
+ implementation(libs.androidx.exifinterface)
+
+ // AndroidX UI Components
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.constraintlayout.compose)
+ implementation(libs.androidx.coordinatorlayout)
+ implementation(libs.androidx.recyclerview)
+ implementation(libs.androidx.recyclerview.selection)
+ implementation(libs.androidx.viewpager2)
+ implementation(libs.androidx.swiperefresh)
+
+ // AndroidX Activity & Fragment
+ implementation(libs.androidx.activity.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.androidx.fragment.compose)
+
+ // AndroidX Lifecycle
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.livedata)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+
+ // AndroidX Navigation
+ implementation(libs.androidx.navigation.fragment)
+ implementation(libs.androidx.navigation.ui)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.navigation.fragment.compose)
+
+ // 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.runtime)
+ implementation(libs.androidx.compose.runtime.livedata)
+ implementation(libs.compose.preferences)
// Material Design
- implementation("com.google.android.material:material:$material")
-
- // AndroidX SwipeRefreshLayout
- implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
-
- // Compose Libraries
- implementation("androidx.activity:activity-compose:1.9.3")
- implementation("androidx.compose.material3:material3:$material3")
- implementation("androidx.compose.ui:ui:$composeVersion")
- implementation("androidx.compose.foundation:foundation:$composeVersion")
- implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion")
- implementation("androidx.compose.material:material-icons-extended:$composeVersion")
- debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion")
-
- // Navigation
- implementation("androidx.navigation:navigation-compose:2.8.6")
-
- // Preference
- implementation("androidx.preference:preference-ktx:1.2.1")
-
- // Dependency Injection
- implementation("io.insert-koin:koin-core:4.1.0-Beta5")
- implementation("io.insert-koin:koin-android:4.1.0-Beta5")
- implementation("io.insert-koin:koin-androidx-compose:4.1.0-Beta5")
-
- // Image Libraries
- implementation("com.github.bumptech.glide:glide:4.16.0")
- annotationProcessor("com.github.bumptech.glide:compiler:4.16.0")
- implementation("com.github.esafirm:android-image-picker:3.0.0")
- implementation("com.squareup.picasso:picasso:2.5.2")
- implementation("io.coil-kt:coil-compose:2.7.0")
- implementation("io.coil-kt:coil-video:2.7.0")
-
- // Networking and Data
+ implementation(libs.google.material)
+
+ // AndroidX Other
+ implementation(libs.androidx.preferences)
+ implementation(libs.androidx.biometric)
+ implementation(libs.androidx.security.crypto)
+ implementation(libs.androidx.work)
+
+ // Dependency Injection - Koin
+ implementation(libs.koin.core)
+ implementation(libs.koin.android)
+ implementation(libs.koin.androidx.compose)
+ implementation(libs.koin.androidx.navigation)
+ implementation(libs.koin.compose)
+ implementation(libs.koin.compose.viewmodel)
+ implementation(libs.koin.compose.viewmodel.navigation)
+
// Networking
- implementation("com.squareup.retrofit2:retrofit:2.11.0")
- implementation("com.squareup.retrofit2:converter-gson:2.11.0")
- implementation("com.google.code.gson:gson:2.11.0")
- implementation("com.squareup.okhttp3:okhttp:4.12.0")
- implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
- implementation("com.github.guardianproject:sardine-android:89f7eae512")
-
- // Utility Libraries
- implementation("com.jakewharton.timber:timber:5.0.1")
- implementation("com.orhanobut:logger:2.2.0")
- implementation("com.github.abdularis:circularimageview:1.4")
- implementation("com.tbuonomo:dotsindicator:5.1.0")
- implementation("com.guolindev.permissionx:permissionx:1.6.4")
-
- // Barcode Scanning
- implementation("com.google.zxing:core:3.4.1")
- implementation("com.journeyapps:zxing-android-embedded:4.2.0")
-
- // Security and Encryption
- implementation("org.bouncycastle:bcpkix-jdk15to18:1.72")
- implementation("org.bouncycastle:bcprov-jdk15to18:1.72")
- api("org.bouncycastle:bcpg-jdk15to18:1.71")
+ implementation(libs.okhttp)
+ implementation(libs.okhttp.logging)
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.gson)
+ implementation(libs.guardianproject.sardine)
+
+ // Images & Media
+ implementation(libs.coil)
+ implementation(libs.coil.compose)
+ implementation(libs.coil.video)
+ implementation(libs.coil.network)
+
+ // CameraX
+ implementation(libs.androidx.camera.core)
+ implementation(libs.androidx.camera.camera2)
+ implementation(libs.androidx.camera.lifecycle)
+ implementation(libs.androidx.camera.video)
+ implementation(libs.androidx.camera.view)
+ implementation(libs.androidx.camera.extensions)
+
+ // Media3 - ExoPlayer
+ implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.ui)
// Google Play Services
- implementation("com.google.android.gms:play-services-auth:21.3.0")
-// implementation("com.google.android.play:core-ktx:1.8.1")
-// implementation("com.google.android.play:asset-delivery-ktx:2.3.0")
-// implementation("com.google.android.play:feature-delivery-ktx:2.1.0")
-// implementation("com.google.android.play:review-ktx:2.0.2")
-// implementation("com.google.android.play:app-update-ktx:2.1.0")
+ //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)
// Google Drive API
- implementation("com.google.http-client:google-http-client-gson:1.42.3")
- implementation("com.google.api-client:google-api-client-android:1.26.0")
- implementation("com.google.apis:google-api-services-drive:v3-rev136-1.25.0")
-
- // Tor Libraries
- implementation("info.guardianproject:tor-android:0.4.7.14")
- implementation("info.guardianproject:jtorctl:0.4.5.7")
-
- implementation("org.bitcoinj:bitcoinj-core:0.16.2")
- implementation("com.eclipsesource.j2v8:j2v8:6.2.1@aar")
-
- // ProofMode //from here: https://github.com/guardianproject/proofmode
- implementation("org.proofmode:android-libproofmode:1.0.26") {
- //transitive = false
+ //implementation(libs.google.api.client.android)
+ //implementation(libs.google.http.client.gson)
+ //implementation(libs.google.drive.api)
+
+ // Security & Cryptography
+ implementation(libs.bouncycastle.bcprov)
+ implementation(libs.bouncycastle.bcpkix)
+ api(libs.bouncycastle.bcpg)
+ implementation(libs.netcipher)
+
+ // Tor & Bitcoin
+ implementation(libs.tor.android)
+ implementation(libs.jtorctl)
+ implementation(libs.bitcoinj.core)
+
+ // ProofMode
+ implementation(libs.proofmode) {
exclude(group = "org.bitcoinj")
exclude(group = "com.google.protobuf")
exclude(group = "org.slf4j")
@@ -226,41 +287,49 @@ dependencies {
exclude(group = "com.squareup.okio", module = "okio")
}
- // Guava Conflicts
- implementation("com.google.guava:guava:31.0.1-jre")
- implementation("com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava")
-
-
- implementation("com.github.satyan:sugar:1.5")
-
-
- // adding web dav support: https://github.com/thegrizzlylabs/sardine-android'
- implementation("com.github.guardianproject:sardine-android:89f7eae512")
-
-
- implementation("com.github.derlio:audio-waveform:v1.0.1")
-
-
- implementation("org.cleaninsights.sdk:clean-insights-sdk:2.8.0")
- implementation("info.guardianproject.netcipher:netcipher:2.2.0-alpha")
-
-
- // Tests
- testImplementation("junit:junit:4.13.2")
- testImplementation("org.robolectric:robolectric:4.10.3")
- androidTestImplementation("androidx.test.ext:junit:1.2.1")
- androidTestImplementation("androidx.test:runner:1.6.2")
- testImplementation("androidx.work:work-testing:2.9.1")
+ // Utilities
+ implementation(libs.timber)
+ implementation(libs.gson)
+ implementation(libs.guava)
+ implementation(libs.guava.listenablefuture)
+ implementation(libs.dotsindicator)
+ implementation(libs.permissionx)
+ implementation(libs.satyan.sugar)
+
+ // Analytics & Tracking
+ implementation(libs.mixpanel)
+ implementation(libs.clean.insights)
+
+ // Firebase
+ implementation(libs.firebase.crashlytics)
+
+ // Testing
+ testImplementation(libs.junit)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.work.testing)
+ androidTestImplementation(libs.androidx.test.junit)
+ androidTestImplementation(libs.androidx.test.runner)
+
+ // Detekt Plugins
+ detektPlugins(libs.detekt.formatting)
+ detektPlugins(libs.detekt.rules.libraries)
+ detektPlugins(libs.detekt.rules.authors)
+ detektPlugins(libs.detekt.compose)
+ detektPlugins(libs.detekt.rules.compose)
}
configurations.all {
exclude(group = "com.google.guava", module = "listenablefuture")
}
-/**
-testdroid {username '$bbusername'
-password '$bbpassword'
-deviceGroup 'gpdevices'
-mode "FULL_RUN"
-projectName "OASave"}**/
-
+detekt {
+ config.setFrom(file("$rootDir/config/detekt-config.yml"))
+ baseline = file("$rootDir/config/baseline.xml")
+ source.setFrom(
+ files("$rootDir/app/src")
+ )
+ buildUponDefaultConfig = true
+ allRules = false
+ autoCorrect = false
+ ignoreFailures = true
+}
diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
new file mode 100644
index 000000000..3dd8b3d7c
--- /dev/null
+++ b/app/detekt-baseline.xml
@@ -0,0 +1,1918 @@
+
+
+
+
+ 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/google-services.json b/app/google-services.json
new file mode 100644
index 000000000..a2ba8be0e
--- /dev/null
+++ b/app/google-services.json
@@ -0,0 +1,48 @@
+{
+ "project_info": {
+ "project_number": "900597763618",
+ "project_id": "oasave-78ec7",
+ "storage_bucket": "oasave-78ec7.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:900597763618:android:e30a7cc27956392030c47b",
+ "android_client_info": {
+ "package_name": "net.opendasharchive.openarchive.debug"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyD1kOmoaIG2hzZjbD2a0fylMABO9SSo950"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:900597763618:android:6f0ce72c1443a26d30c47b",
+ "android_client_info": {
+ "package_name": "net.opendasharchive.openarchive.release"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyD1kOmoaIG2hzZjbD2a0fylMABO9SSo950"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..09589d666
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2459bf39d..27b10f8b2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,10 @@
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
+
+
-
+
+
+
+
@@ -31,6 +39,10 @@
+
+
+
+
+
+
+
+
@@ -93,62 +109,73 @@
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
+ android:icon="@mipmap/ic_mask_launcher_calculator"
+ android:label="@string/app_mask_calculator_label"
+ android:roundIcon="@mipmap/ic_mask_launcher_calculator"
+ android:targetActivity=".features.main.MainActivity">
+
+
+
+
+
-
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+ android:theme="@style/SaveAppTheme.NoActionBar.Onboarding" />
-
-
-
-
-
-
+
+
+
+
-
+ android:exported="false"
+ android:foregroundServiceType="dataSync" />
diff --git a/app/src/main/assets/sugar_upgrades/35.sql b/app/src/main/assets/sugar_upgrades/35.sql
index 033981119..d771c107f 100644
--- a/app/src/main/assets/sugar_upgrades/35.sql
+++ b/app/src/main/assets/sugar_upgrades/35.sql
@@ -1 +1 @@
-ALTER TABLE SPACE add META_DATA TEXT;
\ No newline at end of file
+--ALTER TABLE SPACE add META_DATA TEXT;
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt
index 9428aee2c..2d7e2df54 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt
@@ -17,66 +17,23 @@ interface FolderAdapterListener {
fun projectClicked(project: Project)
- fun projectEdit(project: Project)
-
- fun getSelectedProject(): Project?
}
-class FolderAdapter(
- private val context: Context,
- listener: FolderAdapterListener?,
- val isArchived: Boolean = false
-) : ListAdapter(DIFF_CALLBACK), FolderAdapterListener {
-
- inner class ViewHolder(private val binding: RvFoldersRowBinding) : RecyclerView.ViewHolder(binding.root) {
-
- fun bind(listener: WeakReference?, project: Project?) {
+class FolderAdapter(private val context: Context, private val listener: FolderAdapterListener, private val isArchived: Boolean = false) : ListAdapter(DIFF_CALLBACK) {
- val isSelected = listener?.get()?.getSelectedProject()?.id == project?.id
- itemView.isSelected = isSelected
+ inner class FolderViewHolder(private val binding: RvFoldersRowBinding) :
+ RecyclerView.ViewHolder(binding.root) {
- 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
+ fun bind(project: Project) {
- binding.root.setBackgroundResource(backgroundRes)
-
- binding.rvTitle.text = project?.description
- binding.rvTitle.setTextColor(ContextCompat.getColor(context, textColorRes))
-
- val icon = if (isSelected) {
- ContextCompat.getDrawable(context, R.drawable.baseline_folder_white_24)
- } else {
- ContextCompat.getDrawable(context, R.drawable.outline_folder_white_24)
- }
-
- icon?.setTint(ContextCompat.getColor(context, iconColorRes))
+ 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)
- if (isArchived) {
- binding.rvEdit.visibility = View.GONE
- } else {
- binding.rvEdit.visibility = View.VISIBLE
- }
-
-
-
- if (project != null) {
- binding.textContainer.setOnClickListener {
- if (isArchived) {
- listener?.get()?.projectEdit(project)
- } else {
- listener?.get()?.projectClicked(project)
- }
- }
-
- binding.rvEdit.setOnClickListener {
- listener?.get()?.projectEdit(project)
- }
-
- } else {
- binding.root.setOnClickListener(null)
+ itemView.setOnClickListener {
+ listener.projectClicked(project)
}
}
}
@@ -91,48 +48,10 @@ class FolderAdapter(
return oldItem.description == newItem.description
}
}
-
- private var highlightColor: Int? = null
- private var defaultColor: Int? = null
-
- fun getColorOld(context: Context, highlight: Boolean): Int {
- if (highlight) {
- var color = highlightColor
-
- if (color != null) return color
-
- color = ContextCompat.getColor(context, R.color.colorPrimary)
- highlightColor = color
-
- return color
- }
-
- var color = defaultColor
-
- if (color != null) return color
-
- val textview = TextView(context)
- color = textview.currentTextColor
- defaultColor = color
-
- return color
- }
-
- fun getColor(context: Context, highlight: Boolean): Int {
- return if (highlight) {
- ContextCompat.getColor(context, R.color.colorPrimary)
- } else {
- ContextCompat.getColor(context, R.color.colorOnBackground)
- }
- }
}
- private val mListener: WeakReference? = WeakReference(listener)
-
- private var mLastSelected: Project? = null
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
- return ViewHolder(
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FolderViewHolder {
+ return FolderViewHolder(
RvFoldersRowBinding.inflate(
LayoutInflater.from(parent.context),
parent, false
@@ -140,43 +59,14 @@ class FolderAdapter(
)
}
- override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ override fun onBindViewHolder(holder: FolderViewHolder, position: Int) {
val project = getItem(position)
- holder.bind(WeakReference(this), project)
+ holder.bind( project)
}
fun update(projects: List) {
- notifyItemChanged(getIndex(mLastSelected))
submitList(projects)
}
-
- override fun projectClicked(project: Project) {
- notifyItemChanged(getIndex(getSelectedProject()))
- notifyItemChanged(getIndex(project))
-
- mListener?.get()?.projectClicked(project)
- }
-
- override fun getSelectedProject(): Project? {
- mLastSelected = mListener?.get()?.getSelectedProject()
-
- return mLastSelected
- }
-
- override fun projectEdit(project: Project) {
- notifyItemChanged(getIndex(getSelectedProject()))
- notifyItemChanged(getIndex(project))
-
- mListener?.get()?.projectEdit(project)
- }
-
- private fun getIndex(project: Project?): Int {
- return if (project == null) {
- -1
- } else {
- currentList.indexOf(project)
- }
- }
}
\ 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 e99f921aa..21a9e30a0 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt
@@ -2,35 +2,46 @@ package net.opendasharchive.openarchive
import android.app.NotificationChannel
import android.app.NotificationManager
+import android.app.UiModeManager
import android.content.Context
-import android.util.Log
-import coil.Coil
-import coil.ImageLoader
-import coil.util.Logger
+import android.os.Build
+import androidx.appcompat.app.AppCompatDelegate
+import coil3.ImageLoader
+import coil3.PlatformContext
+import coil3.SingletonImageLoader
+import coil3.video.VideoFrameDecoder
import com.orm.SugarApp
import info.guardianproject.netcipher.proxy.OrbotHelper
import net.opendasharchive.openarchive.core.di.coreModule
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.Analytics
import net.opendasharchive.openarchive.util.Prefs
-import net.opendasharchive.openarchive.util.Theme
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.core.logger.Level
-import timber.log.Timber
-class SaveApp : SugarApp() {
+class SaveApp : SugarApp(), SingletonImageLoader.Factory {
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
}
+ private fun applyTheme() {
+
+ val useDarkMode = Prefs.getBoolean(getString(R.string.pref_key_use_dark_mode), false)
+ val nightMode = if (useDarkMode) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO
+ AppCompatDelegate.setDefaultNightMode(nightMode)
+ }
+
override fun onCreate() {
super.onCreate()
+ Analytics.init(this)
AppLogger.init(applicationContext, initDebugger = true)
registerActivityLifecycleCallbacks(PasscodeManager())
startKoin {
@@ -40,32 +51,16 @@ class SaveApp : SugarApp() {
coreModule,
featuresModule,
retrofitModule,
- unixSocketModule
+ unixSocketModule,
+ passcodeModule
)
}
- val imageLoader = ImageLoader.Builder(this)
- .logger(object : Logger {
- override var level = Log.VERBOSE
-
- override fun log(
- tag: String,
- priority: Int,
- message: String?,
- throwable: Throwable?
- ) {
- Timber.tag("Coil").log(priority, throwable, message)
- }
- })
- .build()
-
- Coil.setImageLoader(imageLoader)
Prefs.load(this)
+ applyTheme()
if (Prefs.useTor) initNetCipher()
- Theme.set(Prefs.theme)
-
CleanInsightsManager.init(this)
createSnowbirdNotificationChannel()
@@ -110,4 +105,13 @@ class SaveApp : SugarApp() {
const val TOR_SERVICE_ID = 2602
const val TOR_SERVICE_CHANNEL = "tor_service_channel"
}
+
+ override fun newImageLoader(context: PlatformContext): ImageLoader {
+ return ImageLoader.Builder(this)
+ .components {
+ add(VideoFrameDecoder.Factory())
+ }
+ .logger(AppLogger.imageLogger)
+ .build()
+ }
}
\ 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 a9d91456c..80fe47bc3 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,7 +1,39 @@
package net.opendasharchive.openarchive.core.di
+import android.content.Context
+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.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.dsl.module
val coreModule = module {
+ // Provide a ResourceProvider using the application context.
+ single { DefaultResourceProvider(androidApplication()) }
+ // Provide DialogStateManager and let Koin inject the ResourceProvider.
+ viewModel { DialogStateManager(get()) }
+
+ viewModel { HomeViewModel() }
+
+ viewModel {
+ MainViewModel()
+ }
+
+ viewModel {
+ BrowseFoldersViewModel(
+ context = get()
+ )
+ }
+
+
+ viewModelOf(::SetupLicenseViewModel)
}
+
+
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 0d743787f..d72463e1e 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,15 +1,12 @@
package net.opendasharchive.openarchive.core.di
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.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
@@ -20,6 +17,7 @@ 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.module.dsl.viewModelOf
import org.koin.core.qualifier.named
import org.koin.dsl.module
@@ -27,40 +25,8 @@ val featuresModule = module {
includes(internetArchiveModule)
// TODO: have some registry of feature modules
- single {
- AppConfig(
- passcodeLength = 6,
- enableHapticFeedback = true,
- maxRetryLimitEnabled = false,
- biometricAuthEnabled = false,
- maxFailedAttempts = 5,
- snowbirdEnabled = true
- )
- }
- single {
- HapticManager(
- appConfig = get(),
- )
- }
- single {
- PBKDF2HashingStrategy()
- }
-
- single { AppConfig() }
-
- single {
- val hashingStrategy: HashingStrategy = PBKDF2HashingStrategy()
-
- PasscodeRepository(
- context = get(),
- config = get(),
- hashingStrategy = hashingStrategy
- )
- }
- viewModel { PasscodeEntryViewModel(get(), get()) }
- viewModel { PasscodeSetupViewModel(get(), get()) }
// single { SnowbirdFileRepository(get(named("retrofit"))) }
// single { SnowbirdGroupRepository(get(named("retrofit"))) }
@@ -72,4 +38,12 @@ val featuresModule = module {
viewModel { (application: Application) -> SnowbirdGroupViewModel(application, get()) }
viewModel { (application: Application) -> SnowbirdFileViewModel(application, get()) }
viewModel { (application: Application) -> SnowbirdRepoViewModel(application, get()) }
+
+
+ viewModelOf(::SpaceListViewModel)
+
+ // WebDAV
+ single { SaveClientFactoryImpl(get()) }
+ single { WebDavRepository(get()) }
+ viewModel { WebDavViewModel(get(), get()) }
}
\ No newline at end of file
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
new file mode 100644
index 000000000..5b5581791
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt
@@ -0,0 +1,50 @@
+package net.opendasharchive.openarchive.core.di
+
+import android.content.Context
+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.PasscodeRepository
+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.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(),
+ )
+ }
+
+ single {
+ PBKDF2HashingStrategy()
+ }
+
+ single {
+ val hashingStrategy: HashingStrategy = PBKDF2HashingStrategy()
+
+ PasscodeRepository(
+ context = get(),
+ config = get(),
+ hashingStrategy = hashingStrategy
+ )
+ }
+
+ viewModel { PasscodeEntryViewModel(get(), get()) }
+ viewModel { PasscodeSetupViewModel(get(), get()) }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt b/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt
index 923d98a7d..6058e6219 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt
@@ -1,12 +1,8 @@
package net.opendasharchive.openarchive.core.logger
import android.content.Context
-import com.orhanobut.logger.AndroidLogAdapter
-import com.orhanobut.logger.DiskLogAdapter
-import com.orhanobut.logger.FormatStrategy
-import com.orhanobut.logger.Logger
-import com.orhanobut.logger.PrettyFormatStrategy
-import net.opendasharchive.openarchive.BuildConfig
+import net.opendasharchive.openarchive.core.logger.AppLogger.init
+import net.opendasharchive.openarchive.util.Analytics
import timber.log.Timber
@@ -37,10 +33,12 @@ object AppLogger {
// Info Level Logging
fun i(message: String, vararg args: Any?) {
Timber.i(message + args.joinToString(" "))
+ Analytics.log(Analytics.APP_LOG, mapOf("info" to message + args.joinToString(" ")))
}
fun i(message: String, throwable: Throwable) {
Timber.i(throwable, message)
+ Analytics.log(Analytics.APP_LOG, mapOf("info" to message))
}
// Debug Level Logging
@@ -55,19 +53,22 @@ object AppLogger {
// Error Level Logging
fun e(message: String, vararg args: Any?) {
Timber.e(message + args.joinToString(" "))
+ Analytics.log(Analytics.APP_ERROR, mapOf("error" to message + args.joinToString(" ")))
}
fun e(message: String, throwable: Throwable) {
Timber.e(throwable, message)
+ Analytics.log(Analytics.APP_ERROR, mapOf("error" to message))
}
fun e(throwable: Throwable) {
Timber.e(throwable)
+ Analytics.log(Analytics.APP_ERROR, mapOf("error" to throwable.message))
}
// Warning Level Logging
fun w(message: String, vararg args: Any?) {
- Timber.w(message + args.joinToString(" "))
+ Timber.w("%s%s", message, args.joinToString(" "))
}
fun w(message: String, throwable: Throwable) {
@@ -76,7 +77,7 @@ object AppLogger {
// Verbose Level Logging
fun v(message: String, vararg args: Any?) {
- Timber.v(message + args.joinToString(" "))
+ Timber.v("%s%s", message, args.joinToString(" "))
}
// Tagged Logging Methods
@@ -98,4 +99,18 @@ object AppLogger {
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)
+ }
+ }
}
\ No newline at end of file
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 c7e2ff259..396124102 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
@@ -1,9 +1,51 @@
package net.opendasharchive.openarchive.core.presentation.components
-import androidx.compose.foundation.layout.RowScope
+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.Icon
+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.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview
@Composable
-fun PrimaryButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) =
- Button(onClick = onClick, content = content)
+fun PrimaryButton(
+ modifier: Modifier = Modifier,
+ icon: ImageVector? = null,
+ text: String,
+ onClick: () -> Unit
+) {
+ Button(
+ modifier = modifier,
+ shape = RoundedCornerShape(8f),
+ onClick = onClick
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ icon?.let {
+ Icon(imageVector = it, contentDescription = null)
+ }
+
+ Text(text)
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun PrimaryButtonPreview() {
+ DefaultBoxPreview {
+
+ PrimaryButton(
+ text = "New Folder"
+ ) { }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Colors.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Colors.kt
index 1efc7585c..5a2e74b6a 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Colors.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Colors.kt
@@ -13,6 +13,7 @@ import net.opendasharchive.openarchive.R
private val c23_nav_drawer_night = Color(0xff101010)
private val c23_darker_grey = Color(0xff212021)
private val c23_dark_grey = Color(0xff333333)
+private val c23_darker_medium_grey = Color(0xff434343)
private val c23_medium_grey = Color(0xff696666)
private val c23_grey = Color(0xff9f9f9f)
private val c23_grey_50 = Color(0xff777979)
@@ -47,18 +48,18 @@ data class ColorTheme(
internal fun lightColorScheme() = ColorTheme(
material = lightColorScheme(
- primary = c23_teal,
+ primary = colorResource(R.color.colorPrimary),
onPrimary = Color.Black,
- primaryContainer = c23_teal,
- onPrimaryContainer = Color.Black,
+ primaryContainer = colorResource(R.color.colorPrimaryContainer),
+ onPrimaryContainer = colorResource(R.color.colorOnPrimaryContainer),
- secondary = c23_teal,
- onSecondary = Color.Black,
- secondaryContainer = c23_teal_90,
- onSecondaryContainer = Color.Black,
+ secondary = colorResource(R.color.colorSecondary),
+ onSecondary = colorResource(R.color.colorOnSecondary),
+ secondaryContainer = colorResource(R.color.colorSecondaryContainer),
+ onSecondaryContainer = colorResource(R.color.colorOnSecondaryContainer),
- tertiary = c23_powder_blue,
- onTertiary = Color.Black,
+ tertiary = colorResource(R.color.colorTertiary),
+ onTertiary = colorResource(R.color.colorSecondary),
tertiaryContainer = c23_powder_blue,
onTertiaryContainer = Color.Black,
@@ -70,12 +71,12 @@ internal fun lightColorScheme() = ColorTheme(
background = colorResource(R.color.colorBackground),
onBackground = colorResource(R.color.colorOnBackground),
- surface = c23_light_grey,
- onSurface = Color.Black,
+ surface = Color.White,
+ onSurface = colorResource(R.color.colorOnSurface),
surfaceVariant = c23_grey,
- onSurfaceVariant = c23_darker_grey,
+ onSurfaceVariant = colorResource(R.color.colorOnSurfaceVariant),
- outline = Color.Black,
+ outline = colorResource(R.color.colorOutline),
inverseOnSurface = Color.White,
inverseSurface = c23_dark_grey,
inversePrimary = Color.Black,
@@ -92,18 +93,19 @@ internal fun lightColorScheme() = ColorTheme(
@Composable
internal fun darkColorScheme() = ColorTheme(
material = darkColorScheme(
- primary = darkPrimary,
+
+ primary = colorResource(R.color.colorPrimary),
onPrimary = Color.White,
- primaryContainer = c23_teal,
- onPrimaryContainer = Color.White,
+ primaryContainer = colorResource(R.color.colorPrimaryContainer),
+ onPrimaryContainer = colorResource(R.color.colorOnPrimaryContainer),
- secondary = c23_teal,
- onSecondary = Color.Black,
- secondaryContainer = c23_teal_20,
- onSecondaryContainer = Color.White,
+ secondary = colorResource(R.color.colorSecondary),
+ onSecondary = colorResource(R.color.colorOnSecondary),
+ secondaryContainer = colorResource(R.color.colorSecondaryContainer),
+ onSecondaryContainer = colorResource(R.color.colorOnSecondaryContainer),
- tertiary = c23_powder_blue,
- onTertiary = Color.Black,
+ tertiary = colorResource(R.color.colorTertiary),
+ onTertiary = colorResource(R.color.colorSecondary),
tertiaryContainer = c23_powder_blue,
onTertiaryContainer = Color.Black,
@@ -118,9 +120,9 @@ internal fun darkColorScheme() = ColorTheme(
surface = c23_darker_grey,
onSurface = Color.White,
surfaceVariant = c23_dark_grey,
- onSurfaceVariant = c23_light_grey,
+ onSurfaceVariant = colorResource(R.color.colorOnSurfaceVariant),
- outline = Color.White,
+ outline = colorResource(R.color.colorOutline),
inverseSurface = c23_light_grey,
inverseOnSurface = Color.Black,
inversePrimary = Color.White,
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 c0261494f..602ce5879 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
@@ -8,27 +8,67 @@ import androidx.compose.material3.Surface
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.unit.dp
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.ComposeAppBar
+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
@Composable
fun DefaultScaffoldPreview(
content: @Composable () -> Unit
) {
+ val context = LocalContext.current
+ KoinApplicationPreview(
+ application = {
+ androidContext(context)
+ modules(passcodeModule)
+ }
+ ) {
+ SaveAppTheme {
- SaveAppTheme {
+ Scaffold(
+ topBar = {
+ ComposeAppBar(
+ title = "Save App"
+ )
+ }
+ ) { paddingValues ->
- Scaffold(
- topBar = {
- ComposeAppBar()
+ Box(
+ modifier = Modifier.Companion.padding(paddingValues),
+ contentAlignment = Alignment.Companion.Center
+ ) {
+ content()
+ }
}
- ) { paddingValues ->
+ }
+ }
- Box(
- modifier = Modifier.Companion.padding(paddingValues),
- contentAlignment = Alignment.Companion.Center
- ) {
- content()
+}
+
+@Composable
+fun DefaultEmptyScaffoldPreview(
+ content: @Composable () -> Unit
+) {
+ val context = LocalContext.current
+ KoinApplicationPreview(
+ application = {
+ androidContext(context)
+ modules(passcodeModule)
+ }
+ ) {
+ SaveAppTheme {
+
+ Scaffold { paddingValues ->
+
+ Box(
+ modifier = Modifier.Companion.padding(paddingValues),
+ contentAlignment = Alignment.Companion.Center
+ ) {
+ content()
+ }
}
}
}
@@ -39,18 +79,26 @@ fun DefaultScaffoldPreview(
fun DefaultBoxPreview(
content: @Composable () -> Unit
) {
- SaveAppTheme {
- Surface(
- color = MaterialTheme.colorScheme.surface
- ) {
- Box(
- modifier = Modifier.padding(12.dp),
- contentAlignment = Alignment.Center
+ val context = LocalContext.current
+ KoinApplicationPreview(
+ application = {
+ androidContext(context)
+ modules(passcodeModule)
+ }
+ ) {
+ SaveAppTheme {
+ Surface(
+ color = MaterialTheme.colorScheme.surface
) {
- content()
+ Box(
+ modifier = Modifier.padding(12.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ content()
+ }
}
- }
+ }
}
}
\ No newline at end of file
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
new file mode 100644
index 000000000..356414cf0
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/SaveTextStyles.kt
@@ -0,0 +1,216 @@
+package net.opendasharchive.openarchive.core.presentation.theme
+
+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.text.TextStyle
+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.sp
+
+/**
+ * Save App Text Styles - Based on Figma Design System
+ * From "Save App 3.0" - 6 text styles only
+ *
+ * Usage Examples:
+ * - 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
+ )
+ }
+}
+
+/**
+ * Raw TextStyle objects based on Figma "Save App 3.0"
+ * Only the 6 text styles from the design system
+ */
+object SaveTextStyles {
+
+ // 18pt - SemiBold - For page titles, primary headers
+ val titleBold = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.01.sp,
+ lineHeight = 22.sp,
+ )
+
+ val titleLarge = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.01.sp,
+ lineHeight = 22.sp,
+ )
+
+ // 16pt - SemiBold - For section headers, card titles
+ val titleMedium = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.01.sp,
+ lineHeight = 20.sp,
+ )
+
+ // 14pt - Medium - Primary body text, descriptions
+ val bodyLarge = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 0.01.sp,
+ lineHeight = 16.sp,
+ )
+
+ // 13sp Hint in text fields
+ val labelMedium = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Normal,
+ fontStyle = FontStyle.Italic
+ )
+
+ // 11pt - Medium - Secondary text, captions, metadata
+ val bodySmall = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 0.01.sp,
+ lineHeight = 14.sp,
+ )
+
+ // 14pt Link - Medium - Interactive elements, buttons, links
+ val labelLarge = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 0.01.sp,
+ lineHeight = 16.sp,
+ )
+
+ // 11pt Italic - For subtle notes, disclaimers, hints
+ val bodySmallEmphasis = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Normal,
+ fontStyle = FontStyle.Italic,
+ letterSpacing = 0.01.sp,
+ lineHeight = 14.sp,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Theme.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Theme.kt
index 20c022d35..deb3a5d35 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Theme.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Theme.kt
@@ -24,7 +24,8 @@ fun SaveAppTheme(
MaterialTheme(
colorScheme = colors.material,
content = content,
- shapes = Shapes
+ shapes = Shapes,
+ typography = Typography,
)
}
}
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
new file mode 100644
index 000000000..aaede27ba
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Type.kt
@@ -0,0 +1,92 @@
+package net.opendasharchive.openarchive.core.presentation.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import net.opendasharchive.openarchive.R
+
+// Define Montserrat FontFamily
+val MontserratFontFamily = FontFamily(
+ Font(R.font.montserrat_thin, weight = FontWeight.Thin, style = FontStyle.Normal), // 100
+ Font(R.font.montserrat_extra_light, weight = FontWeight.ExtraLight, style = FontStyle.Normal), // 200
+ Font(R.font.montserrat_light, weight = FontWeight.Light, style = FontStyle.Normal), // 300
+ Font(R.font.montserrat_regular, weight = FontWeight.Normal, style = FontStyle.Normal), // 400
+ Font(R.font.montserrat_medium, weight = FontWeight.Medium, style = FontStyle.Normal), // 500
+ Font(R.font.montserrat_semi_bold, weight = FontWeight.SemiBold, style = FontStyle.Normal), // 600
+ Font(R.font.montserrat_bold, weight = FontWeight.Bold, style = FontStyle.Normal), // 700
+ Font(R.font.montserrat_extra_bold, weight = FontWeight.ExtraBold, style = FontStyle.Normal), // 800
+ Font(R.font.montserrat_black, weight = FontWeight.Black, style = FontStyle.Normal), // 900
+
+ Font(R.font.montserrat_thin_italic, weight = FontWeight.Thin, style = FontStyle.Italic), // 100
+ Font(R.font.montserrat_extra_light_italic, weight = FontWeight.ExtraLight, style = FontStyle.Italic), // 200
+ Font(R.font.montserrat_light_italic, weight = FontWeight.Light, style = FontStyle.Italic), // 300
+ Font(R.font.montserrat_italic, weight = FontWeight.Normal, style = FontStyle.Italic), // 400
+ Font(R.font.montserrat_medium_italic, weight = FontWeight.Medium, style = FontStyle.Italic), // 500
+ Font(R.font.montserrat_semi_bold_italic, weight = FontWeight.SemiBold, style = FontStyle.Italic), // 600
+ Font(R.font.montserrat_bold_italic, weight = FontWeight.Bold, style = FontStyle.Italic), // 700
+ Font(R.font.montserrat_extra_bold_italic, weight = FontWeight.ExtraBold, style = FontStyle.Italic), // 800
+ Font(R.font.montserrat_black_italic, weight = FontWeight.Black, style = FontStyle.Italic) // 900
+)
+
+
+// Updated Typography to integrate with your 6 Figma text styles
+val Typography = Typography(
+ // Display styles - Keep Material Design defaults with Montserrat
+ displayLarge = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 57.sp,
+ lineHeight = 64.sp,
+ fontWeight = FontWeight.Normal
+ ),
+ displayMedium = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 45.sp,
+ lineHeight = 52.sp,
+ fontWeight = FontWeight.Normal
+ ),
+ displaySmall = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ fontWeight = FontWeight.Normal
+ ),
+
+ // Headlines - Keep Material defaults
+ headlineLarge = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ fontWeight = FontWeight.Bold
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ fontWeight = FontWeight.SemiBold
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ fontWeight = FontWeight.SemiBold
+ ),
+
+ // Titles - Map to your Figma styles
+ titleLarge = SaveTextStyles.titleLarge, // 18sp, SemiBold
+ titleMedium = SaveTextStyles.titleMedium, // 16sp, SemiBold
+ titleSmall = SaveTextStyles.bodyLarge, // 14sp, Medium
+
+ // Body text - Map to your Figma styles
+ bodyLarge = SaveTextStyles.bodyLarge, // 14sp, Medium
+ bodyMedium = SaveTextStyles.bodyLarge, // 14sp, Medium (reuse)
+ bodySmall = SaveTextStyles.bodySmall, // 11sp, Medium
+
+ // Labels - Map to your Figma styles
+ labelLarge = SaveTextStyles.labelLarge, // 14sp, Medium (for links/actions)
+ labelMedium = SaveTextStyles.labelMedium, // 13sp, Italic Medium
+ labelSmall = SaveTextStyles.bodySmall // 11sp, Medium
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Media.kt b/app/src/main/java/net/opendasharchive/openarchive/db/Media.kt
index 6301d2ffa..411813425 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/db/Media.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/db/Media.kt
@@ -8,6 +8,7 @@ import com.orm.SugarRecord
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
+import androidx.core.net.toUri
data class Media(
var originalFilePath: String = "",
@@ -111,7 +112,7 @@ data class Media(
}
val fileUri: Uri
- get() = Uri.parse(originalFilePath)
+ get() = originalFilePath.toUri()
val file: File
get() = fileUri.toFile()
diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/MediaAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/db/MediaAdapter.kt
deleted file mode 100644
index 4d1b82fd9..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/db/MediaAdapter.kt
+++ /dev/null
@@ -1,345 +0,0 @@
-package net.opendasharchive.openarchive.db
-
-import android.annotation.SuppressLint
-import android.app.Activity
-import android.content.Intent
-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.features.media.PreviewActivity
-import net.opendasharchive.openarchive.upload.BroadcastManager
-import net.opendasharchive.openarchive.upload.UploadManagerActivity
-import net.opendasharchive.openarchive.upload.UploadService
-import net.opendasharchive.openarchive.util.AlertHelper
-import net.opendasharchive.openarchive.util.Prefs
-import net.opendasharchive.openarchive.util.extensions.toggle
-import java.lang.ref.WeakReference
-
-class MediaAdapter(
- activity: Activity?,
- private val generator: (parent: ViewGroup) -> MediaViewHolder,
- data: List,
- private val recyclerView: RecyclerView,
- private val supportedStatuses: List = listOf(
- Media.Status.Local,
- Media.Status.Uploading,
- Media.Status.Error
- ),
- private val checkSelecting: (() -> Unit)? = null
-) : RecyclerView.Adapter() {
-
- var media: ArrayList = ArrayList(data)
- private set
-
- var doImageFade = true
-
- var isEditMode = false
-
- var selecting = false
- private set
-
- private var mActivity = WeakReference(activity)
-
- init {
- setHasStableIds(true)
- }
-
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder {
- val mvh = generator(parent)
-
- mvh.itemView.setOnClickListener { v ->
- if (selecting && checkSelecting != null) {
- selectView(v)
- } else {
- val pos = recyclerView.getChildLayoutPosition(v)
-
- when (media[pos].sStatus) {
- Media.Status.Local -> {
- if (supportedStatuses.contains(Media.Status.Local)) {
- mActivity.get()?.let {
- PreviewActivity.start(it, media[pos].projectId)
- }
- }
- }
-
- Media.Status.Queued, Media.Status.Uploading -> {
- if (supportedStatuses.contains(Media.Status.Uploading)) {
- mActivity.get()?.let {
- it.startActivity(
- Intent(it, UploadManagerActivity::class.java)
- )
- }
- }
- }
-
- Media.Status.Error -> {
- if (supportedStatuses.contains(Media.Status.Error)) {
- //CleanInsightsManager.measureEvent("backend", "upload-error", media[pos].space?.friendlyName)
- mActivity.get()?.let {
- AlertHelper.show(
- 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()
- )
- )
- }
- }
- }
-
- else -> {
- if (checkSelecting != null) {
- selectView(v)
- }
- }
- }
- }
- }
-
- if (checkSelecting != null) {
- mvh.itemView.setOnLongClickListener { v ->
- selectView(v)
-
- true
- }
- }
-
- mvh.flagIndicator?.setOnClickListener {
- showFirstTimeFlag()
-
- // Toggle flag
- val mediaId = mvh.itemView.tag as? Long ?: return@setOnClickListener
-
- val item = media.firstOrNull { it.id == mediaId } ?: return@setOnClickListener
- item.flag = !item.flag
- item.save()
-
- notifyItemChanged(media.indexOf(item))
- }
-
- return mvh
- }
-
- override fun getItemCount(): Int = media.size
-
- override fun getItemId(position: Int): Long {
- return media[position].id
- }
-
- @SuppressLint("ClickableViewAccessibility")
- override fun onBindViewHolder(holder: MediaViewHolder, position: Int) {
- AppLogger.i("onBindViewHolder called for position $position")
- holder.bind(media[position], selecting, doImageFade)
- holder.handle?.toggle(isEditMode)
- }
-
- override fun onBindViewHolder(holder: MediaViewHolder, 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)
- holder.handle?.toggle(isEditMode)
- }
- }
- } else {
- holder.bind(media[position], selecting, doImageFade)
- holder.handle?.toggle(isEditMode)
- }
- }
-
- fun updateItem(mediaId: Long, progress: Int, isUploaded: Boolean = false): Boolean {
- val idx = media.indexOfFirst { it.id == mediaId }
- AppLogger.i("updateItem: mediaId=$mediaId idx=$idx")
- if (idx < 0) return false
-
- val item = media[idx]
-
- if (isUploaded) {
- item.status = Media.Status.Uploaded.id
- AppLogger.i("Media item $mediaId uploaded, notifying item changed at position $idx")
- notifyItemChanged(idx, "full")
- } else if (progress >= 0) {
- item.uploadPercentage = progress
- item.status = Media.Status.Uploading.id
- notifyItemChanged(idx, "progress")
- }
-
- 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 showFirstTimeFlag() {
- if (Prefs.flagHintShown) return
- val activity = mActivity.get() ?: return
-
- AlertHelper.show(activity, R.string.popup_flag_desc, R.string.popup_flag_title)
-
- Prefs.flagHintShown = true
- }
-
- 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))
-
- selecting = media.firstOrNull { it.selected } != null
- checkSelecting?.invoke()
- }
-
- 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_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()
- }
-
- BroadcastManager.postDelete(recyclerView.context, item.id)
- }
-
- super.onDismissed(transientBottomBar, event)
- }
- })
-
- snackbar.show()
-
- removeItem(item.id)
-
- mActivity.get()?.let {
- BroadcastManager.postDelete(it, 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
- }
-
- selecting = false
-
- 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/MediaViewHolder.kt b/app/src/main/java/net/opendasharchive/openarchive/db/MediaViewHolder.kt
deleted file mode 100644
index 036dea040..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/db/MediaViewHolder.kt
+++ /dev/null
@@ -1,429 +0,0 @@
-package net.opendasharchive.openarchive.db
-
-import android.annotation.SuppressLint
-import android.text.format.Formatter
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.core.content.ContextCompat
-import androidx.recyclerview.widget.RecyclerView
-import androidx.swiperefreshlayout.widget.CircularProgressDrawable
-import androidx.viewbinding.ViewBinding
-import com.bumptech.glide.Glide
-import com.github.derlio.waveform.SimpleWaveformView
-import com.github.derlio.waveform.soundfile.SoundFile
-import com.google.android.material.progressindicator.CircularProgressIndicator
-import com.squareup.picasso.Picasso
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.launch
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.core.logger.AppLogger
-import net.opendasharchive.openarchive.databinding.RvMediaBoxBinding
-import net.opendasharchive.openarchive.databinding.RvMediaRowBigBinding
-import net.opendasharchive.openarchive.databinding.RvMediaRowSmallBinding
-import net.opendasharchive.openarchive.fragments.VideoRequestHandler
-import net.opendasharchive.openarchive.util.extensions.hide
-import net.opendasharchive.openarchive.util.extensions.show
-import timber.log.Timber
-import java.io.InputStream
-
-abstract class MediaViewHolder(protected val binding: ViewBinding) :
- RecyclerView.ViewHolder(binding.root) {
-
- class Box(parent: ViewGroup) : MediaViewHolder(
- RvMediaBoxBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- ) {
- override val image: ImageView
- get() = (binding as RvMediaBoxBinding).image
-
- override val waveform: SimpleWaveformView
- get() = (binding as RvMediaBoxBinding).waveform
-
- override val videoIndicator: ImageView
- get() = (binding as RvMediaBoxBinding).videoIndicator
-
- override val overlayContainer: View
- get() = (binding as RvMediaBoxBinding).overlayContainer
-
- override val progress: CircularProgressIndicator
- get() = (binding as RvMediaBoxBinding).progress
-
- override val progressText: TextView
- get() = (binding as RvMediaBoxBinding).progressText
-
- override val error: ImageView
- get() = (binding as RvMediaBoxBinding).error
-
- override val title: TextView?
- get() = null //(binding as RvMediaBoxBinding).title
-
- override val fileInfo: TextView?
- get() = null //(binding as RvMediaBoxBinding).fileInfo
-
- override val locationIndicator: ImageView?
- get() = null
-
- override val tagsIndicator: ImageView?
- get() = null
-
- override val descIndicator: ImageView?
- get() = null
-
- override val flagIndicator: ImageView?
- get() = null
-
- override val selectedIndicator: View
- get() = (binding as RvMediaBoxBinding).selectedIndicator
-
- override val handle: ImageView?
- get() = null
- }
-
- class BigRow(parent: ViewGroup) : MediaViewHolder(
- RvMediaRowBigBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- ) {
- override val image: ImageView
- get() = (binding as RvMediaRowBigBinding).image
-
- override val waveform: SimpleWaveformView
- get() = (binding as RvMediaRowBigBinding).waveform
-
- override val videoIndicator: ImageView
- get() = (binding as RvMediaRowBigBinding).videoIndicator
-
- override val overlayContainer: View?
- get() = null
-
- override val progress: CircularProgressIndicator?
- get() = null
-
- override val progressText: TextView?
- get() = null
-
- override val error: ImageView?
- get() = null
-
- override val title: TextView
- get() = (binding as RvMediaRowBigBinding).title
-
- override val fileInfo: TextView
- get() = (binding as RvMediaRowBigBinding).fileInfo
-
- override val locationIndicator: ImageView
- get() = (binding as RvMediaRowBigBinding).locationIndicator
-
- override val tagsIndicator: ImageView
- get() = (binding as RvMediaRowBigBinding).tagsIndicator
-
- override val descIndicator: ImageView
- get() = (binding as RvMediaRowBigBinding).descIndicator
-
- override val flagIndicator: ImageView
- get() = (binding as RvMediaRowBigBinding).flagIndicator
-
- override val selectedIndicator: View?
- get() = null
-
- override val handle: ImageView?
- get() = null
- }
-
- class SmallRow(parent: ViewGroup) : MediaViewHolder(
- RvMediaRowSmallBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- ) {
- override val image: ImageView
- get() = (binding as RvMediaRowSmallBinding).image
-
- override val waveform: SimpleWaveformView
- get() = (binding as RvMediaRowSmallBinding).waveform
-
- override val videoIndicator: ImageView?
- get() = null
-
- override val overlayContainer: View
- get() = (binding as RvMediaRowSmallBinding).overlayContainer
-
- override val progress: CircularProgressIndicator
- get() = (binding as RvMediaRowSmallBinding).progress
-
- override val progressText: TextView
- get() = (binding as RvMediaRowSmallBinding).progressText
-
- override val error: ImageView
- get() = (binding as RvMediaRowSmallBinding).error
-
- override val title: TextView
- get() = (binding as RvMediaRowSmallBinding).title
-
- override val fileInfo: TextView
- get() = (binding as RvMediaRowSmallBinding).fileInfo
-
- override val locationIndicator: ImageView?
- get() = null
-
- override val tagsIndicator: ImageView?
- get() = null
-
- override val descIndicator: ImageView?
- get() = null
-
- override val flagIndicator: ImageView?
- get() = null
-
- override val selectedIndicator: View?
- get() = null
-
- override val handle: ImageView
- get() = (binding as RvMediaRowSmallBinding).handle
- }
-
-
- companion object {
- val soundCache = HashMap()
- }
-
-
- abstract val image: ImageView
- abstract val waveform: SimpleWaveformView
- abstract val videoIndicator: ImageView?
- abstract val overlayContainer: View?
- abstract val progress: CircularProgressIndicator?
- abstract val progressText: TextView?
- abstract val error: ImageView?
- abstract val title: TextView?
- abstract val fileInfo: TextView?
- abstract val locationIndicator: ImageView?
- abstract val tagsIndicator: ImageView?
- abstract val descIndicator: ImageView?
- abstract val flagIndicator: ImageView?
- abstract val selectedIndicator: View?
- abstract val handle: ImageView?
-
- private val mContext = itemView.context
-
- private val mPicasso = Picasso.Builder(mContext)
- .addRequestHandler(VideoRequestHandler(mContext))
- .build()
-
-
- @SuppressLint("SetTextI18n")
- fun bind(media: Media? = null, batchMode: Boolean = false, doImageFade: Boolean = true) {
- AppLogger.i("Binding media item ${media?.id} with status ${media?.sStatus} and progress ${media?.uploadPercentage}")
- itemView.tag = media?.id
-
- if (batchMode && media?.selected == true) {
- itemView.setBackgroundResource(R.color.colorPrimary)
- selectedIndicator?.show()
- } else {
- itemView.setBackgroundResource(R.color.transparent)
- selectedIndicator?.hide()
- }
-
- image.alpha = if (media?.sStatus == Media.Status.Uploaded || !doImageFade) 1f else 0.5f
-
- if (media?.mimeType?.startsWith("image") == true) {
- val progress = CircularProgressDrawable(mContext)
- progress.strokeWidth = 5f
- progress.centerRadius = 30f
- progress.start()
-
- Glide.with(mContext)
- .load(media.fileUri)
- .placeholder(progress)
- .fitCenter()
- .into(image)
-
- image.show()
- waveform.hide()
- videoIndicator?.hide()
- } else if (media?.mimeType?.startsWith("video") == true) {
- mPicasso.load(VideoRequestHandler.SCHEME_VIDEO + ":" + media.originalFilePath)
- .fit()
- .centerCrop()
- .into(image)
-
- image.show()
- waveform.hide()
- videoIndicator?.show()
- } else if (media?.mimeType?.startsWith("audio") == true) {
- videoIndicator?.hide()
-
- val soundFile = soundCache[media.originalFilePath]
-
- if (soundFile != null) {
- image.hide()
- waveform.setAudioFile(soundFile)
- waveform.show()
- } else {
- image.setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.no_thumbnail))
- image.show()
- waveform.hide()
-
- CoroutineScope(Dispatchers.IO).launch {
- @Suppress("NAME_SHADOWING")
- val soundFile = try {
- SoundFile.create(media.originalFilePath) {
- return@create true
- }
- } catch (e: Throwable) {
- Timber.d(e)
-
- null
- }
-
- if (soundFile != null) {
- soundCache[media.originalFilePath] = soundFile
-
- MainScope().launch {
- waveform.setAudioFile(soundFile)
- image.hide()
- waveform.show()
- }
- }
- }
- }
- } else {
- image.setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.no_thumbnail))
- image.show()
- waveform.hide()
- videoIndicator?.hide()
- }
-
- if (media != null) {
- val file = media.file
-
- if (file.exists()) {
- 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()
- }
- }
-
- fileInfo?.text = if (media.contentLength > 0) {
- Formatter.formatShortFileSize(mContext, media.contentLength)
- } else {
- media.formattedCreateDate
- }
- }
-
- fileInfo?.show()
- } else {
- 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))
-
- overlayContainer?.show()
- progress?.hide()
- progressText?.hide()
- error?.show()
-
- if (media.statusMessage.isNotBlank()) {
- fileInfo?.text = media.statusMessage
- fileInfo?.show()
- }
- } else if (media?.sStatus == Media.Status.Queued) {
- AppLogger.i("Media Item ${media.id} is queued")
- overlayContainer?.show()
- progress?.isIndeterminate = true
- progress?.show()
- progressText?.hide()
- error?.hide()
- } else if (media?.sStatus == Media.Status.Uploading) {
-// val progressValue = if (media.contentLength > 0) {
-// (media.progress.toFloat() / media.contentLength.toFloat() * 100f).roundToInt()
-// } else 0
- progress?.isIndeterminate = false
- val progressValue = media.uploadPercentage ?: 0
- AppLogger.i("Media Item ${media.id} is uploading")
-
- overlayContainer?.show()
- progress?.show()
- progressText?.show()
-
- // Make sure to keep spinning until the upload has made some noteworthy progress.
- if (progressValue > 2) {
- progress?.setProgressCompat(progressValue, true)
- }
-// else {
-// progress?.isIndeterminate = true
-// }
-
- progressText?.text = "${progressValue}%"
-
- error?.hide()
- } else {
- overlayContainer?.hide()
- progress?.hide()
- progressText?.hide()
- error?.hide()
- }
-
- if (sbTitle.isNotEmpty()) sbTitle.append(": ")
- sbTitle.append(media?.title)
-
- if (sbTitle.isNotBlank()) {
- title?.text = sbTitle.toString()
- title?.show()
- } else {
- title?.hide()
- }
-
- locationIndicator?.setImageResource(
- if (media?.location.isNullOrBlank()) R.drawable.ic_location_unselected
- else R.drawable.ic_location_selected
- )
-
- tagsIndicator?.setImageResource(
- if (media?.tags.isNullOrBlank()) R.drawable.ic_tag_unselected
- else R.drawable.ic_tag_selected
- )
-
- descIndicator?.setImageResource(
- if (media?.description.isNullOrBlank()) R.drawable.ic_edit_unselected
- else R.drawable.ic_edit_selected
- )
-
- flagIndicator?.setImageResource(
- if (media?.flag == true) R.drawable.ic_flag_selected
- else R.drawable.ic_flag_unselected
- )
- }
-
- fun updateProgress(progressValue: Int) {
- if (progressValue > 2) {
- progress?.isIndeterminate = false
- progress?.setProgressCompat(progressValue, true)
- } else {
- progress?.isIndeterminate = true
- }
-
- AppLogger.i("Updating progressText to $progressValue%")
- if (progressText == null) {
- AppLogger.e("progressText is null")
- } else {
- progressText?.show(animate = true)
- progressText?.text = "$progressValue%"
- }
- }
-}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt b/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt
index f9439ccb5..e71e4aca0 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt
@@ -2,18 +2,18 @@ package net.opendasharchive.openarchive.db
import android.content.Context
import android.content.Intent
-import android.graphics.drawable.BitmapDrawable
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.github.abdularis.civ.AvatarImageView
import com.orm.SugarRecord
import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.core.logger.AppLogger
import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity
-import net.opendasharchive.openarchive.services.gdrive.GDriveConduit
import net.opendasharchive.openarchive.services.internetarchive.IaConduit
-import net.opendasharchive.openarchive.util.DrawableUtil
import net.opendasharchive.openarchive.util.Prefs
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -53,19 +53,15 @@ data class Space(
name = IaConduit.NAME
host = IaConduit.ARCHIVE_API_ENDPOINT
}
- Type.GDRIVE -> {
- name = GDriveConduit.NAME
- }
Type.RAVEN -> "Raven"
}
}
enum class Type(val id: Int, val friendlyName: String) {
- WEBDAV(0, "WebDAV"),
+ WEBDAV(0, "Private Server"),
INTERNET_ARCHIVE(1, IaConduit.NAME),
- GDRIVE(4, GDriveConduit.NAME),
- RAVEN(5, "Raven"),
+ RAVEN(5, "DWeb Service"),
}
enum class IconStyle {
@@ -91,8 +87,10 @@ data class Space(
whereArgs.add(username)
}
- return find(Space::class.java, whereClause, whereArgs.toTypedArray(),
- null, null, null)
+ return find(
+ Space::class.java, whereClause, whereArgs.toTypedArray(),
+ null, null, null
+ )
}
fun has(type: Type, host: String? = null, username: String? = null): Boolean {
@@ -100,8 +98,12 @@ data class Space(
}
var current: Space?
- get() = get(Prefs.currentSpaceId) ?: first(Space::class.java)
+ get() {
+ AppLogger.i("getting current space....")
+ return get(Prefs.currentSpaceId) ?: first(Space::class.java)
+ }
set(value) {
+ AppLogger.i("setting current space... ${value?.displayname}")
Prefs.currentSpaceId = value?.id ?: -1
}
@@ -112,8 +114,7 @@ data class Space(
fun navigate(activity: AppCompatActivity) {
if (getAll().hasNext()) {
activity.finish()
- }
- else {
+ } else {
activity.finishAffinity()
activity.startActivity(Intent(activity, SpaceSetupActivity::class.java))
}
@@ -135,10 +136,10 @@ data class Space(
val hostUrl: HttpUrl?
get() = host.toHttpUrlOrNull()
- var tType: Type?
- get() = Type.values().firstOrNull { it.id == type }
+ var tType: Type
+ get() = Type.entries.first { it.id == type }
set(value) {
- type = (value ?: Type.WEBDAV).id
+ type = value.id
}
var license: String?
@@ -160,52 +161,69 @@ 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.
- return find(Project::class.java, "space_id = ? AND description = ?", id.toString(), description).size > 0
+ return find(
+ Project::class.java,
+ "space_id = ? AND description = ?",
+ id.toString(),
+ description
+ ).isNotEmpty()
}
- fun getAvatar(context: Context, style: IconStyle = IconStyle.SOLID): Drawable? {
- val color = ContextCompat.getColor(context, R.color.colorOnBackground)
+ fun getAvatar(context: Context): Drawable? {
+
return when (tType) {
- Type.WEBDAV -> ContextCompat.getDrawable(context, R.drawable.ic_private_server) // ?.tint(color)
+ Type.WEBDAV -> ContextCompat.getDrawable(context, R.drawable.ic_private_server)
+
+ Type.INTERNET_ARCHIVE -> ContextCompat.getDrawable(context, R.drawable.ic_internet_archive)
+
+ Type.RAVEN -> ContextCompat.getDrawable(context, R.drawable.snowbird)
- Type.INTERNET_ARCHIVE -> ContextCompat.getDrawable(context, R.drawable.ic_internet_archive) // ?.tint(color)
+ }
+ }
- Type.GDRIVE -> ContextCompat.getDrawable(context, R.drawable.logo_gdrive_outline) // ?.tint(color)
+ @Composable
+ fun getAvatar(): Painter {
- Type.RAVEN -> ContextCompat.getDrawable(context, R.drawable.snowbird) // ?.tint(color)
+ return when (tType) {
+ Type.WEBDAV -> painterResource(R.drawable.ic_space_private_server)
- else -> BitmapDrawable(context.resources, DrawableUtil.createCircularTextDrawable(initial, color))
+ Type.INTERNET_ARCHIVE -> painterResource(R.drawable.ic_space_interent_archive)
+ Type.RAVEN -> painterResource(R.drawable.ic_space_dweb)
}
}
fun setAvatar(view: ImageView) {
when (tType) {
Type.INTERNET_ARCHIVE -> {
- if (view is AvatarImageView) {
- view.state = AvatarImageView.SHOW_IMAGE
- }
-
view.setImageDrawable(getAvatar(view.context))
}
else -> {
- if (view is AvatarImageView) {
- view.state = AvatarImageView.SHOW_INITIAL
- view.setText(initial)
- view.avatarBackgroundColor = ContextCompat.getColor(view.context, R.color.colorPrimary)
- }
- else {
- view.setImageDrawable(getAvatar(view.context))
- }
+ view.setImageDrawable(getAvatar(view.context))
+
}
}
}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaAdapter.kt
new file mode 100644
index 000000000..f0f964742
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaAdapter.kt
@@ -0,0 +1,259 @@
+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
new file mode 100644
index 000000000..40fc9acd4
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaViewHolder.kt
@@ -0,0 +1,281 @@
+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
+ var pdfPreviewSucceeded = false
+ var pdfPreviewPending = false
+ var titleText = ""
+
+ 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" -> {
+ binding.title.hide()
+ pdfPreviewPending = loadPdfThumbnail(media) { success ->
+ pdfPreviewSucceeded = success
+ if (success) {
+ binding.title.hide()
+ } else if (titleText.isNotBlank()) {
+ binding.title.text = titleText
+ binding.title.show()
+ }
+ }
+ }
+
+ 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)
+ if (sbTitle.isNotBlank()) {
+ titleText = sbTitle.toString()
+ }
+
+ if (media?.mimeType == "application/pdf" && pdfPreviewPending) {
+ binding.title.hide()
+ } else if (titleText.isNotBlank() && !(media?.mimeType == "application/pdf" && pdfPreviewSucceeded)) {
+ binding.title.text = titleText
+ 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)
+ }
+ show()
+ }
+ }
+
+ private fun loadPdfThumbnail(media: Media?, onResult: (Boolean) -> Unit): Boolean {
+ if (media == null) {
+ showPdfPlaceholder()
+ onResult(false)
+ return false
+ }
+
+ val uri = media.fileUri
+ val file = media.file
+ if (uri.scheme == "file" && !file.exists()) {
+ showPdfPlaceholder()
+ onResult(false)
+ return false
+ }
+
+ pdfThumbnailJob = PdfThumbnailLoader.loadThumbnail(
+ imageView = binding.image,
+ uri = uri,
+ placeholderRes = R.drawable.ic_pdf,
+ scope = pdfScope,
+ maxDimensionPx = 400,
+ context = mContext,
+ requestKey = media.id,
+ onPlaceholder = { showPdfPlaceholder() },
+ onResult = onResult
+ )
+ return true
+ }
+
+ 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/extensions/BottomSheetExtensions.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/BottomSheetExtensions.kt
new file mode 100644
index 000000000..6a9f4f18e
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/extensions/BottomSheetExtensions.kt
@@ -0,0 +1,57 @@
+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/StringExtensions.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/StringExtensions.kt
index 1b367e5c8..587240540 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/extensions/StringExtensions.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/extensions/StringExtensions.kt
@@ -3,9 +3,9 @@ package net.opendasharchive.openarchive.extensions
import android.graphics.Bitmap
import android.graphics.Color
import android.util.Patterns
-import com.google.zxing.BarcodeFormat
-import com.google.zxing.EncodeHintType
-import com.google.zxing.qrcode.QRCodeWriter
+//import com.google.zxing.BarcodeFormat
+//import com.google.zxing.EncodeHintType
+//import com.google.zxing.qrcode.QRCodeWriter
import timber.log.Timber
import java.io.File
import java.io.InputStream
@@ -22,16 +22,16 @@ import java.nio.charset.StandardCharsets
* @return A Bitmap containing the generated QR code.
*/
fun String.asQRCode(size: Int = 512, quietZone: Int = 4): Bitmap {
- val hints = hashMapOf().apply {
- put(EncodeHintType.MARGIN, quietZone)
- }
+// val hints = hashMapOf().apply {
+// put(EncodeHintType.MARGIN, quietZone)
+// }
- val bits = QRCodeWriter().encode(this, BarcodeFormat.QR_CODE, size, size, hints)
+// val bits = QRCodeWriter().encode(this, BarcodeFormat.QR_CODE, size, size, hints)
return Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).also { bitmap ->
for (x in 0 until size) {
for (y in 0 until size) {
- bitmap.setPixel(x, y, if (bits[x, y]) Color.BLACK else Color.WHITE)
+ //bitmap.setPixel(x, y, if (bits[x, y]) Color.BLACK else Color.WHITE)
}
}
}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/Accordion.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/Accordion.kt
new file mode 100644
index 000000000..3037a3da2
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/Accordion.kt
@@ -0,0 +1,171 @@
+package net.opendasharchive.openarchive.features.core
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterExitState
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+
+@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,
+) {
+ val expanded = state.expanded
+
+ val clickableModifier =
+ if (state.clickable) {
+ Modifier.clickable(
+ enabled = state.enabled,
+ interactionSource = interactionSource,
+ indication = ripple(),
+ onClick = { state.toggle() },
+ )
+ } else {
+ Modifier
+ }
+
+ Column(modifier = modifier) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .semantics {
+ role = Role.Button
+ stateDescription = if (expanded) "Expanded" else "Collapsed"
+ }
+ .then(headerModifier)
+ .then(clickableModifier),
+ ) {
+ headerContent()
+ }
+
+ if (animate) {
+ AnimatedVisibility(
+ visible = expanded,
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ ) {
+ val progress by transition.animateFloat(label = "accordion transition") { state ->
+ if (state == EnterExitState.Visible) 1f else 0f
+ }
+
+ state.updateProgress(progress)
+
+ bodyContent()
+ }
+ } else {
+ if (expanded) {
+ bodyContent()
+ }
+ }
+ }
+}
+
+@Composable
+fun rememberAccordionState(
+ expanded: Boolean = false,
+ enabled: Boolean = true,
+ clickable: Boolean = true,
+ onExpandedChange: ((Boolean) -> Unit)? = null,
+) = remember {
+ AccordionState(expanded, enabled, clickable, onExpandedChange)
+}
+
+class AccordionState(
+ expanded: Boolean = false,
+ var enabled: Boolean = true,
+ var clickable: Boolean = true,
+ var onExpandedChange: ((Boolean) -> Unit)? = null,
+) {
+ var expanded by mutableStateOf(expanded)
+ private set
+
+ var animationProgress by mutableFloatStateOf(0f)
+ private set
+
+ fun toggle() {
+ if (!enabled) return
+ expanded = !expanded
+ onExpandedChange?.invoke(expanded)
+ }
+
+ fun updateProgress(progress: Float) {
+ animationProgress = progress
+ }
+
+ fun collapse() {
+ expanded = false
+ }
+}
+
+@Composable
+fun rememberAccordionGroupState(
+ count: Int,
+ allowMultipleOpen: Boolean = false,
+): AccordionGroupState {
+ return remember { AccordionGroupState(count, allowMultipleOpen) }
+}
+
+class AccordionGroupState(
+ count: Int,
+ private val allowMultipleOpen: Boolean,
+) {
+ private val states = List(count) { AccordionState() }
+ private var openedIndex by mutableIntStateOf(-1)
+
+ fun getState(index: Int): AccordionState {
+ val state = states[index]
+ state.onExpandedChange = { isExpanded ->
+ if (allowMultipleOpen) {
+ if (!isExpanded && openedIndex == index) {
+ openedIndex = -1
+ }
+ } else {
+ if (isExpanded) {
+ openedIndex = index
+ states.forEachIndexed { i, otherState ->
+ if (i != index) otherState.collapse()
+ }
+ } else if (openedIndex == index) {
+ openedIndex = -1
+ }
+ }
+ }
+ return state
+ }
+
+ fun collapseAll() {
+ states.forEach { it.collapse() }
+ openedIndex = -1
+ }
+
+ fun expand(index: Int) {
+ if (index in states.indices) {
+ states[index].toggle()
+ }
+ }
+}
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
index 06d1a955e..d36f872ed 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt
@@ -1,18 +1,62 @@
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 com.google.android.material.appbar.MaterialToolbar
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 org.koin.androidx.viewmodel.ext.android.viewModel
abstract class BaseActivity : AppCompatActivity() {
+ val dialogManager: DialogStateManager by viewModel()
+
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
@@ -57,9 +101,15 @@ abstract class BaseActivity : AppCompatActivity() {
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/BaseButton.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseButton.kt
new file mode 100644
index 000000000..c5d78c228
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseButton.kt
@@ -0,0 +1,150 @@
+package net.opendasharchive.openarchive.features.core
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview
+
+@Composable
+fun BaseButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ backgroundColor: Color = MaterialTheme.colorScheme.tertiary,
+ textColor: Color = MaterialTheme.colorScheme.onPrimary,
+ cornerRadius: Dp = 12.dp,
+) {
+
+ Button(
+ modifier = modifier,
+ onClick = onClick,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = backgroundColor,
+ contentColor = textColor
+ ),
+ shape = RoundedCornerShape(cornerRadius),
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
+ ) {
+ ButtonText(text, color = textColor)
+ }
+}
+
+@Composable
+fun BaseNeutralButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ textColor: Color = MaterialTheme.colorScheme.onPrimary,
+) {
+
+ TextButton(
+ modifier = modifier,
+ onClick = onClick,
+ ) {
+ ButtonText(text, color = textColor)
+ }
+}
+
+@Composable
+fun BaseDestructiveButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ borderColor: Color = MaterialTheme.colorScheme.error,
+ textColor: Color = MaterialTheme.colorScheme.error,
+ cornerRadius: Dp = 12.dp,
+) {
+
+ OutlinedButton(
+ modifier = modifier,
+ onClick = onClick,
+ shape = RoundedCornerShape(cornerRadius),
+ border = BorderStroke(
+ width = 1.dp,
+ color = borderColor
+ ),
+ ) {
+ ButtonText(
+ text,
+ color = textColor
+ )
+ }
+}
+
+
+@Composable
+fun ButtonText(
+ text: String,
+ modifier: Modifier = Modifier,
+ fontSize: TextUnit = 16.sp,
+ fontWeight: FontWeight = FontWeight.SemiBold,
+ color: Color = MaterialTheme.colorScheme.onPrimary
+) {
+ Text(
+ modifier = modifier,
+ text = text,
+ style = MaterialTheme.typography.bodyLarge.copy(
+ fontSize = fontSize,
+ fontWeight = fontWeight,
+ color = color
+ ))
+}
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun CustomButtonPreview() {
+ DefaultBoxPreview {
+
+ BaseButton(
+ text = "Submit",
+ onClick = {},
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun CustomNeutralButtonPreview() {
+ DefaultBoxPreview {
+
+ BaseNeutralButton(
+ text = "Cancel",
+ onClick = {},
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun CustomDestructiveButtonPreview() {
+ DefaultBoxPreview {
+
+ BaseDestructiveButton(
+ text = "Delete",
+ onClick = {},
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
\ 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
new file mode 100644
index 000000000..ad69572e3
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseComposeActivity.kt
@@ -0,0 +1,56 @@
+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 com.google.android.material.appbar.MaterialToolbar
+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 org.koin.androidx.compose.koinViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+abstract class BaseComposeActivity : AppCompatActivity() {
+
+ val dialogManager: DialogStateManager by viewModel()
+
+ companion object {
+ const val EXTRA_DATA_SPACE = "space"
+ }
+
+
+
+ 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()
+ }
+
+ 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)
+ }
+ }
+}
\ 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
new file mode 100644
index 000000000..fa4f865ec
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt
@@ -0,0 +1,62 @@
+package net.opendasharchive.openarchive.features.core
+
+import android.content.Context
+import android.os.Bundle
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.db.SnowbirdError
+import net.opendasharchive.openarchive.extensions.androidViewModel
+import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager
+import net.opendasharchive.openarchive.features.core.dialog.showDialog
+import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity
+import net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupViewModel
+import net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoViewModel
+import net.opendasharchive.openarchive.util.FullScreenOverlayManager
+
+abstract class BaseFragment : Fragment(), ToolbarConfigurable {
+
+ protected val dialogManager: DialogStateManager by activityViewModels()
+
+ val snowbirdGroupViewModel: SnowbirdGroupViewModel by androidViewModel()
+ val snowbirdRepoViewModel: SnowbirdRepoViewModel by androidViewModel()
+
+ 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@BaseFragment)
+ } else {
+ FullScreenOverlayManager.hide()
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ (activity as? SpaceSetupActivity)?.updateToolbarFromFragment(this)
+ }
+}
\ 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
new file mode 100644
index 000000000..a195fbb52
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/ComposeAppBar.kt
@@ -0,0 +1,49 @@
+package net.opendasharchive.openarchive.features.core
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+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 net.opendasharchive.openarchive.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ComposeAppBar(
+ title: String = "",
+ onNavigationAction: () -> Unit = {}
+) {
+ TopAppBar(
+ modifier = Modifier.fillMaxWidth(),
+ title = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onNavigationAction) {
+ Icon(
+ painter = painterResource(R.drawable.ic_arrow_back_ios),
+ contentDescription = null
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.tertiary,
+ navigationIconContentColor = Color.White,
+ titleContentColor = Color.White,
+ actionIconContentColor = 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
new file mode 100644
index 000000000..403a67503
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/ToolbarConfigurable.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.features.core
+
+interface ToolbarConfigurable {
+ fun getToolbarTitle(): String
+ fun getToolbarSubtitle(): String? = null
+ fun shouldShowBackButton(): Boolean = true
+}
\ 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
new file mode 100644
index 000000000..04e5a5f45
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/UiImage.kt
@@ -0,0 +1,54 @@
+package net.opendasharchive.openarchive.features.core
+
+import androidx.annotation.DrawableRes
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+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()
+
+
+ /**
+ * Resolve UiImage into a Composable function that returns an Icon/Image
+ * Instead of directly rendering inside, this provides flexibility for additional customizations.
+ */
+ @Composable
+ fun asIcon(
+ contentDescription: String? = null,
+ tint: Color? = null,
+ modifier: Modifier = Modifier
+ ): @Composable () -> Unit {
+ return {
+ when (this) {
+ is DynamicVector -> Icon(
+ imageVector = vector,
+ contentDescription = contentDescription,
+ modifier = modifier,
+ tint = tint ?: Color.Unspecified
+ )
+
+ is DrawableResource -> Icon(
+ painter = painterResource(id = resId),
+ contentDescription = contentDescription,
+ modifier = modifier,
+ tint = tint ?: Color.Unspecified
+ )
+ }
+ }
+ }
+
+}
+
+
+fun @receiver:DrawableRes Int.asUiImage(): UiImage.DrawableResource {
+ return UiImage.DrawableResource(this)
+}
+
+fun ImageVector.asUiImage(): UiImage {
+ return UiImage.DynamicVector(this)
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..68bf024fe
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/UiText.kt
@@ -0,0 +1,34 @@
+package net.opendasharchive.openarchive.features.core
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+
+sealed class UiText {
+
+ data class DynamicString(val value: String) : UiText()
+ data class StringResource(@StringRes val resId: Int) : UiText()
+
+ fun asString(context: android.content.Context): String {
+ return when (this) {
+ is DynamicString -> value
+ is StringResource -> context.getString(resId)
+ }
+ }
+
+ @Composable
+ fun asString(): String {
+ return when (this) {
+ is DynamicString -> value
+ is StringResource -> stringResource(resId)
+ }
+ }
+}
+
+fun @receiver:StringRes Int.asUiText(): UiText {
+ return UiText.StringResource(this)
+}
+
+fun String.asUiText(): UiText {
+ return UiText.DynamicString(this)
+}
\ 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
new file mode 100644
index 000000000..24991e6fa
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/BaseDialog.kt
@@ -0,0 +1,331 @@
+package net.opendasharchive.openarchive.features.core.dialog
+
+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.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.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
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+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.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.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.ViewModel
+import net.opendasharchive.openarchive.R
+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.asUiImage
+
+@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
+) {
+
+ val (isCheckedState, setCheckedState) = remember { mutableStateOf(false) }
+
+ Dialog(
+ onDismissRequest = { onDismiss.invoke() },
+ properties = DialogProperties(
+ dismissOnBackPress = true,
+ dismissOnClickOutside = true,
+ usePlatformDefaultWidth = true
+ )
+ ) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = 4.dp
+ ),
+ colors = CardDefaults.cardColors(
+ containerColor = backgroundColor
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 18.dp, bottom = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+
+ icon?.let { icon ->
+ icon.asIcon(
+ contentDescription = null,
+ modifier = Modifier.size(30.dp),
+ tint = iconColor ?: Color.Unspecified
+ ).invoke()
+
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ BaseDialogTitle(title)
+
+ Spacer(Modifier.height(4.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ BaseDialogMessage(message)
+ }
+
+ if (hasCheckbox) {
+ Spacer(Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = isCheckedState,
+ onCheckedChange = { isChecked ->
+ setCheckedState(isChecked)
+ onCheckBoxStateChanged.invoke(isChecked)
+ }
+ )
+
+ BaseDialogMessage(checkBoxHint)
+ }
+
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ positiveButton?.let { btn ->
+ Spacer(Modifier.height(4.dp))
+ BaseButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = btn.text.asString(),
+ onClick = {
+ btn.action()
+ onDismiss()
+ })
+ }
+
+ destructiveButton?.let { btn ->
+ Spacer(modifier = Modifier.height(4.dp))
+ BaseDestructiveButton(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ btn.action()
+ onDismiss()
+ },
+ text = btn.text.asString()
+ )
+ }
+
+ neutralButton?.let { btn ->
+ Spacer(modifier = Modifier.height(4.dp))
+ BaseNeutralButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = btn.text.asString(),
+ onClick = {
+ btn.action()
+ onDismiss()
+ })
+ }
+ }
+
+ }
+
+
+ }
+}
+
+@Composable
+fun BaseDialogTitle(
+ text: String,
+ modifier: Modifier = Modifier
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold
+ ),
+ textAlign = TextAlign.Center,
+ modifier = modifier
+ )
+}
+
+@Composable
+fun BaseDialogMessage(
+ text: String,
+ modifier: Modifier = Modifier
+) {
+ Text(
+ text = text,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ modifier = modifier
+ )
+}
+
+
+class DialogStateManager(private val resourceProvider: ResourceProvider) : ViewModel() {
+ private val _dialogConfig = mutableStateOf(null)
+ val dialogConfig: State = _dialogConfig
+
+ fun showDialog(config: DialogConfig) {
+ _dialogConfig.value = config
+ }
+
+ fun dismissDialog() {
+ _dialogConfig.value = null
+ }
+
+ /**
+ * Helper to get the ResourceProvider. This will throw if one wasn’t provided.
+ */
+ fun requireResourceProvider(): ResourceProvider =
+ resourceProvider
+
+
+ override fun onCleared() {
+ super.onCleared()
+ }
+}
+
+@Composable
+fun DialogHost(dialogStateManager: DialogStateManager) {
+ val currentDialog by dialogStateManager.dialogConfig
+
+ currentDialog?.let { config ->
+ BaseDialog(
+ onDismiss = {
+ dialogStateManager.dismissDialog()
+ config.onDismissAction?.invoke()
+ },
+ icon = config.icon,
+ iconColor = config.iconColor,
+ title = config.title.asString(),
+ message = config.message.asString(),
+ positiveButton = config.positiveButton,
+ neutralButton = config.neutralButton,
+ destructiveButton = config.destructiveButton,
+ hasCheckbox = config.showCheckbox,
+ onCheckBoxStateChanged = { config.onCheckboxChanged(it) },
+ checkBoxHint = config.checkboxText?.asString() ?: "",
+ )
+ }
+}
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun BaseDialogPreview() {
+ DefaultBoxPreview {
+
+ BaseDialog(
+ onDismiss = {},
+ icon = Icons.Filled.Check.asUiImage(),
+ 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)),
+ )
+
+ }
+}
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun WarningDialogPreview() {
+ DefaultBoxPreview {
+
+ BaseDialog(
+ onDismiss = {},
+ icon = Icons.Default.Warning.asUiImage(),
+ 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")),
+ hasCheckbox = true,
+ checkBoxHint = "Do not show me this again",
+ onCheckBoxStateChanged = { },
+ )
+ }
+}
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun ErrorDialogPreview() {
+ DefaultBoxPreview {
+
+ BaseDialog(
+ onDismiss = {},
+ icon = Icons.Default.ErrorOutline.asUiImage(),
+ 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")),
+ )
+ }
+}
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun TorWarningDialogPreview() {
+ DefaultBoxPreview {
+
+ BaseDialog(
+ onDismiss = {},
+ icon = Icons.Default.Info.asUiImage(),
+ 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))),
+ )
+ }
+}
\ 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
new file mode 100644
index 000000000..d789007c9
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/DialogConfigBuilder.kt
@@ -0,0 +1,422 @@
+package net.opendasharchive.openarchive.features.core.dialog
+
+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
+import androidx.compose.ui.graphics.vector.ImageVector
+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.UiImage
+import net.opendasharchive.openarchive.features.core.UiText
+
+// --------------------------------------------------------------------
+// 1. Dialog Types
+// --------------------------------------------------------------------
+enum class DialogType {
+ Success, Error, Warning, Info, Custom
+}
+
+// --------------------------------------------------------------------
+// 2. The unified dialog configuration model.
+// --------------------------------------------------------------------
+data class DialogConfig(
+ val type: DialogType,
+ val title: UiText,
+ val message: UiText,
+ val icon: UiImage? = null,
+ val iconColor: Color? = 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 cornerRadius: Dp? = null,
+ val onDismissAction: (() -> Unit)? = null,
+)
+
+// --------------------------------------------------------------------
+// 3. Button configuration
+// --------------------------------------------------------------------
+data class ButtonData(
+ val text: UiText,
+ val action: () -> Unit = {},
+)
+
+// --------------------------------------------------------------------
+// 4. DSL marker and ButtonBuilder DSL
+// --------------------------------------------------------------------
+@DslMarker
+annotation class DialogDsl
+
+@DialogDsl
+class ButtonBuilder {
+ var text: UiText? = null
+ var action: () -> Unit = {}
+
+ fun build(defaultText: UiText): ButtonData =
+ ButtonData(text = text ?: defaultText, action = action)
+}
+
+// --------------------------------------------------------------------
+// 5. DSL Builder for DialogConfig
+// --------------------------------------------------------------------
+@DialogDsl
+class DialogBuilder {
+ // Basic settings
+ var type: DialogType = DialogType.Info
+ var icon: UiImage? = null
+ var title: UiText? = null
+ var message: UiText? = null
+ var iconColor: Color? = null
+ var backgroundColor: Color? = null
+ var cornerRadius: Dp? = null
+
+ // Buttons (initially null)
+ private var _positiveButton: ButtonData? = null
+ private var _neutralButton: ButtonData? = null
+ private var _destructiveButton: ButtonData? = null
+
+ // Checkbox options
+ var showCheckbox: Boolean = false
+ var checkboxText: UiText? = null
+ var onCheckboxChanged: (Boolean) -> Unit = {}
+
+ private var _onDismissAction: (() -> Unit)? = null
+
+ // Button DSL functions – simple and concise
+ fun positiveButton(block: ButtonBuilder.() -> Unit) {
+ _positiveButton = ButtonBuilder().apply(block)
+ .build(defaultText = defaultPositiveTextFor(type))
+ }
+
+ fun neutralButton(block: ButtonBuilder.() -> Unit) {
+ _neutralButton = ButtonBuilder().apply(block)
+ .build(defaultText = defaultNeutralText())
+ }
+
+ fun destructiveButton(block: ButtonBuilder.() -> Unit) {
+ _destructiveButton = ButtonBuilder().apply(block)
+ .build(defaultText = defaultDestructiveText())
+ }
+
+ fun onDismissAction(block: () -> Unit) {
+ _onDismissAction = block
+ }
+
+ // 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)
+ }
+ private fun defaultNeutralText(): UiText = UiText.StringResource(R.string.lbl_Cancel)
+ private fun defaultDestructiveText(): UiText = UiText.StringResource(R.string.lbl_Cancel)
+
+ // -------------------------------
+ // 5a. Compose build() – use MaterialTheme defaults.
+ // -------------------------------
+ @Composable
+ fun build(): DialogConfig {
+
+ 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.Custom -> null
+ }
+ }
+
+ val finalIconColor = iconColor ?: when (type) {
+ DialogType.Error -> MaterialTheme.colorScheme.error
+ DialogType.Warning -> MaterialTheme.colorScheme.tertiary
+ else -> MaterialTheme.colorScheme.onBackground
+ }
+ val finalBackgroundColor = backgroundColor ?: MaterialTheme.colorScheme.surfaceVariant
+ 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("")
+ }
+
+ return DialogConfig(
+ type = type,
+ title = finalTitle,
+ message = message ?: UiText.DynamicString(""),
+ icon = icon,
+ iconColor = finalIconColor,
+ positiveButton = _positiveButton, //?: ButtonData(defaultPositiveTextFor(type)),
+ neutralButton = _neutralButton,
+ destructiveButton = _destructiveButton,
+ showCheckbox = showCheckbox,
+ checkboxText = checkboxText,
+ onCheckboxChanged = onCheckboxChanged,
+ backgroundColor = finalBackgroundColor,
+ cornerRadius = finalCornerRadius,
+ onDismissAction = _onDismissAction
+ )
+ }
+
+ // -------------------------------
+ // 5b. View build() – use ContextCompat to get resource colors.
+ // -------------------------------
+ fun build(resourceProvider: ResourceProvider): DialogConfig {
+
+ 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.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)
+ }
+ val finalBackgroundColor = backgroundColor ?: resourceProvider.getColor(R.color.colorSurface)
+ 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("")
+ }
+
+ return DialogConfig(
+ type = type,
+ title = finalTitle,
+ message = message ?: UiText.DynamicString(""),
+ icon = icon,
+ iconColor = finalIconColor,
+ positiveButton = _positiveButton, //?: ButtonData(defaultPositiveTextFor(type)),
+ neutralButton = _neutralButton,
+ destructiveButton = _destructiveButton,
+ onDismissAction = _onDismissAction,
+ showCheckbox = showCheckbox,
+ checkboxText = checkboxText,
+ onCheckboxChanged = onCheckboxChanged,
+ backgroundColor = finalBackgroundColor,
+ cornerRadius = finalCornerRadius
+ )
+ }
+}
+
+// --------------------------------------------------------------------
+// 6. Extension functions on DialogStateManager for showing dialogs
+// --------------------------------------------------------------------
+
+// --- Compose extension: allows calling showDialog { ... } in a @Composable block.
+@Composable
+fun DialogStateManager.showDialog(block: DialogBuilder.() -> Unit) {
+ val config = DialogBuilder().apply(block).build()
+ showDialog(config)
+}
+
+// --- View extension: pass a Context so that resource colors are used.
+fun DialogStateManager.showDialog(resourceProvider: ResourceProvider = this.requireResourceProvider(), block: DialogBuilder.() -> Unit) {
+ val config = DialogBuilder().apply(block).build(resourceProvider)
+ showDialog(config)
+}
+
+
+// --------------------------------------------------------------------
+// 7. Helper functions for common dialog types
+// --------------------------------------------------------------------
+
+// Compose helper for a success dialog.
+@Composable
+fun DialogStateManager.showSuccessDialog(
+ message: String,
+ title: String = "", // if empty, default title is used
+ onPositive: () -> Unit = {}
+) {
+ showDialog {
+ type = DialogType.Success
+ this.message = UiText.DynamicString(message)
+ if (title.isNotEmpty()) this.title = UiText.DynamicString(title)
+ positiveButton {
+ text = UiText.StringResource(R.string.lbl_ok)
+ action = onPositive
+ }
+ }
+}
+
+// View helper for an info/hint dialog.
+fun DialogStateManager.showSuccessDialog(
+ @StringRes title: Int?,
+ @StringRes message: Int,
+ @StringRes positiveButtonText: Int? = null,
+ icon: UiImage? = null,
+ onDone: () -> Unit = {},
+ onDismissed: () -> Unit = {}
+) {
+ val resourceProvider = this.requireResourceProvider()
+
+ 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)
+ positiveButton {
+ text = UiText.StringResource(positiveButtonText ?: R.string.lbl_got_it)
+ action = onDone
+ }
+ onDismissAction {
+ onDismissed()
+ }
+ }
+}
+
+// View helper for an error dialog.
+fun DialogStateManager.showErrorDialog(
+ message: String,
+ title: String = "",
+ onDismiss: () -> Unit = {}
+) {
+ val resourceProvider = this.requireResourceProvider()
+
+ showDialog(resourceProvider) {
+ type = DialogType.Error
+ this.message = UiText.DynamicString(message)
+ if (title.isNotEmpty()) this.title = UiText.DynamicString(title)
+
+ positiveButton {
+ text = UiText.StringResource(R.string.lbl_ok)
+ action = onDismiss
+ }
+ }
+}
+
+// View helper for an info/hint dialog.
+fun DialogStateManager.showInfoDialog(
+ message: UiText,
+ title: UiText?,
+ icon: UiImage? = null,
+ onDone: () -> Unit = {},
+) {
+ val resourceProvider = this.requireResourceProvider()
+
+ showDialog(resourceProvider) {
+ type = DialogType.Info
+ this.icon = icon
+ this.iconColor = resourceProvider.getColor(R.color.colorTertiary)
+ this.title = title
+ this.message = message
+ positiveButton {
+ text = UiText.StringResource(R.string.lbl_ok)
+ action = onDone
+ }
+ }
+}
+
+// View helper for an info/hint dialog.
+fun DialogStateManager.showWarningDialog(
+ title: UiText?,
+ message: UiText,
+ icon: UiImage? = null,
+ positiveButtonText: UiText? = null,
+ onDone: () -> Unit = {},
+ onCancel: () -> Unit = {}
+) {
+ val resourceProvider = this.requireResourceProvider()
+
+ showDialog(resourceProvider) {
+ type = DialogType.Warning
+ this.title = title
+ this.icon = icon
+ iconColor = resourceProvider.getColor(R.color.colorTertiary)
+ this.message = message
+ positiveButton {
+ text = positiveButtonText ?: UiText.StringResource(R.string.lbl_got_it)
+ action = onDone
+ }
+ destructiveButton {
+ text = UiText.StringResource(R.string.lbl_Cancel)
+ action = onCancel
+ }
+ }
+}
+
+// For Destructive Actions confirmation (Removing folder or server etc)
+fun DialogStateManager.showDestructiveDialog(
+ title: UiText?,
+ message: UiText,
+ icon: UiImage? = null,
+ positiveButtonText: UiText? = null,
+ onDone: () -> Unit = {},
+ onCancel: () -> Unit = {}
+) {
+ val resourceProvider = this.requireResourceProvider()
+
+ showDialog(resourceProvider) {
+ type = DialogType.Warning
+ this.title = title
+ this.icon = icon
+ this.message = message
+ positiveButton {
+ text = positiveButtonText ?: UiText.StringResource(R.string.lbl_got_it)
+ action = onDone
+ }
+ destructiveButton {
+ text = UiText.StringResource(R.string.lbl_Cancel)
+ action = onCancel
+ }
+ }
+}
+
+
+/**
+ * ResourceProvider is an abstraction that lets you look up colors and vector icons
+ * without passing a Context every time.
+ */
+interface ResourceProvider {
+ fun getColor(@ColorRes colorRes: Int): Color
+ fun getVector(@DrawableRes drawableRes: Int): ImageVector?
+}
+
+/**
+ * A simple implementation that uses an Android Context.
+ * You can instantiate this once (for example in your BaseActivity) and pass it
+ * to your DialogStateManager.
+ */
+class DefaultResourceProvider(private val context: Context) : ResourceProvider {
+ override fun getColor(@ColorRes colorRes: Int): Color {
+ // 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/AddFolderActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderActivity.kt
deleted file mode 100644
index 12dd72856..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderActivity.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-package net.opendasharchive.openarchive.features.folders
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.activity.result.ActivityResultLauncher
-import androidx.activity.result.contract.ActivityResultContracts
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.ActivityAddFolderBinding
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity
-import net.opendasharchive.openarchive.util.extensions.hide
-
-class AddFolderActivity : BaseActivity() {
-
- companion object {
- const val EXTRA_FOLDER_ID = "folder_id"
- const val EXTRA_FOLDER_NAME = "folder_name"
- }
-
- private lateinit var mBinding: ActivityAddFolderBinding
- private lateinit var mResultLauncher: ActivityResultLauncher
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- mResultLauncher =
- registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- setResult(RESULT_OK, it.data)
- finish()
- } else {
- val name = it.data?.getStringExtra(EXTRA_FOLDER_NAME)
-
- if (!name.isNullOrBlank()) {
- val i = Intent(this, CreateNewFolderActivity::class.java)
- i.putExtra(EXTRA_FOLDER_NAME, name)
-
- mResultLauncher.launch(i)
- }
- }
- }
-
- mBinding = ActivityAddFolderBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
- setupToolbar(
- title = getString(R.string.add_a_folder),
- showBackButton = true
- )
-
- mBinding.addFolderContainer.setOnClickListener {
- setFolder(false)
- }
-
- mBinding.browseFolderContainer.setOnClickListener {
- setFolder(true)
- }
-
-
- // We cannot browse the Internet Archive. Directly forward to creating a project,
- // as it doesn't make sense to show a one-option menu.
- if (Space.current?.tType == Space.Type.INTERNET_ARCHIVE) {
- mBinding.browseFolderContainer.hide()
-
- finish()
- setFolder(false)
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- finish()
-
- return true
- }
- }
-
- return super.onOptionsItemSelected(item)
- }
-
- private fun setFolder(browse: Boolean) {
- if (Space.current == null) {
- finish()
- startActivity(Intent(this, SpaceSetupActivity::class.java))
-
- return
- }
-
- mResultLauncher.launch(
- Intent(
- this,
- if (browse) BrowseFoldersActivity::class.java else CreateNewFolderActivity::class.java
- )
- )
- }
-}
\ 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
new file mode 100644
index 000000000..2490c39de
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderScreen.kt
@@ -0,0 +1,164 @@
+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
+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.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
+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
+
+@Composable
+fun AddFolderScreen() {
+
+ val navController = LocalView.current.findNavController()
+
+ SaveAppTheme {
+ 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)
+ }
+ )
+ }
+
+}
+
+
+@Composable
+fun AddFolderScreenContent(
+ onCreateFolder: () -> Unit,
+ onBrowseFolders: () -> Unit
+) {
+
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding()
+ .padding(vertical = 24.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Text(
+ text = stringResource(id = R.string.select_where_to_store_your_media),
+ fontSize = 18.sp,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 64.dp)
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ FolderOption(
+ iconRes = R.drawable.ic_create_new_folder,
+ text = stringResource(id = R.string.create_a_new_folder),
+ onClick = onCreateFolder
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ FolderOption(
+ iconRes = R.drawable.ic_browse_existing_folders,
+ text = stringResource(id = R.string.browse_existing_folders),
+ onClick = onBrowseFolders
+ )
+ }
+}
+
+
+@Composable
+fun FolderOption(iconRes: Int, text: String, onClick: () -> Unit) {
+
+ Card(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
+ onClick = onClick,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.background
+ ),
+ shape = RoundedCornerShape(8.dp),
+ border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 24.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.tertiary
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Text(
+ text = text,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.weight(1f)
+ )
+
+ Icon(
+ painter = painterResource(R.drawable.ic_arrow_forward_ios),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+}
+
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun AddFolderScreenPreview() {
+ DefaultScaffoldPreview {
+ AddFolderScreenContent(
+ onCreateFolder = {},
+ 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
new file mode 100644
index 000000000..67dd1470f
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFolderScreen.kt
@@ -0,0 +1,103 @@
+package net.opendasharchive.openarchive.features.folders
+
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+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.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.findNavController
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview
+import org.koin.androidx.compose.koinViewModel
+import java.util.Date
+
+@Composable
+fun BrowseFolderScreen(
+ viewModel: BrowseFoldersViewModel = koinViewModel()
+) {
+
+ val navController = LocalView.current.findNavController()
+
+
+ val folders by viewModel.folders.observeAsState()
+
+
+ BrowseFolderScreenContent(
+ folders = folders ?: emptyList()
+ )
+}
+
+
+@Composable
+fun BrowseFolderScreenContent(
+ folders: List
+) {
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = 24.dp, horizontal = 16.dp),
+ contentPadding = PaddingValues(16.dp)
+ ) {
+
+ items(folders) { folder ->
+ BrowseFolderItem(folder) { }
+ }
+ }
+
+}
+
+@Composable
+fun BrowseFolderItem(
+ folder: Folder,
+ onClick: () -> Unit
+) {
+
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+
+ Icon(painter = painterResource(R.drawable.ic_folder_new), contentDescription = null)
+ Text(folder.name)
+ }
+ }
+}
+
+@Preview
+@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()),
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt
deleted file mode 100644
index a04be3d33..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-package net.opendasharchive.openarchive.features.folders
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import androidx.activity.viewModels
-import androidx.recyclerview.widget.LinearLayoutManager
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.ActivityBrowseFoldersBinding
-import net.opendasharchive.openarchive.db.Project
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.util.extensions.toggle
-import java.util.Date
-
-
-class BrowseFoldersActivity : BaseActivity() {
-
- private lateinit var mBinding: ActivityBrowseFoldersBinding
- private val mViewModel: BrowseFoldersViewModel by viewModels()
-
- private var mSelected: Folder? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- mBinding = ActivityBrowseFoldersBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
- setupToolbar(
- title = getString(R.string.browse_existing),
- showBackButton = true
- )
-
- mBinding.rvFolderList.layoutManager = LinearLayoutManager(this)
-
- val space = Space.current
- if (space != null) mViewModel.getFiles(this, space)
-
- mViewModel.folders.observe(this) {
- mBinding.projectsEmpty.toggle(it.isEmpty())
-
- mBinding.rvFolderList.adapter = BrowseFoldersAdapter(it) { folder ->
- this.mSelected = folder
- invalidateOptionsMenu()
- }
- }
-
- mViewModel.progressBarFlag.observe(this) {
- mBinding.progressBar.toggle(it)
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.menu_browse_folder, menu)
-
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
- val addMenuItem = menu?.findItem(R.id.action_add)
- addMenuItem?.isVisible = mSelected != null
- return super.onPrepareOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- R.id.action_add -> {
- addFolder(mSelected)
- return true
- }
- }
-
- return super.onOptionsItemSelected(item)
- }
-
- 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
-
-// if (license.isNullOrBlank()) {
-// val i = Intent()
-// i.putExtra(AddFolderActivity.EXTRA_FOLDER_NAME, folder.name)
-//
-// setResult(RESULT_CANCELED, i)
-// }
-// else {
- val project = Project(folder.name, Date(), space.id, licenseUrl = license)
- project.save()
-
- val i = Intent()
- i.putExtra(AddFolderActivity.EXTRA_FOLDER_ID, project.id)
-
- setResult(RESULT_OK, i)
-// }
-
- finish()
- }
-}
\ No newline at end of file
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
index cc86d19b1..699fb3ca9 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersAdapter.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersAdapter.kt
@@ -22,7 +22,6 @@ class BrowseFoldersAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FolderViewHolder {
val binding = FolderRowBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- val context = binding.root.context
return FolderViewHolder(binding, onClick)
}
@@ -41,11 +40,9 @@ class BrowseFoldersAdapter(
itemView.isSelected = isSelected
-
- val folderIconRes = if (isSelected) R.drawable.ic_folder_selected else R.drawable.ic_folder_unselected
-
- binding.icon.setImageDrawable(ContextCompat.getDrawable(binding.icon.context, folderIconRes))
-
+ 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)
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
new file mode 100644
index 000000000..431b2c94a
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersFragment.kt
@@ -0,0 +1,142 @@
+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 3eb5945bd..daaecba80 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
@@ -10,14 +10,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.opendasharchive.openarchive.db.Space
import net.opendasharchive.openarchive.services.SaveClient
-import net.opendasharchive.openarchive.services.gdrive.GDriveConduit
import timber.log.Timber
import java.io.IOException
import java.util.Date
+
+
data class Folder(val name: String, val modified: Date)
-class BrowseFoldersViewModel : ViewModel() {
+class BrowseFoldersViewModel(private val context: Context) : ViewModel() {
private val mFolders = MutableLiveData>()
@@ -26,7 +27,7 @@ class BrowseFoldersViewModel : ViewModel() {
val progressBarFlag = MutableLiveData(false)
- fun getFiles(context: Context, space: Space) {
+ fun getFiles(space: Space) {
viewModelScope.launch {
progressBarFlag.value = true
@@ -35,8 +36,6 @@ class BrowseFoldersViewModel : ViewModel() {
when (space.tType) {
Space.Type.WEBDAV -> getWebDavFolders(context, space)
- Space.Type.GDRIVE -> getGDriveFolders(context, space)
-
else -> emptyList()
}
}
@@ -67,8 +66,4 @@ class BrowseFoldersViewModel : ViewModel() {
}
} ?: emptyList()
}
-
- private fun getGDriveFolders(context: Context, space: Space): List {
- return GDriveConduit.listFoldersInRoot(GDriveConduit.getDrive(context))
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderActivity.kt
deleted file mode 100644
index ea7644966..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderActivity.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-package net.opendasharchive.openarchive.features.folders
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.inputmethod.EditorInfo
-import android.widget.Toast
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.ActivityCreateNewFolderBinding
-import net.opendasharchive.openarchive.db.Project
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.features.settings.CcSelector
-import net.opendasharchive.openarchive.util.extensions.hide
-import java.util.Date
-
-class CreateNewFolderActivity : BaseActivity() {
-
- companion object {
- private const val SPECIAL_CHARS = ".*[\\\\/*\\s]"
- }
-
- private lateinit var mBinding: ActivityCreateNewFolderBinding
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- mBinding = ActivityCreateNewFolderBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
- setupToolbar(
- title = "Create Folder",
- showBackButton = true
- )
-
- mBinding.newFolder.setText(intent.getStringExtra(AddFolderActivity.EXTRA_FOLDER_NAME))
-
- mBinding.newFolder.setOnEditorActionListener { _, actionId, _ ->
- if (actionId == EditorInfo.IME_ACTION_DONE) {
- store()
- }
-
- false
- }
-
- if (Space.current?.license != null) {
- mBinding.cc.root.hide()
- }
- else {
- CcSelector.init(mBinding.cc)
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.menu_new_folder, menu)
-
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- finish()
- return true
- }
- R.id.action_done -> {
- store()
- return true
- }
- }
- return super.onOptionsItemSelected(item)
- }
-
- private fun store() {
- val name = mBinding.newFolder.text.toString()
-
- if (name.isBlank()) return
-
- if (name.matches(SPECIAL_CHARS.toRegex())) {
- Toast.makeText(this,
- 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(this, getString(R.string.folder_name_already_exists),
- Toast.LENGTH_LONG).show()
-
- return
- }
-
- val license = space.license ?: CcSelector.get(mBinding.cc)
-
- val project = Project(name, Date(), space.id, licenseUrl = license)
- project.save()
-
- val i = Intent()
- i.putExtra(AddFolderActivity.EXTRA_FOLDER_ID, project.id)
-
- setResult(RESULT_OK, i)
- finish()
- }
-}
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
new file mode 100644
index 000000000..7eb62b578
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderFragment.kt
@@ -0,0 +1,159 @@
+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/internetarchive/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt
index a7c18b17c..f958fad9d 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt
@@ -27,6 +27,6 @@ val internetArchiveModule = module {
factory { InternetArchiveMapper() }
factory { InternetArchiveRepository(get(), get(), get()) }
factory { args -> InternetArchiveLoginUseCase(get(), get(), args.get()) }
- viewModel { args -> InternetArchiveDetailsViewModel(get(), args.get()) }
- viewModel { args -> InternetArchiveLoginViewModel(get(), args.get()) }
+ viewModel { InternetArchiveDetailsViewModel(get(), get()) }
+ viewModel { InternetArchiveLoginViewModel(get()) }
}
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
index 010378921..f3f76913a 100644
--- 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
@@ -1,6 +1,5 @@
package net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt
deleted file mode 100644
index 05b47be51..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-package net.opendasharchive.openarchive.features.internetarchive.presentation
-
-import android.content.Intent
-import android.os.Bundle
-import androidx.activity.compose.setContent
-import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.ui.Modifier
-import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult
-import net.opendasharchive.openarchive.features.internetarchive.presentation.components.getSpace
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.ComposeAppBar
-import net.opendasharchive.openarchive.features.main.MainActivity
-
-@Deprecated("use jetpack compose")
-class InternetArchiveActivity : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- val (space, isNewSpace) = intent.extras.getSpace(Space.Type.INTERNET_ARCHIVE)
-
- setContent {
-
- SaveAppTheme {
- Scaffold(
- topBar = {
- ComposeAppBar(
- title = if (isNewSpace) "Add Internet Archive" else "Edit Internet Archive",
- onNavigationAction = { finish(IAResult.Cancelled) }
- )
- }
- ) { paddingValues ->
- Box(modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)) {
- InternetArchiveScreen(space, isNewSpace) {
- finish(it)
- }
- }
- }
- }
-
-
- }
- }
-
- private fun finish(result: IAResult) {
- when (result) {
- IAResult.Saved -> {
- startActivity(Intent(this, MainActivity::class.java))
- // measureNewBackend(Space.Type.INTERNET_ARCHIVE)
- }
-
- IAResult.Deleted -> Space.navigate(this)
- IAResult.Cancelled -> onBackPressed()
- }
- }
-}
-
-//fun Activity.measureNewBackend(type: Space.Type) {
-// CleanInsightsManager.getConsent(this) {
-// CleanInsightsManager.measureEvent(
-// "backend",
-// "new",
-// type.friendlyName
-// )
-// }
-//}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt
deleted file mode 100644
index 5aeff9b97..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package net.opendasharchive.openarchive.features.internetarchive.presentation
-
-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 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.onboarding.BaseFragment
-import net.opendasharchive.openarchive.features.onboarding.ToolbarConfigurable
-
-@Deprecated("only used for backward compatibility")
-class InternetArchiveFragment : BaseFragment(), ToolbarConfigurable {
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
-
- val (space, isNewSpace) = arguments.getSpace(Space.Type.INTERNET_ARCHIVE)
-
- return ComposeView(requireContext()).apply {
- setContent {
- InternetArchiveScreen(space, isNewSpace) { result ->
- finish(result)
- }
- }
- }
- }
-
- private fun finish(result: IAResult) {
- setFragmentResult(result.value, bundleOf())
-
- if (result == IAResult.Saved) {
- // activity?.measureNewBackend(Space.Type.INTERNET_ARCHIVE)
- }
- }
-
- companion object {
-
- val RESP_SAVED = IAResult.Saved.value
- val RESP_CANCEL = IAResult.Cancelled.value
-
- @JvmStatic
- fun newInstance(args: Bundle) = InternetArchiveFragment().apply {
- arguments = args
- }
-
- @JvmStatic
- fun newInstance(spaceId: Long) = newInstance(args = bundleWithSpaceId(spaceId))
-
- @JvmStatic
- fun newInstance() = newInstance(args = bundleWithNewSpace())
- }
-
- override fun getToolbarTitle() = "Internet Archive"
- override fun shouldShowBackButton() = true
-}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt
deleted file mode 100644
index 4429b4cd0..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package net.opendasharchive.openarchive.features.internetarchive.presentation
-
-import androidx.compose.runtime.Composable
-import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult
-import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsScreen
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginScreen
-
-@Composable
-fun InternetArchiveScreen(space: Space, isNewSpace: Boolean, onFinish: (IAResult) -> Unit) = SaveAppTheme {
- if (isNewSpace) {
- InternetArchiveLoginScreen(space) {
- onFinish(it)
- }
- } else {
- InternetArchiveDetailsScreen(space) {
- onFinish(it)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/BundleExt.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/BundleExt.kt
deleted file mode 100644
index 177d56153..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/BundleExt.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package net.opendasharchive.openarchive.features.internetarchive.presentation.components
-
-import android.os.Bundle
-import androidx.core.os.bundleOf
-import net.opendasharchive.openarchive.db.Space
-
-@Deprecated("only for use with fragments and activities")
-private const val ARG_VAL_NEW_SPACE = -1L
-
-@Deprecated("only for use with fragments and activities")
-private const val ARG_SPACE = "space"
-
-@Deprecated("only for use with fragments and activities")
-enum class IAResult(
- val value: String
-) {
- Saved("ia_fragment_resp_saved"), Deleted("ia_fragment_resp_deleted"), Cancelled("ia_fragment_resp_cancel"),
-}
-
-@Deprecated("only for use with fragments and activities")
-fun bundleWithSpaceId(spaceId: Long) = bundleOf(ARG_SPACE to spaceId)
-
-@Deprecated("only for use with fragments and activities")
-fun bundleWithNewSpace() = bundleOf(ARG_SPACE to ARG_VAL_NEW_SPACE)
-
-@Deprecated("only for use with fragments and activities")
-fun Bundle?.getSpace(type: Space.Type): Pair {
- val mSpaceId = this?.getLong(ARG_SPACE, ARG_VAL_NEW_SPACE) ?: ARG_VAL_NEW_SPACE
-
- val isNewSpace = ARG_VAL_NEW_SPACE == mSpaceId
-
- return if (isNewSpace) {
- Pair(Space(type), true)
- } else {
- Space.get(mSpaceId)?.let { Pair(it, false) } ?: Pair(Space(type), true)
- }
-}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt
index b05abbbc9..fb3b667ea 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt
@@ -1,16 +1,20 @@
package net.opendasharchive.openarchive.features.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
-import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.fillMaxWidth
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.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
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
@@ -18,58 +22,88 @@ 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.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.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.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.DialogStateManager
+import net.opendasharchive.openarchive.features.core.dialog.showDialog
import net.opendasharchive.openarchive.features.internetarchive.presentation.login.CustomTextField
-import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview
+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
-import org.koin.core.parameter.parametersOf
-@Composable
-fun InternetArchiveDetailsScreen(space: Space, onResult: (IAResult) -> Unit) {
- val viewModel: InternetArchiveDetailsViewModel = koinViewModel {
- parametersOf(space)
+
+class InternetArchiveDetailFragment : BaseFragment(), ToolbarConfigurable {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+
+ return ComposeView(requireContext()).apply {
+ setContent {
+ SaveAppTheme {
+ InternetArchiveDetailsScreen(
+ onNavigateBack = {
+ findNavController().popBackStack()
+ }
+ )
+ }
+ }
+ }
}
- val state by viewModel.state.collectAsState()
+ override fun getToolbarTitle() = getString(R.string.internet_archive)
+ override fun shouldShowBackButton() = true
+}
+
+@Composable
+private fun InternetArchiveDetailsScreen(
+ viewModel: InternetArchiveDetailsViewModel = koinViewModel(),
+ onNavigateBack: () -> Unit,
+) {
+
+ val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
- viewModel.actions.collect { action ->
- when (action) {
- is Action.Remove -> onResult(IAResult.Deleted)
- is Action.Cancel -> onResult(IAResult.Cancelled)
- else -> Unit
+ viewModel.events.collect { event ->
+ when (event) {
+ is InternetArchiveDetailsEvent.NavigateBack -> onNavigateBack()
}
}
}
- InternetArchiveDetailsContent(state, viewModel::dispatch)
+ val context = LocalContext.current
+ val activity = context as FragmentActivity
+ val dialogManager = (activity as BaseActivity).dialogManager
+ InternetArchiveDetailsContent(state, viewModel::onAction, dialogManager)
}
@Composable
private fun InternetArchiveDetailsContent(
state: InternetArchiveDetailsState,
- dispatch: Dispatch
+ onAction: (InternetArchiveDetailsAction) -> Unit,
+ dialogManager: DialogStateManager? = null
) {
- var isRemoving by remember { mutableStateOf(false) }
-
+ val scrollState = rememberScrollState()
Box(
modifier = Modifier
@@ -77,11 +111,18 @@ private fun InternetArchiveDetailsContent(
.padding(24.dp)
) {
- Column {
-
- InternetArchiveHeader()
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
- Spacer(Modifier.height(ThemeDimensions.spacing.large))
+ Text(
+ text = stringResource(R.string.account),
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(top = 16.dp)
+ )
CustomTextField(
label = stringResource(R.string.label_username),
@@ -90,8 +131,6 @@ private fun InternetArchiveDetailsContent(
enabled = false,
)
- Spacer(Modifier.height(ThemeDimensions.spacing.medium))
-
CustomTextField(
label = stringResource(R.string.label_screen_name),
value = state.screenName,
@@ -99,95 +138,109 @@ private fun InternetArchiveDetailsContent(
enabled = false,
)
- Spacer(Modifier.height(ThemeDimensions.spacing.medium))
-
-
CustomTextField(
label = stringResource(R.string.label_email),
value = state.email,
onValueChange = {},
enabled = false,
)
- }
- Button(
- modifier = Modifier
- .padding(12.dp)
- .align(Alignment.BottomCenter),
- onClick = {
- isRemoving = true
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = ThemeColors.material.error,
- contentColor = Color.White
+ Text(
+ text = stringResource(R.string.license_label),
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(top = 16.dp)
)
- ) {
- Text(stringResource(id = R.string.menu_delete))
- }
- }
- if (isRemoving) {
- RemoveInternetArchiveDialog(onDismiss = { isRemoving = false }) {
- isRemoving = false
- dispatch(Action.Remove)
- }
- }
-}
+ // Creative Commons License integration - now using ViewModel state
+ 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(InternetArchiveDetailsAction.UpdateCcEnabled(enabled))
+ }
+
+ override fun onAllowRemixChange(allowed: Boolean) {
+ onAction(InternetArchiveDetailsAction.UpdateAllowRemix(allowed))
+ }
+
+ override fun onRequireShareAlikeChange(required: Boolean) {
+ onAction(InternetArchiveDetailsAction.UpdateRequireShareAlike(required))
+ }
+
+ override fun onAllowCommercialChange(allowed: Boolean) {
+ onAction(InternetArchiveDetailsAction.UpdateAllowCommercial(allowed))
+ }
+
+ override fun onCc0EnabledChange(enabled: Boolean) {
+ onAction(InternetArchiveDetailsAction.UpdateCc0Enabled(enabled))
+ }
+ },
+ ccLabelText = stringResource(R.string.set_creative_commons_license_for_all_folders_on_this_server)
+ )
-@Composable
-private fun RemoveInternetArchiveDialog(onDismiss: () -> Unit, onRemove: () -> Unit) {
- AlertDialog(
- onDismissRequest = onDismiss,
- containerColor = ThemeColors.material.surface,
- titleContentColor = ThemeColors.material.onSurface,
- textContentColor = ThemeColors.material.onSurfaceVariant,
- title = {
- Text(text = stringResource(id = R.string.remove_from_app))
- },
- text = { Text(stringResource(id = R.string.are_you_sure_you_want_to_remove_this_server_from_the_app)) },
- dismissButton = {
- TextButton(
- onClick = onDismiss,
- colors = ButtonDefaults.textButtonColors(
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer
- )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 24.dp),
+ horizontalArrangement = Arrangement.Center
) {
- Text(stringResource(id = R.string.action_cancel))
+ 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
+ }
+ }
+ }
+ },
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = colorResource(R.color.red_bg)
+ )
+ ) {
+ Text(
+ stringResource(id = R.string.remove_from_app),
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
}
- }, confirmButton = {
- Button(
- onClick = onRemove,
- shape = RoundedCornerShape(ThemeDimensions.roundedCorner),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer
- )
- ) {
- Text(stringResource(id = R.string.remove))
- }
- })
+ }
+ }
}
@Composable
@Preview(showBackground = true)
-@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
private fun InternetArchiveScreenPreview() {
DefaultScaffoldPreview {
InternetArchiveDetailsContent(
state = InternetArchiveDetailsState(
email = "abc@example.com",
userName = "@abc_name",
- screenName = "ABC Name"
- )
- ) {}
- }
-}
-
-@Composable
-@Preview(showBackground = true)
-@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
-private fun RemoveInternetArchiveDialogPreview() {
- SaveAppTheme {
- RemoveInternetArchiveDialog(onDismiss = { }) {}
+ 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/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt
index d81558c9e..72682a290 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt
@@ -7,5 +7,29 @@ data class InternetArchiveDetailsState(
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,
+ val requireShareAlike: Boolean = false,
+ val allowCommercial: Boolean = false,
+ val cc0Enabled: Boolean = false,
+ val licenseUrl: String? = null
)
+sealed interface InternetArchiveDetailsAction {
+ data object Remove : InternetArchiveDetailsAction
+ data object Cancel : InternetArchiveDetailsAction
+ data class UpdateLicense(val license: String?) : InternetArchiveDetailsAction
+ // Creative Commons License actions
+ data class UpdateCcEnabled(val enabled: Boolean) : InternetArchiveDetailsAction
+ data class UpdateAllowRemix(val allowed: Boolean) : InternetArchiveDetailsAction
+ data class UpdateRequireShareAlike(val required: Boolean) : InternetArchiveDetailsAction
+ data class UpdateAllowCommercial(val allowed: Boolean) : InternetArchiveDetailsAction
+ data class UpdateCc0Enabled(val enabled: Boolean) : InternetArchiveDetailsAction
+}
+
+sealed interface InternetArchiveDetailsEvent {
+ data object NavigateBack : 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/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt
index b5a267c2b..de56c7a8a 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt
@@ -1,54 +1,240 @@
package net.opendasharchive.openarchive.features.internetarchive.presentation.details
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
-import net.opendasharchive.openarchive.core.presentation.StatefulViewModel
+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.db.Space
import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive
-import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsViewModel.Action
+import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager
+import org.koin.core.component.KoinComponent
class InternetArchiveDetailsViewModel(
private val gson: Gson,
- private val space: Space
-) : StatefulViewModel(InternetArchiveDetailsState()) {
+ savedStateHandle: SavedStateHandle
+) : ViewModel(), KoinComponent {
- init {
- dispatch(Action.Load(space))
- }
+ private val args = InternetArchiveDetailFragmentArgs.fromSavedStateHandle(savedStateHandle)
- override fun reduce(state: InternetArchiveDetailsState, action: Action) = when(action) {
- is Action.Loaded -> state.copy(
- userName = action.value.userName,
- email = action.value.email,
- screenName = action.value.screenName
- )
- else -> state
+ private val space: Space = Space.get(args.spaceId)!!
+
+
+ private val _uiState = MutableStateFlow(InternetArchiveDetailsState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = Channel()
+ val events = _events.receiveAsFlow()
+
+ init {
+ loadSpaceData()
}
- override suspend fun effects(state: InternetArchiveDetailsState, action: Action) {
+ fun onAction(action: InternetArchiveDetailsAction) {
when (action) {
- is Action.Remove -> {
- space.delete()
- notify(action)
+ is InternetArchiveDetailsAction.Remove -> {
+ removeSpace()
+ }
+
+ is InternetArchiveDetailsAction.Cancel -> {
+ viewModelScope.launch {
+ _events.send(InternetArchiveDetailsEvent.NavigateBack)
+ }
+ }
+
+ is InternetArchiveDetailsAction.UpdateLicense -> {
+ _uiState.update { it.copy(license = action.license) }
+ updateLicense(action.license)
+ }
+
+ is InternetArchiveDetailsAction.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 InternetArchiveDetailsAction.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 Action.Load -> {
- val metaData = gson.fromJson(space.metaData, InternetArchive.MetaData::class.java)
- dispatch(Action.Loaded(metaData))
+ is InternetArchiveDetailsAction.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 Action.Cancel -> notify(action)
- else -> Unit
+ is InternetArchiveDetailsAction.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 InternetArchiveDetailsAction.UpdateCc0Enabled -> {
+ _uiState.update { currentState ->
+ if (action.enabled) {
+ // When CC0 is enabled, disable all other options
+ currentState.copy(
+ cc0Enabled = true,
+ allowRemix = false,
+ requireShareAlike = false,
+ allowCommercial = false
+ )
+ } else {
+ currentState.copy(cc0Enabled = false)
+ }
+ }
+ generateAndUpdateLicense()
+ }
+ }
+ }
+
+ private fun loadSpaceData() {
+ try {
+ val metaData = if (space.metaData.isNotEmpty()) {
+ gson.fromJson(space.metaData, InternetArchive.MetaData::class.java)
+ } else {
+ // Fallback to space properties if no metaData
+ InternetArchive.MetaData(
+ userName = space.username,
+ screenName = space.displayname.ifEmpty { space.username },
+ email = space.username
+ )
+ }
+ _uiState.update { currentState ->
+ val newState = currentState.copy(
+ userName = metaData.userName,
+ email = metaData.email,
+ screenName = metaData.screenName,
+ license = space.license
+ )
+ initializeLicenseState(newState, space.license)
+ }
+ } 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
+ )
+ _uiState.update { currentState ->
+ val newState = currentState.copy(
+ userName = fallbackMetaData.userName,
+ email = fallbackMetaData.email,
+ screenName = fallbackMetaData.screenName,
+ license = space.license
+ )
+ initializeLicenseState(newState, space.license)
+ }
}
}
- sealed interface Action {
+ private fun removeSpace() {
+ viewModelScope.launch {
+ space.delete()
+ _events.send(InternetArchiveDetailsEvent.NavigateBack)
+ }
+ }
- data class Load(val value: Space) : Action
+ private fun updateLicense(license: String?) {
+ space.license = license
+ space.save()
+ }
- data class Loaded(val value: InternetArchive.MetaData) : Action
+ private fun getInternetArchiveSpace(): Space? {
+ val iaSpaces = Space.get(Space.Type.INTERNET_ARCHIVE)
+ return iaSpaces.firstOrNull()
+ }
- data object Remove : Action
+ 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(
+ ccEnabled = true,
+ cc0Enabled = true,
+ allowRemix = false,
+ allowCommercial = false,
+ requireShareAlike = false,
+ licenseUrl = currentLicense
+ )
+ } else if (isCC && currentLicense != null) {
+ // 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),
+ licenseUrl = currentLicense
+ )
+ } else {
+ // No license
+ currentState.copy(
+ ccEnabled = false,
+ cc0Enabled = false,
+ allowRemix = false, // Changed from true to fix auto-enable bug
+ allowCommercial = false,
+ requireShareAlike = false,
+ licenseUrl = null
+ )
+ }
+ }
- data object Cancel : Action
+ 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) }
+ updateLicense(newLicense)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/InternetArchiveHeader.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveHeader.kt
similarity index 70%
rename from app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/InternetArchiveHeader.kt
rename to app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveHeader.kt
index bd53c8080..88619c3ea 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/InternetArchiveHeader.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveHeader.kt
@@ -1,5 +1,6 @@
-package net.opendasharchive.openarchive.features.internetarchive.presentation.components
+package net.opendasharchive.openarchive.features.internetarchive.presentation.login
+import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -18,8 +19,8 @@ import androidx.compose.ui.graphics.ColorFilter.Companion.tint
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.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.opendasharchive.openarchive.R
@@ -28,7 +29,7 @@ import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors
import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions
@Composable
-fun InternetArchiveHeader(modifier: Modifier = Modifier, titleSize: TextUnit = 18.sp) {
+fun InternetArchiveHeader(modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
@@ -36,28 +37,28 @@ fun InternetArchiveHeader(modifier: Modifier = Modifier, titleSize: TextUnit = 1
) {
Box(
modifier = Modifier
- .size(ThemeDimensions.touchable)
- .background(
- color = ThemeColors.material.surface,
- shape = CircleShape
- )
+ .size(50.dp)
.clip(CircleShape)
+ .background(ThemeColors.material.surfaceDim,)
+ .padding(8.dp),
+ contentAlignment = Alignment.Center
) {
Image(
- modifier = Modifier
- .matchParentSize()
- .padding(11.dp),
+ modifier = Modifier.size(30.dp),
painter = painterResource(id = R.drawable.ic_internet_archive),
- contentDescription = stringResource(
- id = R.string.internet_archive
- ),
+ contentDescription = stringResource(R.string.internet_archive),
colorFilter = tint(colorResource(id = R.color.colorTertiary))
)
}
- Column(modifier = Modifier.padding(start = ThemeDimensions.spacing.medium)) {
+
+ Column(
+ modifier = Modifier.padding(start = ThemeDimensions.spacing.medium, end = ThemeDimensions.spacing.xlarge)
+ ) {
Text(
text = stringResource(id = R.string.internet_archive_description),
- color = ThemeColors.material.onSurfaceVariant
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ color = ThemeColors.material.onSurfaceVariant,
)
}
}
@@ -65,7 +66,7 @@ fun InternetArchiveHeader(modifier: Modifier = Modifier, titleSize: TextUnit = 1
@Composable
@Preview(showBackground = true)
-@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun InternetArchiveHeaderPreview() {
SaveAppTheme {
InternetArchiveHeader()
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
index dd6b636d2..422c92d5c 100644
--- 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
@@ -1,40 +1,52 @@
package net.opendasharchive.openarchive.features.internetarchive.presentation.login
import android.content.Intent
-import android.net.Uri
+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.material.icons.Icons
-import androidx.compose.material.icons.filled.Visibility
-import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.foundation.text.selection.LocalTextSelectionColors
+import androidx.compose.foundation.text.selection.TextSelectionColors
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
+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.material3.TextField
-import androidx.compose.material3.TextFieldDefaults
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
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
@@ -42,9 +54,12 @@ 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.graphics.Color
+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
@@ -54,92 +69,146 @@ 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.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.login.InternetArchiveLoginAction.CreateLogin
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.Login
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdatePassword
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdateUsername
+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
-import org.koin.core.parameter.parametersOf
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction as Action
-@Composable
-fun InternetArchiveLoginScreen(space: Space, onResult: (IAResult) -> Unit) {
- val viewModel: InternetArchiveLoginViewModel = koinViewModel {
- parametersOf(space)
+
+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()
+ }
+ )
+ }
+ }
+ }
}
- val state by viewModel.state.collectAsStateWithLifecycle()
+ 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 launcher =
- rememberLauncherForActivityResult(
- contract = ActivityResultContracts.StartActivityForResult(),
- onResult = {})
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult(),
+ onResult = {}
+ )
LaunchedEffect(Unit) {
- viewModel.actions.collect { action ->
- when (action) {
- is CreateLogin -> launcher.launch(
- Intent(
- Intent.ACTION_VIEW, Uri.parse(CreateLogin.URI)
+ viewModel.events.collect { event ->
+ when (event) {
+ is InternetArchiveLoginEvent.NavigateToSignup -> {
+ launcher.launch(
+ Intent(
+ Intent.ACTION_VIEW, "https://archive.org/account/signup".toUri()
+ )
)
- )
+ }
- is Action.Cancel -> onResult(IAResult.Cancelled)
+ is InternetArchiveLoginEvent.NavigateBack -> onCancel()
- is Action.LoginSuccess -> onResult(IAResult.Saved)
+ is InternetArchiveLoginEvent.LoginSuccess -> {
+ onLoginSuccess(event.spaceId)
+ }
- else -> Unit
+ is InternetArchiveLoginEvent.LoginError -> {
+ // Error handling can be done here if needed
+ }
}
}
}
- InternetArchiveLoginContent(state, viewModel::dispatch)
+ InternetArchiveLoginContent(state, viewModel::onAction)
}
@Composable
private fun InternetArchiveLoginContent(
- state: InternetArchiveLoginState, dispatch: Dispatch
+ state: InternetArchiveLoginState,
+ onAction: (InternetArchiveLoginAction) -> Unit
) {
- // If extra paranoid could pre-hash password in memory
- // and use the store/dispatcher
- var showPassword by rememberSaveable {
- mutableStateOf(false)
- }
-
- LaunchedEffect(state.isLoginError) {
- while (state.isLoginError) {
- delay(3000)
- dispatch(Action.ErrorClear)
- }
- }
+ val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
- .padding(16.dp),
+ .padding(top = 32.dp, bottom = 16.dp)
+ .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
InternetArchiveHeader(
- modifier = Modifier.padding(bottom = ThemeDimensions.spacing.large)
+ 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 = { dispatch(UpdateUsername(it)) },
+ onValueChange = {
+ onAction(InternetArchiveLoginAction.ErrorClear)
+ onAction(InternetArchiveLoginAction.UpdateUsername(it))
+ },
label = stringResource(R.string.label_username),
- placeholder = stringResource(R.string.placeholder_email_or_username),
+ placeholder = stringResource(R.string.prompt_email),
isError = state.isUsernameError,
isLoading = state.isBusy,
keyboardType = KeyboardType.Email,
@@ -150,44 +219,59 @@ private fun InternetArchiveLoginContent(
CustomSecureField(
value = state.password,
- onValueChange = { dispatch(UpdatePassword(it)) },
+ onValueChange = {
+ onAction(InternetArchiveLoginAction.ErrorClear)
+ onAction(InternetArchiveLoginAction.UpdatePassword(it))
+ },
label = stringResource(R.string.label_password),
- placeholder = stringResource(R.string.placeholder_password),
+ placeholder = stringResource(R.string.prompt_password),
isError = state.isPasswordError,
isLoading = state.isBusy,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
)
- Spacer(Modifier.height(ThemeDimensions.spacing.large))
-
- AnimatedVisibility(
- visible = state.isLoginError,
- enter = fadeIn(),
- exit = fadeOut()
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Start
) {
- Text(
- text = stringResource(R.string.error_incorrect_username_or_password),
- color = MaterialTheme.colorScheme.error
- )
+ 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)
- .weight(1f),
+ .padding(top = ThemeDimensions.spacing.small),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.prompt_no_account),
- color = ThemeColors.material.onBackground
+ style = MaterialTheme.typography.bodyLarge.copy( // reuse your themed style
+ color = ThemeColors.material.onBackground,
+ fontWeight = FontWeight.SemiBold
+ )
)
TextButton(
modifier = Modifier.heightIn(ThemeDimensions.touchable),
- onClick = { dispatch(CreateLogin) }) {
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.tertiary
+ ),
+ onClick = { onAction(InternetArchiveLoginAction.CreateLogin) }
+ ) {
Text(
text = stringResource(R.string.label_create_login),
- fontWeight = FontWeight.Bold,
- style = MaterialTheme.typography.bodyLarge
+ style = MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = FontWeight.SemiBold
+ )
)
}
}
@@ -195,31 +279,54 @@ private fun InternetArchiveLoginContent(
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(top = ThemeDimensions.spacing.medium),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceEvenly
+ .weight(1f)
+ .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)),
+ verticalAlignment = Alignment.Bottom,
+ horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
modifier = Modifier
- .weight(1f)
+ .padding(8.dp)
.heightIn(ThemeDimensions.touchable)
- .padding(ThemeDimensions.spacing.small),
+ .weight(1f),
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = colorResource(R.color.colorOnBackground)
+ ),
+ enabled = !state.isBusy,
shape = RoundedCornerShape(ThemeDimensions.roundedCorner),
- onClick = { dispatch(Action.Cancel) }) {
- Text(stringResource(R.string.action_cancel))
+ 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),
- onClick = { dispatch(Login) },
+ 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.label_login))
+ Text(
+ stringResource(R.string.next),
+ style = MaterialTheme.typography.titleLarge
+ )
}
}
}
@@ -227,39 +334,21 @@ private fun InternetArchiveLoginContent(
}
@Composable
+@Preview
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
private fun InternetArchiveLoginPreview() {
DefaultScaffoldPreview {
InternetArchiveLoginContent(
state = InternetArchiveLoginState(
- username = "user@example.org", password = "abc123"
- )
- ) {}
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ComposeAppBar(
- title: String = "Save App",
- onNavigationAction: () -> Unit = {}
-) {
- TopAppBar(
- title = {
- Text(title)
- },
- navigationIcon = {
- IconButton(onClick = onNavigationAction) {
- Icon(painter = painterResource(R.drawable.ic_arrow_back), contentDescription = null)
- }
- },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.primary,
- navigationIconContentColor = Color.White,
- titleContentColor = Color.White,
- actionIconContentColor = Color.White
+ username = "",
+ password = "",
+ isLoginError = true,
+ isPasswordError = true,
+ isUsernameError = true
+ ),
+ onAction = {}
)
- )
+ }
}
@Composable
@@ -274,38 +363,78 @@ fun CustomTextField(
isLoading: Boolean = false,
keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Next,
+ onFocusChange: ((Boolean) -> Unit)? = null,
+ onImeAction: (() -> Unit)? = null,
) {
- TextField(
- modifier = modifier.fillMaxWidth(),
- value = value,
- enabled = !isLoading,
- onValueChange = onValueChange,
- label = {
- Text(label)
- },
- placeholder = {
- placeholder?.let {
- Text(placeholder)
- }
- },
- singleLine = true,
- shape = RoundedCornerShape(ThemeDimensions.roundedCorner),
- keyboardOptions = KeyboardOptions(
- capitalization = KeyboardCapitalization.None,
- autoCorrectEnabled = false,
- keyboardType = keyboardType,
- imeAction = imeAction,
- platformImeOptions = PlatformImeOptions(),
- showKeyboardOnFocus = true,
- hintLocales = null
- ),
- isError = isError,
- colors = TextFieldDefaults.colors(
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- ),
+ 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
@@ -321,20 +450,22 @@ fun CustomSecureField(
imeAction: ImeAction,
) {
- var showPassword by rememberSaveable {
- mutableStateOf(false)
- }
+ var showPassword by rememberSaveable { mutableStateOf(false) }
- TextField(
+ OutlinedTextField(
modifier = modifier.fillMaxWidth(),
value = value,
enabled = !isLoading,
onValueChange = onValueChange,
- label = {
- Text(label)
- },
placeholder = {
- Text(placeholder)
+ Text(
+ text = placeholder,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontStyle = FontStyle.Italic,
+ fontSize = 13.sp,
+ fontFamily = MontserratFontFamily
+ )
+ )
},
singleLine = true,
shape = RoundedCornerShape(ThemeDimensions.roundedCorner),
@@ -349,19 +480,94 @@ fun CustomSecureField(
),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
isError = isError,
- colors = TextFieldDefaults.colors(
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
+ 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(
- imageVector = if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff,
- contentDescription = "show password"
+ 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/internetarchive/presentation/login/InternetArchiveLoginState.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt
index 12bdc3787..e4d3aab29 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt
@@ -15,20 +15,17 @@ data class InternetArchiveLoginState(
)
sealed interface InternetArchiveLoginAction {
+ data class UpdateUsername(val username: String) : InternetArchiveLoginAction
+ data class UpdatePassword(val password: String) : InternetArchiveLoginAction
data object Login : InternetArchiveLoginAction
-
data object Cancel : InternetArchiveLoginAction
-
- data class LoginSuccess(val value: InternetArchive) : InternetArchiveLoginAction
-
- data class LoginError(val value: Throwable) : InternetArchiveLoginAction
-
+ data object CreateLogin : InternetArchiveLoginAction
data object ErrorClear : InternetArchiveLoginAction
+}
- data object CreateLogin : InternetArchiveLoginAction {
- const val URI = "https://archive.org/account/signup"
- }
-
- data class UpdateUsername(val value: String) : InternetArchiveLoginAction
- data class UpdatePassword(val value: String) : 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/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt
index a04aee8a0..e3c540e74 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt
@@ -1,65 +1,93 @@
package net.opendasharchive.openarchive.features.internetarchive.presentation.login
-import net.opendasharchive.openarchive.core.presentation.StatefulViewModel
+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.db.Space
import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.InternetArchiveLoginUseCase
import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.ValidateLoginCredentialsUseCase
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.Cancel
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.CreateLogin
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.ErrorClear
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.Login
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.LoginError
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.LoginSuccess
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdatePassword
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdateUsername
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.parameter.parametersOf
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction as Action
-import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginState as State
class InternetArchiveLoginViewModel(
private val validateLoginCredentials: ValidateLoginCredentialsUseCase,
- private val space: Space,
-) : StatefulViewModel(State()), KoinComponent {
+) : ViewModel(), KoinComponent {
+
+ val space = Space(Space.Type.INTERNET_ARCHIVE)
private val loginUseCase: InternetArchiveLoginUseCase by inject {
parametersOf(space)
}
- override fun reduce(
- state: State,
- action: Action
- ): State = when (action) {
- is UpdateUsername -> state.copy(
- username = action.value,
- isValid = validateLoginCredentials(action.value, state.password)
- )
-
- is UpdatePassword -> state.copy(
- password = action.value,
- isValid = validateLoginCredentials(state.username, action.value)
- )
+ private val _uiState = MutableStateFlow(InternetArchiveLoginState())
+ val uiState: StateFlow = _uiState.asStateFlow()
- is Login -> state.copy(isBusy = true)
- is LoginError -> state.copy(isLoginError = true, isBusy = false)
- is LoginSuccess, is Cancel -> state.copy(isBusy = false)
- is ErrorClear -> state.copy(isLoginError = false)
- else -> state
- }
+ private val _events = Channel()
+ val events = _events.receiveAsFlow()
- override suspend fun effects(state: State, action: Action) {
+ fun onAction(action: InternetArchiveLoginAction) {
when (action) {
- is Login ->
- loginUseCase(state.username, state.password)
- .onSuccess { ia ->
- notify(LoginSuccess(ia))
- }
- .onFailure { dispatch(LoginError(it)) }
+ is InternetArchiveLoginAction.UpdateUsername -> {
+ _uiState.update { currentState ->
+ currentState.copy(
+ username = action.username,
+ isValid = validateLoginCredentials(action.username, currentState.password)
+ )
+ }
+ }
+
+ is InternetArchiveLoginAction.UpdatePassword -> {
+ _uiState.update { currentState ->
+ currentState.copy(
+ password = action.password,
+ isValid = validateLoginCredentials(currentState.username, action.password)
+ )
+ }
+ }
+
+ is InternetArchiveLoginAction.Login -> {
+ performLogin()
+ }
- is CreateLogin, is Cancel -> notify(action)
- else -> Unit
+ is InternetArchiveLoginAction.Cancel -> {
+ viewModelScope.launch {
+ _events.send(InternetArchiveLoginEvent.NavigateBack)
+ }
+ }
+
+ is InternetArchiveLoginAction.CreateLogin -> {
+ viewModelScope.launch {
+ _events.send(InternetArchiveLoginEvent.NavigateToSignup)
+ }
+ }
+
+ is InternetArchiveLoginAction.ErrorClear -> {
+ _uiState.update { it.copy(isLoginError = false, isUsernameError = false, isPasswordError = false) }
+ }
}
}
+ private fun performLogin() {
+ _uiState.update { it.copy(isBusy = true) }
+ viewModelScope.launch {
+ val currentState = _uiState.value
+ loginUseCase.invoke(currentState.username, currentState.password)
+ .onSuccess { ia ->
+ _uiState.update { it.copy(isBusy = false) }
+ _events.send(InternetArchiveLoginEvent.LoginSuccess(space.id))
+ }
+ .onFailure { error ->
+ _uiState.update { it.copy(isLoginError = true, isUsernameError = true, isPasswordError = true, isBusy = false) }
+ _events.send(InternetArchiveLoginEvent.LoginError(error))
+ }
+ }
+ }
}
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
new file mode 100644
index 000000000..61d94621b
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt
@@ -0,0 +1,247 @@
+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.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+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 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 org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import timber.log.Timber
+import kotlin.getValue
+
+class HomeActivity: FragmentActivity() {
+
+ 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
+
+ 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 requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ if (isGranted) {
+ Timber.d("Able to post notifications")
+ } else {
+ Timber.d("Need to explain")
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ installSplashScreen()
+
+ // Perform any intent processing (e.g. deep-links or shared media)
+ handleIntent(intent)
+
+ // Check notification permission (for Android 13+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ checkNotificationPermissions()
+ }
+
+ // 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()
+ }
+ }
+ )
+
+ // 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) }
+ )
+ }
+ }
+
+ /**
+ * 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
+ }
+
+ /**
+ * Refresh UI details for the current project.
+ */
+ private fun refreshCurrentProject() {
+ // TODO: Update your UI state, refresh fragment content, etc.
+ }
+
+ /**
+ * Launch a preview after media import.
+ */
+ private fun previewMedia() {
+ // TODO: Launch your preview activity or update the UI as needed.
+ }
+
+ /**
+ * 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.
+ }
+
+ /**
+ * Navigate to a folder after selection.
+ */
+ private fun navigateToFolder(folderId: Long) {
+ // TODO: Update your navigation or fragment state to display the selected folder.
+ }
+
+ /**
+ * 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
+ )
+
+ }
+ }
+ AddMediaType.GALLERY -> {
+ // Launch the gallery/image picker.
+ Picker.pickMedia(mediaLaunchers.galleryLauncher)
+ }
+ AddMediaType.FILES -> {
+ // Launch the file picker.
+ Picker.pickFiles(mediaLaunchers.filePickerLauncher)
+ }
+ }
+ } 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)
+ }
+ }
+
+ /**
+ * 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)
+ }
+ }
+ // Optionally handle other actions (like ACTION_SEND) here.
+ }
+ }
+ }
+
+ 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
new file mode 100644
index 000000000..2fa7c01ce
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/InAppUpdateCoordinator.kt
@@ -0,0 +1,162 @@
+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
index f3580fddc..ecb4079f6 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt
@@ -1,352 +1,900 @@
package net.opendasharchive.openarchive.features.main
-import android.Manifest
+import android.app.Activity
+import android.content.Context
import android.content.Intent
-import android.content.pm.PackageManager
+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.widget.Toast
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.LinearLayout
+import android.widget.PopupWindow
+import androidx.activity.result.IntentSenderRequest
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.Space
+import net.opendasharchive.openarchive.extensions.getMeasurments
import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.features.folders.AddFolderActivity
+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.settings.FoldersActivity
+import net.opendasharchive.openarchive.features.onboarding.StartDestination
import net.opendasharchive.openarchive.features.settings.passcode.AppConfig
-import net.opendasharchive.openarchive.features.spaces.SpacesActivity
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.AlertHelper
+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.Utility
+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 timber.log.Timber
+import org.koin.androidx.viewmodel.ext.android.viewModel
import java.text.NumberFormat
-class MainActivity : BaseActivity() {
+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 mLastItem: Int = 0
- private var mLastMediaItem: Int = 0
+ 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 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.pager.currentItem
+ get() = binding.contentMain.pager.currentItem
set(value) {
- binding.pager.currentItem = 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(AddFolderActivity.EXTRA_FOLDER_ID, -1))
+ refreshProjects(it.data?.getLongExtra(SpaceSetupActivity.EXTRA_FOLDER_ID, -1))
}
}
- private val folderResultLauncher =
- registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == RESULT_OK) {
- val selectedFolderId = result.data?.getLongExtra("SELECTED_FOLDER_ID", -1)
- if (selectedFolderId != null && selectedFolderId > -1) {
- navigateToFolder(selectedFolderId)
- }
- }
- }
+ private lateinit var permissionManager: PermissionManager
+ private lateinit var reviewManager: ReviewManager
+ private var shouldPromptReview = false
+ private var shouldCheckForUpdate = false
- private val requestPermissionLauncher = registerForActivityResult(
- ActivityResultContracts.RequestPermission()
- ) { isGranted: Boolean ->
- if (isGranted) {
- Timber.d("Able to post notifications")
- } else {
- Timber.d("Need to explain")
- }
- }
+ 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
+ }
+
+
+// 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)
+ shouldPromptReview = InAppReviewHelper.onAppLaunched()
+
+ // Set flag to check for app updates on first onResume
+ shouldCheckForUpdate = Prefs.didCompleteOnboarding
+ }
+
+ override fun onResume() {
+ super.onResume()
+ AppLogger.i("MainActivity onResume is called.......")
+ refreshSpace()
+ mCurrentPagerItem = mSelectedPageIndex
+ importSharedMedia(intent)
+ if (serverListOffset == 0F) {
+ val dims = binding.spaces.getMeasurments()
+ serverListOffset = -dims.second.toFloat()
+ serverListCurOffset = serverListOffset
+ }
+
+ if (Prefs.returnToSettingsAfterRestart) {
+ Prefs.returnToSettingsAfterRestart = false
+ binding.contentMain.pager.post {
+ mCurrentPagerItem = mPagerAdapter.settingsIndex
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 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)
+ 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()
+ }
+ )
+ }
- if (media.isNotEmpty()) {
- preview()
- }
- })
-
- setSupportActionBar(binding.toolbar)
- supportActionBar?.setDisplayHomeAsUpEnabled(false)
- supportActionBar?.title = null
+ private fun setupToolbarAndPager() {
+ setSupportActionBar(binding.contentMain.toolbar)
+ supportActionBar?.apply {
+ setDisplayHomeAsUpEnabled(false)
+ title = null
+ }
mPagerAdapter = ProjectAdapter(supportFragmentManager, lifecycle)
- binding.pager.adapter = mPagerAdapter
-
- binding.pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
- override fun onPageScrolled(
- position: Int, positionOffset: Float,
- positionOffsetPixels: Int
- ) {
- // Do Nothing
- }
+ binding.contentMain.pager.adapter = mPagerAdapter
+ binding.contentMain.pager.registerOnPageChangeCallback(object :
+ ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
- mLastItem = position
+ mSelectedPageIndex = position
if (position < mPagerAdapter.settingsIndex) {
- mLastMediaItem = position
+ 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()
+ }
}
-
- override fun onPageScrollStateChanged(state: Int) {}
})
+ }
- setupBottomNavBar()
-
+ private fun setupNavigationDrawer() {
+ // Drawer listener resets state on close
+ binding.drawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener {
+ override fun onDrawerClosed(drawerView: View) {
+ collapseSpacesList()
+ }
- binding.breadcrumbSpace.setOnClickListener {
- startActivity(Intent(this, SpacesActivity::class.java))
- }
+ override fun onDrawerOpened(drawerView: View) {
+ //
+ }
- binding.breadcrumbFolder.setOnClickListener {
- val selectedSpaceId = getSelectedSpace()?.id
- val selectedProjectId = getSelectedProject()?.id
- val intent = Intent(this, FoldersActivity::class.java)
- intent.putExtra(
- FoldersActivity.EXTRA_SELECTED_SPACE_ID,
- selectedSpaceId
- ) // Pass the selected space ID
- intent.putExtra(
- FoldersActivity.EXTRA_SELECTED_PROJECT_ID,
- selectedProjectId
- ) // Pass the selected project ID
- folderResultLauncher.launch(intent)
- }
+ override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
+ //
+ }
+ override fun onDrawerStateChanged(newState: Int) {
+ //
+ }
+ })
- if (appConfig.snowbirdEnabled) {
+ binding.navigationDrawerHeader.setOnClickListener { toggleSpacesList() }
+ binding.dimOverlay.setOnClickListener { collapseSpacesList() }
- checkNotificationPermissions()
+ mSpaceAdapter = SpaceDrawerAdapter(this)
+ binding.spaces.layoutManager = LinearLayoutManager(this)
+ binding.spaces.adapter = mSpaceAdapter
- SnowbirdBridge.getInstance().initialize()
- val intent = Intent(this, SnowbirdService::class.java)
- startForegroundService(intent)
+ mFolderAdapter = FolderDrawerAdapter(this)
+ binding.folders.layoutManager = LinearLayoutManager(this)
+ binding.folders.adapter = mFolderAdapter
- handleIntent(intent)
+ binding.btnAddFolder.scaleAndTintDrawable(Position.Start, 0.75)
+ binding.btnAddFolder.setOnClickListener {
+ closeDrawer()
+ navigateToAddFolder()
}
+
+ updateCurrentSpaceAtDrawer()
}
- private fun handleIntent(intent: Intent) {
- if (intent.action == Intent.ACTION_VIEW) {
- val uri = intent.data
- if (uri?.scheme == "save-veilid") {
- processUri(uri)
+ private fun setupBottomNavBar() {
+ with(binding.contentMain.bottomNavBar) {
+ onMyMediaClick = {
+ mCurrentPagerItem = mSelectedMediaPageIndex
+ }
+ // TODO: Avoid launching multiple pickers on rapid repeated taps.
+ onAddClick = {
+ if (mSelectedPageIndex >= mPagerAdapter.settingsIndex) {
+ navigateToMediaPageForAdd(AddMediaType.GALLERY)
+ } else {
+ addClicked(AddMediaType.GALLERY)
+ }
+ }
+ onSettingsClick = {
+ mCurrentPagerItem = mPagerAdapter.settingsIndex
+ }
+
+ if (Picker.canPickFiles(this@MainActivity)) {
+ setAddButtonLongClickEnabled()
+ onAddLongClick = {
+ if (mSelectedPageIndex >= mPagerAdapter.settingsIndex) {
+ // Jump back to media page and then open picker.
+ navigateToMediaPageForPicker()
+ } 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 processUri(uri: Uri) {
- val path = uri.path
- val queryParams = uri.queryParameterNames.associateWith { uri.getQueryParameter(it) }
- AppLogger.d("Path: $path, QueryParams: $queryParams")
- }
+ 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 {
+ 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)
+ setFolderBarMode(FolderBarMode.INFO)
+ } else {
+ Snackbar.make(
+ binding.root,
+ getString(R.string.folder_empty_warning),
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+ // Hide the keyboard
+ val imm =
+ binding.contentMain.etFolderName.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.hideSoftInputFromWindow(binding.contentMain.etFolderName.windowToken, 0)
- private fun setupBottomNavBar() {
- binding.bottomNavBar.onMyMediaClick = {
- mCurrentPagerItem = mLastMediaItem
+ // Remove focus from the EditText
+ binding.contentMain.etFolderName.clearFocus()
+
+ true
+ } else false
}
- binding.bottomNavBar.onAddClick = {
- addClicked(AddMediaType.GALLERY)
+ binding.contentMain.btnRemoveSelected.setOnClickListener {
+ showDeleteSelectedMediaConfirmDialog()
}
+ }
- binding.bottomNavBar.onSettingsClick = {
- mCurrentPagerItem = mPagerAdapter.settingsIndex
+ // 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()
}
+ }
- if (Picker.canPickFiles(this)) {
- binding.bottomNavBar.setAddButtonLongClickEnabled()
+ 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
+ }
- binding.bottomNavBar.onAddLongClick = {
- //val addMediaDialogFragment = AddMediaDialogFragment()
- //addMediaDialogFragment.show(supportFragmentManager, addMediaDialogFragment.tag)
+ // Check if there is at least one media item in the selected project
+ val hasMedia = getSelectedProject()?.collections?.any { it.media.isNotEmpty() } == true
- val addMediaBottomSheet =
- ContentPickerFragment { actionType -> addClicked(actionType) }
- addMediaBottomSheet.show(supportFragmentManager, ContentPickerFragment.TAG)
- }
+ // Disable select media if no media in current folder
+ popupBinding.menuFolderBarSelectMedia.isEnabled = hasMedia
+ popupBinding.menuFolderBarSelectMedia.alpha = if (hasMedia) 1.0f else 0.4f
- supportFragmentManager.setFragmentResultListener(
- AddMediaDialogFragment.RESP_TAKE_PHOTO,
- this
- ) { _, _ ->
- addClicked(AddMediaType.CAMERA)
- }
- supportFragmentManager.setFragmentResultListener(
- AddMediaDialogFragment.RESP_PHOTO_GALLERY,
- this
- ) { _, _ ->
- addClicked(AddMediaType.GALLERY)
+ // 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()
}
- supportFragmentManager.setFragmentResultListener(
- AddMediaDialogFragment.RESP_FILES,
- this
- ) { _, _ ->
- addClicked(AddMediaType.FILES)
+ }
+
+ // 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)
}
- private fun updateBottomNavbar(position: Int) {
- binding.bottomNavBar.updateSelectedItem(isSettings = position == mPagerAdapter.settingsIndex)
- if (position == mPagerAdapter.settingsIndex) {
- binding.breadcrumbContainer.hide()
+ fun setSelectionMode(isSelecting: Boolean) {
+ if (isSelecting) {
+ setFolderBarMode(FolderBarMode.SELECTION)
} else {
- // Show the breadcrumb container only if there's any server available
- if (Space.current != null) {
- binding.breadcrumbContainer.show()
- }
+ setFolderBarMode(FolderBarMode.INFO)
}
}
- @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
- override fun onStart() {
- super.onStart()
+ // Update the selected count and show/hide the remove button accordingly
+ fun updateSelectedCount(count: Int) {
+ selectedMediaCount = count
+ updateRemoveButtonVisibility()
+ }
- ProofModeHelper.init(this) {
- // Check for any queued uploads and restart, only after ProofMode is correctly initialized.
- UploadService.startUploadService(this)
+ private fun updateRemoveButtonVisibility() {
+ if (folderBarMode == FolderBarMode.SELECTION) {
+ binding.contentMain.btnRemoveSelected.visibility =
+ if (selectedMediaCount > 0) View.VISIBLE else View.GONE
}
}
- override fun onResume() {
- super.onResume()
+ 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 = {
+
+ }
+ )
+ )
+ )
+ }
- refreshSpace()
+ 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()
+ }
+ }
+ }
+ }
- mCurrentPagerItem = mLastItem
+ private fun getCurrentMediaFragment(): MainMediaFragment? {
+ val currentItem = binding.contentMain.pager.currentItem
+ return supportFragmentManager.findFragmentByTag("f$currentItem") as? MainMediaFragment
+ }
- if (!Prefs.didCompleteOnboarding) {
- startActivity(Intent(this, Onboarding23Activity::class.java))
- }
- importSharedMedia(intent)
+ // ----- 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 navigateToFolder(folderId: Long) {
- val folderIndex = mPagerAdapter.getProjectIndexById(folderId)
- if (folderIndex >= 0) {
- binding.pager.setCurrentItem(folderIndex, true)
- mCurrentPagerItem = folderIndex
+ private fun closeDrawer() {
+ binding.drawerLayout.closeDrawer(binding.drawerContent)
+ }
+ private fun toggleSpacesList() {
+ if (serverListCurOffset == serverListOffset) {
+ expandSpacesList()
} else {
- Toast.makeText(this, "Folder not found", Toast.LENGTH_SHORT).show()
+ collapseSpacesList()
}
}
+ private fun expandSpacesList() {
+ serverListCurOffset = 0f
+ binding.spaceListMore.setImageDrawable(
+ ContextCompat.getDrawable(this, R.drawable.ic_expand_less)
+ )
+ binding.spaces.visibility = View.VISIBLE
+ binding.dimOverlay.visibility = View.VISIBLE
+ binding.spaces.bringToFront()
+ binding.dimOverlay.bringToFront()
+ binding.spaces.animate()
+ .translationY(0f).alpha(1f).setDuration(200)
+ .withStartAction {
+ binding.spacesHeaderSeparator.alpha = 0.3f
+ binding.folders.alpha = 0.3f
+ binding.btnAddFolder.alpha = 0.3f
+ }
+ binding.dimOverlay.animate().alpha(1f).setDuration(200)
+ binding.navigationDrawerHeader.elevation = 8f
+ }
- fun updateAfterDelete(done: Boolean) {
- mMenuDelete?.isVisible = !done
+ private fun collapseSpacesList() {
+ serverListCurOffset = serverListOffset
+ binding.spaceListMore.setImageDrawable(
+ ContextCompat.getDrawable(this, R.drawable.ic_expand_more)
+ )
+
+ binding.spaces.animate()
+ .translationY(serverListOffset).alpha(0f).setDuration(200)
+ .withEndAction {
+ binding.spaces.visibility = View.GONE
+ binding.dimOverlay.visibility = View.GONE
+ binding.spacesHeaderSeparator.alpha = 1f
+ binding.folders.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()
- if (done) refreshCurrentFolderCount()
}
- private fun addFolder() {
- mNewFolderResultLauncher.launch(Intent(this, AddFolderActivity::class.java))
+ // ----- 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 ?: "")
+ 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
+ )
+ }
+ }
+ }
+
+ 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
- currentSpace?.let { space ->
- binding.breadcrumbSpace.text = space.friendlyName
- space.setAvatar(binding.spaceIcon)
- } ?: run {
- binding.breadcrumbContainer.visibility = View.INVISIBLE
+ 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()
}
+ mSpaceAdapter.update(Space.getAll().asSequence().toList())
+ updateCurrentSpaceAtDrawer()
refreshProjects()
+ refreshCurrentProject()
+ updateCurrentFolderVisibility()
+ invalidateOptionsMenu()
}
private fun refreshProjects(setProjectId: Long? = null) {
val projects = Space.current?.projects ?: emptyList()
-
mPagerAdapter.updateData(projects)
-
- binding.pager.adapter = mPagerAdapter
+ binding.contentMain.pager.adapter = mPagerAdapter
setProjectId?.let {
mCurrentPagerItem = mPagerAdapter.getProjectIndexById(it, default = 0)
}
+ mFolderAdapter.update(projects)
}
+
private fun refreshCurrentProject() {
val project = getSelectedProject()
if (project != null) {
- binding.pager.post {
+ binding.contentMain.pager.post {
mPagerAdapter.notifyProjectChanged(project)
}
-
- project.space?.setAvatar(binding.spaceIcon)
- binding.breadcrumbFolder.text = project.description
- binding.breadcrumbFolder.show()
-
+ 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 {
- this@MainActivity.binding.breadcrumbFolder.cloak()
+ binding.contentMain.folderNameArrow.visibility = View.INVISIBLE
+ binding.contentMain.folderName.visibility = View.INVISIBLE
}
-
+ updateCurrentFolderVisibility()
refreshCurrentFolderCount()
}
@@ -354,169 +902,287 @@ class MainActivity : BaseActivity() {
val project = getSelectedProject()
if (project != null) {
- val count = NumberFormat.getInstance().format(
- project.collections.map { it.size }
- .reduceOrNull { acc, count -> acc + count } ?: 0)
+ 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()
+ }
+ }
- binding.folderCount.text = count
- binding.folderCount.show()
+ // ----- 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 {
- binding.folderCount.cloak()
+ 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 ?: if ((imageIntent.clipData?.itemCount
- ?: 0) > 0
- ) imageIntent.clipData?.getItemAt(0)?.uri else null
+ 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) {
- val media = Picker.import(this@MainActivity, getSelectedProject(), uri)
-
+ //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) {
- preview()
+ navigateToPreview()
}
}
}
}
- private fun preview() {
+ private fun navigateToPreview() {
val projectId = getSelectedProject()?.id ?: return
-
PreviewActivity.start(this, projectId)
}
- override fun onRequestPermissionsResult(
- requestCode: Int,
- permissions: Array,
- grantResults: IntArray
- ) {
- super.onRequestPermissionsResult(requestCode, permissions, grantResults)
-
- when (requestCode) {
- 2 -> Picker.pickMedia(this, mediaLaunchers.imagePickerLauncher)
+ // ----- Permissions & Intent Handling -----
+ private fun handleIntent(intent: Intent) {
+ if (intent.action == Intent.ACTION_VIEW) {
+ intent.data?.takeIf { it.scheme == "save-veilid" }?.let { processUri(it) }
}
}
-
- fun getSelectedProject(): Project? {
- return mPagerAdapter.getProject(mCurrentPagerItem)
+ private fun processUri(uri: Uri) {
+ val path = uri.path
+ val queryParams = uri.queryParameterNames.associateWith { uri.getQueryParameter(it) }
+ AppLogger.d("Path: $path, QueryParams: $queryParams")
}
- fun getSelectedSpace(): Space? {
- return Space.current
+ // ----- Overrides -----
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.menu_main, menu)
+ return super.onCreateOptionsMenu(menu)
}
- private fun addClicked(mediaType: AddMediaType) {
+ override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
+ val shouldShowSideMenu =
+ Space.current != null && mCurrentPagerItem != mPagerAdapter.settingsIndex
- // Check if there's any project selected
- if (getSelectedProject() != null) {
+ menu?.findItem(R.id.menu_folders)?.apply {
+ isVisible = shouldShowSideMenu
+ }
- if (Prefs.addMediaHint) {
- when (mediaType) {
- AddMediaType.CAMERA -> Picker.takePhoto(
- this@MainActivity,
- mediaLaunchers.cameraLauncher
- )
+ binding.drawerLayout.setDrawerLockMode(
+ if (shouldShowSideMenu) DrawerLayout.LOCK_MODE_UNLOCKED
+ else DrawerLayout.LOCK_MODE_LOCKED_CLOSED
+ )
- AddMediaType.GALLERY -> Picker.pickMedia(
- this,
- mediaLaunchers.imagePickerLauncher
- )
+ if (!shouldShowSideMenu) {
+ closeDrawer()
+ }
+ return super.onPrepareOptionsMenu(menu)
+ }
- AddMediaType.FILES -> Picker.pickFiles(mediaLaunchers.filePickerLauncher)
- }
- } else {
- AlertHelper.show(
- context = this,
- message = R.string.press_and_hold_options_media_screen_message,
- title = R.string.press_and_hold_options_media_screen_title,
- )
- Prefs.addMediaHint = true
+ 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)
+ }
- } else if (Space.current == null) { // Check if there's any space available
- startActivity(Intent(this, SpaceSetupActivity::class.java))
- } else {
+ override fun getSelectedProject(): Project? {
+ return mPagerAdapter.getProject(mCurrentPagerItem)
+ }
- if (!Prefs.addFolderHintShown) {
- AlertHelper.show(
- this,
- R.string.before_adding_media_create_a_new_folder_first,
- R.string.to_get_started_please_create_a_folder,
- R.drawable.ic_folder,
- buttons = listOf(
- AlertHelper.positiveButton(R.string.add_a_folder) { _, _ ->
- Prefs.addFolderHintShown = true
-
- addFolder()
- },
- AlertHelper.negativeButton(R.string.lbl_Cancel)
- )
- )
- } else {
- addFolder()
- }
- }
+ override fun onSpaceSelected(space: Space) {
+ Space.current = space
+ refreshSpace()
+ updateCurrentSpaceAtDrawer()
+ collapseSpacesList()
+ binding.drawerLayout.closeDrawer(binding.drawerContent)
}
- private fun checkNotificationPermissions() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- when {
- ContextCompat.checkSelfPermission(
- this,
- Manifest.permission.POST_NOTIFICATIONS
- ) == PackageManager.PERMISSION_GRANTED -> {
- Timber.d("We have notifications permissions")
- }
+ override fun onAddNewSpace() {
+ collapseSpacesList()
+ closeDrawer()
+ val intent = Intent(this, SpaceSetupActivity::class.java)
+ startActivity(intent)
+ }
- shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
- showNotificationPermissionRationale()
- }
+ override fun getSelectedSpace(): Space? {
+ val currentSpace = Space.current
+ AppLogger.i("current space requested by adapter... = $currentSpace")
+ return Space.current
+ }
- else -> {
- requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
- }
- }
+ /**
+ * 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)
}
}
- private fun showNotificationPermissionRationale() {
- Utility.showMaterialWarning(this, "Accept!") {
- Timber.d("thing")
+ /**
+ * 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)
+ }
+ }
+ })
+ }
}
}
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ private fun navigateToMediaPageForAdd(action: AddMediaType) {
+ // If already on a media page, perform immediately.
+ if (mSelectedPageIndex < mPagerAdapter.settingsIndex) {
+ addClicked(action)
+ return
+ }
- menuInflater.inflate(R.menu.menu_main, menu)
+ pendingAddAction = action
+ pendingAddScroll = true
+ binding.contentMain.pager.setCurrentItem(mSelectedMediaPageIndex, true)
+ }
- return super.onCreateOptionsMenu(menu)
+ 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)
+ }
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
-// R.id.snowbird_menu -> {
-// val intent = Intent(this, SpaceSetupActivity::class.java)
-// intent.putExtra("snowbird", true)
-// startActivity(intent)
-// true
-// }
- else -> super.onOptionsItemSelected(item)
- }
+ 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
}
}
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
index b64b36473..0ad2ba510 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainMediaFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainMediaFragment.kt
@@ -7,10 +7,8 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
-import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
-import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import kotlinx.coroutines.Dispatchers
@@ -22,18 +20,22 @@ 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.MediaAdapter
-import net.opendasharchive.openarchive.db.MediaViewHolder
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.util.AlertHelper
+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 : Fragment() {
+class MainMediaFragment : BaseFragment() {
companion object {
- private const val COLUMN_COUNT = 4
+ private const val COLUMN_COUNT = 3
private const val ARG_PROJECT_ID = "project_id"
fun newInstance(projectId: Long): MainMediaFragment {
@@ -47,11 +49,17 @@ class MainMediaFragment : Fragment() {
}
}
- private var mAdapters = HashMap()
+ 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() {
@@ -80,11 +88,6 @@ class MainMediaFragment : Fragment() {
}
}
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
override fun onStart() {
super.onStart()
BroadcastManager.register(requireContext(), mMessageReceiver)
@@ -95,23 +98,9 @@ class MainMediaFragment : Fragment() {
BroadcastManager.unregister(requireContext(), mMessageReceiver)
}
- @Deprecated("Deprecated in Java")
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.menu_delete -> {
- AlertHelper.show(
- requireContext(), R.string.confirm_remove_media, null, buttons = listOf(
- AlertHelper.positiveButton(R.string.remove) { _, _ ->
- deleteSelected()
- },
- AlertHelper.negativeButton()
- )
- )
- true
- }
-
- else -> super.onOptionsItemSelected(item)
- }
+ override fun onPause() {
+ cancelSelection()
+ super.onPause()
}
override fun onCreateView(
@@ -128,30 +117,35 @@ class MainMediaFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ viewModel.log("MainMediaFragment onCreateView called for project Id $mProjectId")
- if (mProjectId == -1L) {
- val space = Space.current
- val text: String = if (space != null) {
- val projects = space.projects
- if (projects.isNotEmpty()) {
- getString(R.string.tap_to_add)
- } else {
- "Tap the button below to add media folder."
- }
+ val space = Space.current
+ val text: String = if (space != null) {
+ val projects = space.projects
+ if (projects.isNotEmpty()) {
+ getString(R.string.tap_to_add)
} else {
- "Tap the button below to add media server."
+ getString(R.string.tap_to_add_folder)
}
+ } else {
+ getString(R.string.tap_to_add_server)
+ }
+
+ binding.tvWelcomeDescr.text = text
- 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) {
@@ -209,6 +203,21 @@ class MainMediaFragment : Fragment() {
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()
@@ -225,24 +234,30 @@ class MainMediaFragment : Fragment() {
}
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.setHasFixedSize(true)
holder.recyclerView.layoutManager = GridLayoutManager(activity, COLUMN_COUNT)
+ holder.recyclerView.isNestedScrollingEnabled = false
holder.setHeader(collection, media)
- val mediaAdapter = MediaAdapter(
- requireActivity(),
- { MediaViewHolder.Box(it) },
- media,
- holder.recyclerView
- ) {
- (activity as? MainActivity)?.updateAfterDelete(mAdapters.values.firstOrNull { it.selecting } == null)
- }
+ 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
@@ -251,6 +266,66 @@ class MainMediaFragment : Fragment() {
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)
@@ -266,4 +341,16 @@ class MainMediaFragment : Fragment() {
}
}
}
+
+ 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
new file mode 100644
index 000000000..fba870c12
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainViewModel.kt
@@ -0,0 +1,38 @@
+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/QRScannerActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/QRScannerActivity.kt
index 59ae35226..5495f775f 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/main/QRScannerActivity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/QRScannerActivity.kt
@@ -1,13 +1,13 @@
package net.opendasharchive.openarchive.features.main
import android.os.Bundle
-import com.journeyapps.barcodescanner.CaptureActivity
+//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
+//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/SectionViewHolder.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/SectionViewHolder.kt
index 1cf0d1b50..60f34bfff 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/main/SectionViewHolder.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/SectionViewHolder.kt
@@ -6,6 +6,9 @@ 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
@@ -13,11 +16,16 @@ data class SectionViewHolder(
companion object {
- private val mNf
- get() = NumberFormat.getIntegerInstance()
+ private val mNf = NumberFormat.getIntegerInstance()
- private val mDf
- get() = DateFormat.getDateTimeInstance()
+ 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")
+ }
}
@@ -33,24 +41,15 @@ data class SectionViewHolder(
val recyclerView
get() = binding.recyclerView
- fun setHeader(
- collection: Collection,
- media: List
- ) {
+ 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) mDf.format(uploadDate) else "Ready to upload"
+ 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
index a3a147cde..b9ce6e83e 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClient.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/UnixSocketClient.kt
@@ -5,7 +5,6 @@ import android.net.LocalSocket
import android.net.LocalSocketAddress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import net.opendasharchive.openarchive.db.SerializableMarker
import net.opendasharchive.openarchive.services.snowbird.service.HttpLikeException
@@ -34,8 +33,7 @@ enum class HttpMethod(val value: String) {
//}
class UnixSocketClient(context: Context) {
- val socketPath: String = "/data/user/0/net.opendasharchive.openarchive.debug/files/rust_server.sock"
-// val socketPath: String = File(context.filesDir, "rust_server.sock").absolutePath
+ val socketPath: String = File(context.filesDir, "rust_server.sock").absolutePath
val json = Json { ignoreUnknownKeys = true }
suspend inline fun sendRequest(
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
new file mode 100644
index 000000000..6e0686765
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/FolderDrawerAdapter.kt
@@ -0,0 +1,104 @@
+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
new file mode 100644
index 000000000..2300c6576
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaAdapter.kt
@@ -0,0 +1,324 @@
+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
new file mode 100644
index 000000000..35d177347
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaViewHolder.kt
@@ -0,0 +1,351 @@
+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
new file mode 100644
index 000000000..9abcddc4f
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/SpaceDrawerAdapter.kt
@@ -0,0 +1,157 @@
+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?
+}
+
+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()
+ }
+
+ companion object {
+
+ private const val VIEW_TYPE_SPACE = 0
+ private const val VIEW_TYPE_ADD = 1
+
+ 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()
+ }
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return when (getItem(position)) {
+ is SpaceItem.SpaceItemData -> VIEW_TYPE_SPACE
+ is SpaceItem.AddSpaceItem -> VIEW_TYPE_ADD
+ 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)
+ else -> throw IllegalArgumentException("Invalid view type")
+ }
+ }
+
+ override fun onBindViewHolder(holder: ItemTypeViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ fun update(spaces: List) {
+ val items = spaces.map { SpaceItem.SpaceItemData(it) } + 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/HomeScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeScreen.kt
new file mode 100644
index 000000000..044e5c852
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/HomeScreen.kt
@@ -0,0 +1,384 @@
+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.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.layout.size
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.DrawerValue
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.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 net.opendasharchive.openarchive.features.main.ui.components.HomeAppBar
+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.features.settings.SettingsScreen
+import org.koin.androidx.compose.koinViewModel
+import kotlin.math.max
+
+
+@Serializable
+data object HomeRoute
+
+@Serializable
+data object MediaCacheRoute
+
+@Composable
+fun SaveNavGraph(
+ context: Context,
+ viewModel: HomeViewModel = koinViewModel(),
+ onExit: () -> Unit,
+ onNewFolder: () -> Unit,
+ onFolderSelected: (Long) -> Unit,
+ onAddMedia: (AddMediaType) -> Unit
+) {
+ val navController = rememberNavController()
+
+ SaveAppTheme {
+
+ NavHost(
+ navController = navController,
+ startDestination = HomeRoute
+ ) {
+
+ composable {
+ HomeScreen(
+ viewModel = viewModel,
+ onExit = onExit,
+ onNewFolder = onNewFolder,
+ onFolderSelected = onFolderSelected,
+ onAddMedia = onAddMedia,
+ onNavigateToCache = {
+ navController.navigate(MediaCacheRoute)
+ }
+ )
+ }
+
+ composable {
+ MediaCacheScreen(context) {
+ navController.popBackStack()
+ }
+ }
+
+ }
+ }
+}
+
+@Composable
+fun HomeScreen(
+ viewModel: HomeViewModel = koinViewModel(),
+ onExit: () -> Unit,
+ onNewFolder: () -> Unit,
+ onFolderSelected: (Long) -> Unit,
+ onAddMedia: (AddMediaType) -> Unit,
+ onNavigateToCache: () -> Unit
+) {
+
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+ HomeScreenContent(
+ onExit = onExit,
+ state = state,
+ onAction = viewModel::onAction,
+ onNavigateToCache = onNavigateToCache
+ )
+
+
+}
+
+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) }
+ }
+
+ is HomeScreenAction.AddMediaClicked -> TODO()
+ }
+ }
+
+ 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()
+ )
+ }
+ }
+ }
+
+}
+
+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()
+)
+
+@Composable
+fun HomeScreenContent(
+ onExit: () -> Unit,
+ state: HomeScreenState,
+ onAction: (HomeScreenAction) -> Unit,
+ onNavigateToCache: () -> Unit = {}
+) {
+
+ val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
+ val scope = rememberCoroutineScope()
+
+ val projects = state.projectsForSelectedSpace
+ val totalPages = max(1, projects.size) + 1
+ val pagerState = rememberPagerState(initialPage = 0) { totalPages }
+
+ val currentProjectIndex = state.selectedProject?.let { selected ->
+ projects.indexOfFirst { it.id == selected.id }.takeIf { it >= 0 } ?: 0
+ } ?: 0
+
+ // 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))
+ }
+ }
+
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+
+ ModalNavigationDrawer(
+ drawerState = drawerState,
+ gesturesEnabled = true,
+ drawerContent = {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+ MainDrawerContent(
+ selectedSpace = state.selectedSpace,
+ spaceList = state.allSpaces
+ )
+ }
+ }
+ ) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+
+ Scaffold(
+ topBar = {
+ HomeAppBar(
+ onExit = onExit,
+ openDrawer = {
+ scope.launch {
+ drawerState.open()
+ }
+ }
+ )
+ },
+
+ 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) }
+ }
+ },
+ onSettingsClick = {
+ // Scroll to the last page if not already there.
+ if (pagerState.currentPage != totalPages - 1) {
+ scope.launch { pagerState.scrollToPage(totalPages - 1) }
+ }
+ }
+ )
+ }
+
+ ) { 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
+ ) {
+ 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
+ )
+ Text("Edit")
+ }
+ }
+ }
+
+ }
+
+
+
+ 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
+ )
+ }
+
+ else -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("Unexpected page index")
+ }
+ } // This should never be reached
+ }
+ }
+
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun MainContentPreview() {
+ SaveAppTheme {
+
+ HomeScreenContent(
+ onExit = {},
+ state = HomeScreenState(),
+ 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/MainMediaScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt
new file mode 100644
index 000000000..a68559606
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt
@@ -0,0 +1,407 @@
+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.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.detectTapGestures
+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.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.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.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.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.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+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
+
+/**
+ * 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.)
+ */
+data class CollectionSection(
+ val collection: Collection,
+ val media: SnapshotStateList = mutableStateListOf().apply { addAll(collection.media) }
+)
+
+@Composable
+fun MainMediaScreen(
+ projectId: Long,
+) {
+ 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) }
+ }
+ }
+ }
+ }
+
+ BroadcastManager.register(context, receiver)
+ onDispose { BroadcastManager.unregister(context, receiver) }
+ }
+
+ LaunchedEffect(projectId) {
+ refreshSections(projectId, sections)
+ }
+
+ 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)
+ }
+ )
+ }
+ }
+ }
+
+ // 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:
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val dateText = section.collection.uploadDate?.toGMTString() ?: "Unknown Date"
+ Text(text = dateText, style = MaterialTheme.typography.titleMedium)
+ Text(
+ text = "${section.media.size} items",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.Gray
+ )
+ }
+}
+
+/** Renders one collection section: header and grid of media items. */
+@Composable
+fun CollectionSectionView(
+ section: CollectionSection,
+ onMediaClick: (Media) -> Unit,
+ onMediaLongPress: (Media) -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.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)
+ rows.forEach { rowItems ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ rowItems.forEach { media ->
+ MediaItemView(
+ media = media,
+ isSelected = media.selected,
+ onClick = { onMediaClick(media) },
+ onLongClick = { onMediaLongPress(media) },
+ modifier = Modifier
+ .weight(1f)
+ .aspectRatio(1f)
+ )
+ }
+ // Fill out the remaining cells (if any) in this row
+ if (rowItems.size < 4) {
+ repeat(4 - rowItems.size) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ }
+}
+
+/** Renders one media item as an image filling its box. */
+@Composable
+fun MediaItemView(
+ media: Media,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ 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() }
+ )
+ }
+ ) {
+ AsyncImage(
+ model = media.fileUri,
+ contentDescription = media.title,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ when (media.sStatus) {
+ Media.Status.Uploading -> UploadProgress(media.uploadPercentage ?: 0)
+ Media.Status.Error -> ErrorIndicator()
+ else -> Unit
+ }
+ }
+}
+
+
+@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)
+ )
+ }
+}
+
+@Composable
+fun ErrorIndicator() {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Red.copy(alpha = 0.6f)),
+ 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)
+ }
+ }
+}
+
+
+/** 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
+ }
+ // 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()
+}
+
+/** 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)
+ }
+ }
+ // 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
+ }
+}
+
+/** 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 */
+ }
+ }
+}
\ 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
new file mode 100644
index 000000000..30e0cb08e
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaViewModel.kt
@@ -0,0 +1,8 @@
+package net.opendasharchive.openarchive.features.main.ui
+
+import androidx.lifecycle.ViewModel
+
+class MainMediaViewModel : ViewModel() {
+
+}
+
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
new file mode 100644
index 000000000..b36287c19
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MediaCacheScreen.kt
@@ -0,0 +1,178 @@
+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.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
+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.graphics.painter.Painter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.crossfade
+import coil3.size.Scale
+import java.io.File
+
+// MediaFile Data Class
+data class MediaFile(
+ val name: String,
+ val path: String,
+ val isDirectory: Boolean,
+ val type: FileType
+)
+
+// Enum to represent different file types
+enum class FileType {
+ IMAGE, VIDEO, PDF, FOLDER, UNKNOWN
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MediaCacheScreen(context: Context, onNavigateBack: () -> Unit) {
+ val cacheDir = context.cacheDir
+ val files = remember { cacheDir.listFiles()?.map { it.toMediaFile() } ?: emptyList() }
+
+ Scaffold(
+topBar ={
+ TopAppBar(
+ title = { Text("Media Cache") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = null)
+ }
+ }
+ )
+}
+
+ ) { paddingValues ->
+
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 100.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White),
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(files) { file ->
+ CacheFileItem(file)
+ }
+ }
+ }
+ }
+
+}
+
+@Composable
+fun CacheFileItem(file: MediaFile) {
+ val context = LocalContext.current
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .background(Color.LightGray)
+ .padding(8.dp)
+ ) {
+ when {
+ file.isDirectory -> {
+ Icon(
+ imageVector = Icons.Default.Folder,
+ contentDescription = file.name,
+ modifier = Modifier.size(48.dp)
+ )
+ }
+
+ file.type == FileType.IMAGE -> {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(File(file.path))
+ .scale(Scale.FILL)
+ .crossfade(true)
+ .build(),
+ contentDescription = file.name,
+ modifier = Modifier.size(64.dp),
+ contentScale = ContentScale.Crop
+ )
+ }
+
+ file.type == FileType.VIDEO -> {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(File(file.path))
+ .scale(Scale.FIT)
+ .crossfade(true)
+ .build(),
+ contentDescription = file.name,
+ modifier = Modifier.size(64.dp),
+ contentScale = ContentScale.Crop
+ )
+ }
+
+ file.type == FileType.PDF -> {
+ Icon(
+ imageVector = Icons.Default.Description,
+ contentDescription = file.name,
+ modifier = Modifier.size(48.dp)
+ )
+ }
+
+ else -> {
+ Icon(
+ imageVector = Icons.Default.QuestionMark,
+ contentDescription = file.name,
+ modifier = Modifier.size(48.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = file.name,
+ maxLines = 1,
+ modifier = Modifier.widthIn(max = 80.dp)
+ )
+ }
+}
+
+
+fun File.toMediaFile(): MediaFile {
+ val fileType = when {
+ isDirectory -> FileType.FOLDER
+ name.endsWith(".jpg", true) || name.endsWith(".jpeg", true) || name.endsWith(".png", true) -> FileType.IMAGE
+ name.endsWith(".mp4", true) || name.endsWith(".mkv", true) || name.endsWith(".avi", true) -> FileType.VIDEO
+ name.endsWith(".pdf", true) -> FileType.PDF
+ else -> FileType.UNKNOWN
+ }
+ return MediaFile(name = name, path = absolutePath, isDirectory = isDirectory, type = fileType)
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..04a2cae86
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/ExpandableSpaceList.kt
@@ -0,0 +1,168 @@
+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/FolderOptionsPopup.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/FolderOptionsPopup.kt
new file mode 100644
index 000000000..499e244c1
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/FolderOptionsPopup.kt
@@ -0,0 +1,61 @@
+package net.opendasharchive.openarchive.features.main.ui.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.opendasharchive.openarchive.R
+
+@Composable
+fun FolderOptionsPopup(
+ expanded: Boolean = false,
+ onDismissRequest: () -> Unit,
+ onRenameFolder: () -> Unit,
+ onSelectMedia: () -> Unit,
+ onRemoveFolder: () -> Unit
+) {
+
+ DropdownMenu(
+ modifier = Modifier,
+ expanded = expanded,
+ onDismissRequest = onDismissRequest
+ ) {
+
+ Column(modifier = Modifier.padding(8.dp)) {
+
+ DropdownMenuItem(
+ onClick = onRenameFolder,
+ text = { Text(stringResource(R.string.lbl_rename_folder)) }
+ )
+ DropdownMenuItem(
+ onClick = onSelectMedia,
+ text = { Text(stringResource(R.string.lbl_select_media)) }
+ )
+ DropdownMenuItem(
+ onClick = onRemoveFolder,
+ text = { Text(stringResource(R.string.lbl_remove_folder)) }
+ )
+ }
+ }
+
+}
+
+@Preview
+@Composable
+private fun FolderOptionsPopupPreview() {
+
+ FolderOptionsPopup(
+ expanded = true,
+ onDismissRequest = {},
+ onRenameFolder = {},
+ onSelectMedia = {},
+ onRemoveFolder = {}
+ )
+
+}
\ 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
new file mode 100644
index 000000000..3d092349e
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/HomeAppBar.kt
@@ -0,0 +1,79 @@
+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
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import net.opendasharchive.openarchive.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeAppBar(
+ openDrawer: () -> Unit,
+ onExit: () -> Unit
+) {
+
+ TopAppBar(
+ title = {
+ Image(
+ modifier = Modifier
+ .size(64.dp)
+ .clickable {
+ onExit()
+ },
+ painter = painterResource(R.drawable.savelogo),
+ contentDescription = "Save Logo",
+ colorFilter = ColorFilter.tint(colorResource(R.color.colorOnPrimary))
+ )
+ },
+ actions = {
+
+ AnimatedVisibility(
+ visible = false
+ ) {
+ IconButton(
+ onClick = {}
+ ) {
+ Icon(
+ Icons.Outlined.Delete,
+ contentDescription = null
+ )
+ }
+
+ }
+
+ 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)
+ )
+ )
+}
\ 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
new file mode 100644
index 000000000..f257f95ef
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainBottomBar.kt
@@ -0,0 +1,98 @@
+package net.opendasharchive.openarchive.features.main.ui.components
+
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+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.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import net.opendasharchive.openarchive.R
+
+@Composable
+fun MainBottomBar(
+ isSettings: Boolean,
+ onMyMediaClick: () -> Unit,
+ onSettingsClick: () -> Unit,
+ onAddMediaClick: () -> Unit
+) {
+ NavigationBar(
+ modifier = Modifier.fillMaxWidth(),
+ containerColor = MaterialTheme.colorScheme.primary
+ ) {
+
+ BottomNavMenuItem(
+ isSelected = !isSettings,
+ onClick = onMyMediaClick,
+ selectedIcon = Icons.Default.PermMedia,
+ unSelectedIcon = Icons.Outlined.PermMedia,
+ text = stringResource(R.string.my_media)
+ )
+
+ 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
+ )
+ }
+
+ BottomNavMenuItem(
+ isSelected = isSettings,
+ onClick = onSettingsClick,
+ selectedIcon = Icons.Default.Settings,
+ unSelectedIcon = Icons.Outlined.Settings,
+ text = stringResource(R.string.action_settings)
+ )
+
+ }
+}
+
+@Composable
+fun RowScope.BottomNavMenuItem(
+ selectedIcon: ImageVector,
+ unSelectedIcon: ImageVector,
+ isSelected: Boolean,
+ text: String,
+ onClick: () -> Unit
+) {
+ 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
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
new file mode 100644
index 000000000..c96f535a9
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/MainDrawerContent.kt
@@ -0,0 +1,219 @@
+package net.opendasharchive.openarchive.features.main.ui.components
+
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+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.DrawerDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalDrawerSheet
+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.platform.LocalConfiguration
+import androidx.compose.ui.tooling.preview.Preview
+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 net.opendasharchive.openarchive.features.core.rememberAccordionState
+
+@Composable
+fun MainDrawerContent(
+ selectedSpace: Space? = null,
+ spaceList: List = emptyList()
+) {
+
+ 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
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(vertical = 24.dp),
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+
+ Column(
+ modifier = Modifier
+ .padding(vertical = 24.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+
+
+ 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)
+ ) {
+ 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")
+ }
+
+ 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")
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+
+ Icon(
+ imageVector = Icons.Outlined.Folder,
+ tint = MaterialTheme.colorScheme.onBackground,
+ contentDescription = null
+ )
+ 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")
+ }
+
+ 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")
+ }
+ }
+
+
+
+ Spacer(Modifier.height(12.dp))
+
+
+ }
+
+
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
+
+ Button(
+ modifier = Modifier.fillMaxWidth(0.7f),
+ shape = RoundedCornerShape(8f),
+ onClick = {
+
+ }
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(imageVector = Icons.Default.Add, contentDescription = null)
+ Text("New Folder")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MainDrawerFolderListItem(
+ project: Project,
+ isSelected: Boolean = false,
+ onSelected: () -> Unit
+) {
+ 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")
+ }
+}
+
+@Preview
+@Composable
+private fun MainDrawerContentPreview() {
+ DefaultScaffoldPreview {
+ MainDrawerContent()
+ }
+}
\ 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
index 1ea462a82..4cb98d75a 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/ContentPickerFragment.kt
@@ -1,5 +1,6 @@
package net.opendasharchive.openarchive.features.media
+import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -15,6 +16,7 @@ class ContentPickerFragment(private val onMediaPicked: (AddMediaType) -> Unit):
companion object {
const val TAG = "ModalBottomSheet-ContentPickerFragment"
+ const val KEY_DISMISS = "ContentPickerFragment.Dismiss"
}
override fun onCreateView(
@@ -43,4 +45,9 @@ class ContentPickerFragment(private val onMediaPicked: (AddMediaType) -> Unit):
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/MediaLaunchers.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaLaunchers.kt
index 06bfe9500..58452d911 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
@@ -3,10 +3,12 @@ package net.opendasharchive.openarchive.features.media
import android.content.Intent
import android.net.Uri
import androidx.activity.result.ActivityResultLauncher
-import com.esafirm.imagepicker.features.ImagePickerLauncher
+import androidx.activity.result.PickVisualMediaRequest
data class MediaLaunchers(
- val imagePickerLauncher: ImagePickerLauncher,
+ val galleryLauncher: ActivityResultLauncher, // Changed
val filePickerLauncher: ActivityResultLauncher,
- val cameraLauncher: 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/Picker.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt
index 537ad9a9c..f92ed89d0 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/Picker.kt
@@ -1,42 +1,38 @@
package net.opendasharchive.openarchive.features.media
-import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
-import android.content.pm.PackageManager
-import android.graphics.Color
import android.net.Uri
-import android.os.Build
-import android.os.Environment
+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.app.ActivityCompat
-import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
-import com.esafirm.imagepicker.features.ImagePickerConfig
-import com.esafirm.imagepicker.features.ImagePickerLauncher
-import com.esafirm.imagepicker.features.ImagePickerMode
-import com.esafirm.imagepicker.features.ImagePickerSavePath
-import com.esafirm.imagepicker.features.ReturnMode
-import com.esafirm.imagepicker.features.registerImagePicker
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 {
@@ -50,13 +46,15 @@ object Picker {
completed: (List) -> Unit
): MediaLaunchers {
- val mpl = activity.registerImagePicker { result ->
+ // 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(), result.map { it.uri })
-
+ val media = import(activity, project(), uris, false)
activity.lifecycleScope.launch(Dispatchers.Main) {
snackbar.dismiss()
completed(media)
@@ -64,7 +62,7 @@ object Picker {
}
}
- val fpl = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val filePickerLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != AppCompatActivity.RESULT_OK) return@registerForActivityResult
val uri = result.data?.data ?: return@registerForActivityResult
@@ -72,7 +70,8 @@ object Picker {
val snackbar = showProgressSnackBar(activity, root, activity.getString(R.string.importing_media))
activity.lifecycleScope.launch(Dispatchers.IO) {
- val files = import(activity, project(), listOf(uri))
+ // We don't generate proof for file picker files.
+ val files = import(activity, project(), listOf(uri), false)
activity.lifecycleScope.launch(Dispatchers.Main) {
snackbar.dismiss()
@@ -81,14 +80,16 @@ object Picker {
}
}
- val cpl = activity.registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
- if (success) {
+ 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) {
- val media = import(activity, project(), listOf(uri))
+ // 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()
@@ -99,35 +100,86 @@ object Picker {
}
}
- return MediaLaunchers(
- imagePickerLauncher = mpl,
- filePickerLauncher = fpl,
- cameraLauncher = cpl
- )
- }
+ // 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))
- fun pickMedia(activity: Activity, launcher: ImagePickerLauncher) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- if (needAskForPermission(activity, arrayOf(
- Manifest.permission.READ_MEDIA_IMAGES,
- Manifest.permission.READ_MEDIA_VIDEO))
- ) {
- return
+ 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
}
}
- val config = ImagePickerConfig {
- mode = ImagePickerMode.MULTIPLE
- isShowCamera = false
- returnMode = ReturnMode.NONE
- isFolderMode = true
- isIncludeVideo = true
- arrowColor = Color.WHITE
- limit = 99
- savePath = ImagePickerSavePath(Environment.getExternalStorageDirectory().path, false)
+ // 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")
+ }
}
- launcher.launch(config)
+ return MediaLaunchers(
+ galleryLauncher = galleryLauncher,
+ filePickerLauncher = filePickerLauncher,
+ cameraLauncher = legacyCameraLauncher,
+ modernCameraLauncher = modernCameraLauncher,
+ customCameraLauncher = customCameraLauncher
+ )
+ }
+
+ fun pickMedia(launcher: ActivityResultLauncher) {
+ val request = PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
+ launcher.launch(request)
}
fun canPickFiles(context: Context): Boolean {
@@ -140,35 +192,93 @@ object Picker {
private val mFilePickerIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
- type = "application/*"
+ 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
+ )
+ )
}
- private fun needAskForPermission(activity: Activity, permissions: Array): Boolean {
- var needAsk = false
-
- for (permission in permissions) {
- needAsk = ContextCompat.checkSelfPermission(
- activity,
- permission
- ) != PackageManager.PERMISSION_GRANTED
- && ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
+ /**
+ * Launch custom camera with configuration options.
+ * Supports both photo and video capture with preview functionality.
+ */
+ fun launchCustomCamera(activity: Activity, launcher: ActivityResultLauncher, config: CameraConfig = CameraConfig()) {
+ val intent = CameraActivity.createIntent(activity, config)
+ launcher.launch(intent)
+ }
- if (needAsk) break
+ /**
+ * Modern camera photo capture using TakePicture contract.
+ * This is the recommended approach for new implementations.
+ */
+ fun takePhotoModern(activity: Activity, launcher: ActivityResultLauncher) {
+ 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()
}
+ }
- if (!needAsk) return false
+ /**
+ * 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")
- ActivityCompat.requestPermissions(activity, permissions, 2)
+ file?.let {
+ val uri = FileProvider.getUriForFile(
+ activity, "${activity.packageName}.provider",
+ it
+ )
- return true
+ 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): ArrayList {
+ private fun import(context: Context, project: Project?, uris: List, generateProof: Boolean): ArrayList {
val result = ArrayList()
for (uri in uris) {
try {
- val media = import(context, project, uri)
+ //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)
@@ -178,22 +288,38 @@ object Picker {
return result
}
- fun import(context: Context, project: Project?, uri: Uri): Media? {
- @Suppress("NAME_SHADOWING")
+ 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)
- if (!Utility.writeStreamToFile(context.contentResolver.openInputStream(uri), file)) {
+ // 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
+ // Create media object
val media = Media()
-
val coll = project.openCollection
-
media.collectionId = coll.id
val fileSource = uri.path?.let { File(it) }
@@ -208,32 +334,58 @@ object Picker {
}
media.originalFilePath = Uri.fromFile(file).toString()
- media.mimeType = Utility.getMimeType(context, uri) ?: ""
+ // 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
- media.mediaHashString =
- HashUtils.getSHA256FromFileContent(context.contentResolver.openInputStream(uri))
+
+ //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()
- return media
- }
+ // 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
- fun takePhoto(context: Context, launcher: ActivityResultLauncher) {
- val file = Utility.getOutputMediaFileByCache(context, "IMG_${System.currentTimeMillis()}.jpg")
+ AppLogger.d("Generating ProofMode data for URI: $uri, Hash: ${media.mediaHashString}")
- file?.let {
- val uri = FileProvider.getUriForFile(
- context, "${context.packageName}.provider",
- it
- )
- currentPhotoUri = uri
- launcher.launch(uri)
+ // 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)
@@ -241,4 +393,43 @@ object Picker {
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
index e332f4dab..39cd8d004 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewActivity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewActivity.kt
@@ -1,27 +1,39 @@
package net.opendasharchive.openarchive.features.media
import android.content.Context
-import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
-import android.view.ContextThemeWrapper
import android.view.Menu
import android.view.MenuItem
import android.view.View
-import android.widget.TextView
+import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AlertDialog
+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.util.AlertHelper
+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 {
@@ -36,6 +48,8 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis
}
}
+ private val appConfig by inject()
+
private lateinit var mBinding: ActivityPreviewBinding
private lateinit var mediaLaunchers: MediaLaunchers
@@ -60,12 +74,29 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis
}
}
+ 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 }, {
@@ -80,6 +111,13 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis
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)
@@ -104,7 +142,30 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis
}
R.id.action_upload_camera -> {
- Picker.takePhoto(this@PreviewActivity, mediaLaunchers.cameraLauncher)
+ 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 -> {
@@ -127,7 +188,32 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis
if (Picker.canPickFiles(this)) {
val modalBottomSheet = ContentPickerFragment { action ->
when (action) {
- AddMediaType.CAMERA -> Picker.takePhoto(this@PreviewActivity, mediaLaunchers.cameraLauncher)
+ 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)
}
@@ -161,7 +247,9 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis
override fun onClick(view: View?) {
when (view) {
mBinding.btAddMore -> {
- Picker.pickMedia(this, mediaLaunchers.imagePickerLauncher)
+ permissionManager.checkMediaPermissions {
+ Picker.pickMedia(mediaLaunchers.galleryLauncher)
+ }
}
mBinding.btBatchEdit -> {
@@ -211,26 +299,57 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis
}
override fun mediaSelectionChanged() {
- if (mMedia.firstOrNull { it.selected } != null) {
+ 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() {
- mMedia = Media.getByStatus(listOf(Media.Status.Local), Media.ORDER_CREATED)
+ val media = Media.getByStatus(listOf(Media.Status.Local), Media.ORDER_CREATED)
+ media.forEach {
+ if (it.selected) {
+ it.selected = false
+ it.save()
+ }
+ }
+ mMedia = media
}
private fun showFirstTimeBatch() {
if (Prefs.batchHintShown) return
- AlertHelper.show(
- this, R.string.press_and_hold_to_select_and_edit_multiple_media,
- R.string.edit_multiple, R.drawable.ic_batchedit
- )
+ 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
}
@@ -247,31 +366,59 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis
}
if (Prefs.dontShowUploadHint) {
+
queue()
+
} else {
+
var doNotShowAgain = false
- val d = AlertDialog.Builder(ContextThemeWrapper(this, R.style.AlertDialogTheme))
- .setTitle(R.string.once_uploaded_you_will_not_be_able_to_edit_media)
- .setIcon(R.drawable.baseline_cloud_upload_black_48)
- .setPositiveButton(
- R.string.got_it
- ) { _: DialogInterface, _: Int ->
- Prefs.dontShowUploadHint = doNotShowAgain
- queue()
- }
- .setNegativeButton(R.string.lbl_Cancel) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
- .setMultiChoiceItems(
- arrayOf(getString(R.string.do_not_show_me_this_again)),
- booleanArrayOf(false)
- )
- { _, _, isChecked ->
+ 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
- }.show()
+ }
+ positiveButton {
+ text = UiText.DynamicString("Proceed to upload")
+ action = {
+ Prefs.dontShowUploadHint = doNotShowAgain
+ queue()
+ }
+ }
+ neutralButton {
+ text = UiText.DynamicString("Actually, let me edit")
+ }
+ }
+ }
+ }
- // hack for making sure this dialog always shows all lines of the pretty long title, even on small screens
- d.findViewById(androidx.appcompat.R.id.alertTitle)?.maxLines = 99
+ 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)
}
}
-}
\ No newline at end of file
+
+ 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
index 591d74071..2768c210b 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewAdapter.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/PreviewAdapter.kt
@@ -1,14 +1,16 @@
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.db.MediaViewHolder
+import net.opendasharchive.openarchive.features.media.adapter.PreviewViewHolder
import java.lang.ref.WeakReference
-class PreviewAdapter(listener: Listener? = null): ListAdapter(DIFF_CALLBACK) {
+class PreviewAdapter(listener: Listener? = null): ListAdapter(DIFF_CALLBACK) {
interface Listener {
@@ -53,8 +55,9 @@ class PreviewAdapter(listener: Listener? = null): ListAdapter
val media = getMedia(view) ?: return@setOnClickListener
@@ -79,7 +82,7 @@ class PreviewAdapter(listener: Listener? = null): ListAdapter()
+ private val videoImageLoader by lazy {
+ ImageLoader.Builder(this)
+ .components { add(VideoFrameDecoder.Factory()) }
+ .build()
+ }
private val mMedia
get() = mStore.getOrNull(mIndex)
@@ -64,10 +86,16 @@ class ReviewActivity : BaseActivity(), View.OnClickListener {
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 = getString(R.string.edit_media_info),
+ title = if (mBatchMode) "Bulk Edit Media Info" else getString(R.string.edit_media_info),
showBackButton = true
)
@@ -76,11 +104,10 @@ class ReviewActivity : BaseActivity(), View.OnClickListener {
mIndex = savedInstanceState?.getInt(EXTRA_SELECTED_IDX) ?: intent.getIntExtra(EXTRA_SELECTED_IDX, 0)
- mBatchMode = intent.getBooleanExtra(EXTRA_BATCH_MODE, false)
+
mBinding.btFlag.setOnClickListener(this)
- mBinding.waveform.setOnClickListener(this)
mBinding.image.setOnClickListener(this)
mBinding.btPageBack.setOnClickListener {
@@ -167,12 +194,6 @@ class ReviewActivity : BaseActivity(), View.OnClickListener {
override fun onClick(view: View?) {
when (view) {
-// mBinding.waveform, mBinding.image -> {
-// if (mMedia?.mimeType?.startsWith("image") == true) {
-// val draweeView = SimpleDraweeView(this)
-// draweeView.setImageURI(mMedia?.fileUri)
-// }
-// }
mBinding.btFlag -> {
showFirstTimeFlag()
@@ -222,7 +243,7 @@ class ReviewActivity : BaseActivity(), View.OnClickListener {
mBinding.counter.text = getString(R.string.counter, mIndex + 1, mStore.size)
- load(mMedia, mBinding.image, mBinding.waveform)
+ load(mMedia, mBinding.image)
}
updateFlagState()
@@ -254,7 +275,16 @@ class ReviewActivity : BaseActivity(), View.OnClickListener {
}
else {
mBinding.description.setText(mMedia?.description)
- mBinding.location.setText(mMedia?.location)
+
+ // 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)
}
}
@@ -286,11 +316,18 @@ class ReviewActivity : BaseActivity(), View.OnClickListener {
private fun showFirstTimeFlag() {
if (Prefs.flagHintShown) return
- AlertHelper.show(
- context = this,
- message = R.string.popup_flag_desc,
- title = R.string.popup_flag_title
- )
+ 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
}
@@ -305,38 +342,133 @@ class ReviewActivity : BaseActivity(), View.OnClickListener {
}
}
- private fun load(media: Media?, imageView: ImageView, waveform: SimpleWaveformView? = null) {
+ private fun load(media: Media?, imageView: ImageView) {
imageView.show()
- waveform?.hide()
+ clearPdfJob(imageView)
if (media?.mimeType?.startsWith("image") == true) {
- Glide.with(this)
- .load(media.fileUri)
- .into(imageView)
+ 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) {
- Picasso.Builder(this)
- .addRequestHandler(VideoRequestHandler(this))
- .build()
- .load(VideoRequestHandler.SCHEME_VIDEO + ":" + media.originalFilePath)
- ?.fit()
- ?.centerCrop()
- ?.into(imageView)
+ 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.audio_waveform)
-
- if (waveform != null) {
- val soundFile = MediaViewHolder.soundCache[media.originalFilePath]
- if (soundFile != null) {
- waveform.setAudioFile(soundFile)
- waveform.show()
- imageView.hide()
- }
- }
+ 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/adapter/PreviewViewHolder.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/adapter/PreviewViewHolder.kt
new file mode 100644
index 000000000..d13cdc0f9
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/adapter/PreviewViewHolder.kt
@@ -0,0 +1,302 @@
+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
new file mode 100644
index 000000000..6dae03888
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraActivity.kt
@@ -0,0 +1,260 @@
+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 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()
+
+ // 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
new file mode 100644
index 000000000..3febf4fc6
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraBottomControls.kt
@@ -0,0 +1,288 @@
+package net.opendasharchive.openarchive.features.media.camera
+
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.geometry.Offset
+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.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.res.stringResource
+import net.opendasharchive.openarchive.R
+
+@Composable
+fun CameraBottomControls(
+ config: CameraConfig,
+ cameraState: CameraState,
+ onCameraSwitch: () -> Unit,
+ onCaptureModeChange: (CameraCaptureMode) -> Unit,
+ onPhotoCapture: () -> Unit,
+ onVideoStart: () -> Unit,
+ onVideoStop: () -> Unit,
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ // Mode selector (Photo/Video)
+ if (config.allowPhotoCapture && config.allowVideoCapture) {
+ CameraModeSelector(
+ currentMode = cameraState.captureMode,
+ onModeChange = onCaptureModeChange
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Captured items count (left side)
+ if (config.allowMultipleCapture) {
+ CapturedItemsIndicator(
+ count = cameraState.capturedItems.size,
+ onDone = onDone
+ )
+ } else {
+ Spacer(modifier = Modifier.width(80.dp))
+ }
+
+ // Main capture button (center)
+ CameraCaptureButton(
+ captureMode = cameraState.captureMode,
+ isRecording = cameraState.isRecording,
+ onPhotoCapture = onPhotoCapture,
+ onVideoStart = onVideoStart,
+ onVideoStop = onVideoStop
+ )
+
+ // Camera switch button (right side)
+ IconButton(
+ onClick = onCameraSwitch,
+ modifier = Modifier
+ .size(48.dp)
+ .background(Color.Black.copy(alpha = 0.3f), CircleShape)
+ ) {
+ Icon(
+ imageVector = if (cameraState.isFrontCamera) Icons.Default.CameraFront else Icons.Default.CameraRear,
+ contentDescription = stringResource(R.string.switch_camera),
+ tint = Color.White
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CameraModeSelector(
+ currentMode: CameraCaptureMode,
+ onModeChange: (CameraCaptureMode) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .background(
+ Color.Black.copy(alpha = 0.3f),
+ RoundedCornerShape(20.dp)
+ )
+ .padding(4.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ 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)
+ },
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(
+ if (isSelected) Color.White else Color.Transparent
+ )
+ .clickable { onModeChange(mode) }
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ color = if (isSelected) Color.Black else Color.White,
+ fontSize = 14.sp,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
+ )
+ }
+ }
+}
+
+@Composable
+private fun CameraCaptureButton(
+ captureMode: CameraCaptureMode,
+ isRecording: Boolean,
+ onPhotoCapture: () -> Unit,
+ onVideoStart: () -> Unit,
+ onVideoStop: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val density = LocalDensity.current
+
+ Box(
+ modifier = modifier.size(80.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ when (captureMode) {
+ CameraCaptureMode.PHOTO -> {
+ // Photo capture button
+ val scale by animateFloatAsState(
+ targetValue = 1f,
+ animationSpec = spring(stiffness = Spring.StiffnessLow),
+ label = "photo_button_scale"
+ )
+
+ Box(
+ modifier = Modifier
+ .size(70.dp)
+ .scale(scale)
+ .clip(CircleShape)
+ .background(Color.White)
+ .border(4.dp, Color.Gray, CircleShape)
+ .clickable { onPhotoCapture() },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.CameraAlt,
+ contentDescription = stringResource(R.string.capture_photo),
+ tint = Color.Black,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+
+ CameraCaptureMode.VIDEO -> {
+ // Video capture button with recording animation
+ if (isRecording) {
+ val infiniteTransition = rememberInfiniteTransition(label = "recording_animation")
+ val pulseScale by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 1.1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1000, easing = EaseInOut),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "pulse_scale"
+ )
+
+ // Recording indicator with pulsing red circle
+ Box(
+ modifier = Modifier
+ .size(70.dp)
+ .scale(pulseScale)
+ .clip(CircleShape)
+ .background(Color.Red)
+ .clickable { onVideoStop() },
+ contentAlignment = Alignment.Center
+ ) {
+ // Stop icon (square)
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .background(Color.White, RoundedCornerShape(4.dp))
+ )
+ }
+
+ // Recording pulse effect
+ Canvas(
+ modifier = Modifier.size(80.dp)
+ ) {
+ val center = Offset(size.width / 2, size.height / 2)
+ val radius = size.minDimension / 2
+
+ // Outer pulsing circle
+ drawCircle(
+ color = Color.Red.copy(alpha = 0.3f),
+ radius = radius * pulseScale,
+ center = center,
+ style = Stroke(
+ width = with(density) { 2.dp.toPx() },
+ cap = StrokeCap.Round
+ )
+ )
+ }
+ } else {
+ // Start recording button
+ Box(
+ modifier = Modifier
+ .size(70.dp)
+ .clip(CircleShape)
+ .background(Color.Red)
+ .border(4.dp, Color.White, CircleShape)
+ .clickable { onVideoStart() },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Videocam,
+ contentDescription = stringResource(R.string.start_recording),
+ tint = Color.White,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CapturedItemsIndicator(
+ count: Int,
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ if (count > 0) {
+ Button(
+ onClick = onDone,
+ modifier = modifier,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color.Blue,
+ contentColor = Color.White
+ ),
+ shape = RoundedCornerShape(20.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = stringResource(R.string.done),
+ modifier = Modifier.size(16.dp)
+ )
+ Text(
+ text = stringResource(R.string.done_with_count, count),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ } else {
+ Box(modifier = modifier.size(80.dp, 40.dp))
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..733906786
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt
@@ -0,0 +1,19 @@
+package net.opendasharchive.openarchive.features.media.camera
+
+import java.io.Serializable
+
+data class CameraConfig(
+ val allowVideoCapture: Boolean = true,
+ val allowPhotoCapture: Boolean = true,
+ val allowMultipleCapture: Boolean = false,
+ val enablePreview: Boolean = true,
+ val showFlashToggle: Boolean = true,
+ val showGridToggle: Boolean = true,
+ val showCameraSwitch: Boolean = true,
+ val initialMode: CameraCaptureMode = CameraCaptureMode.PHOTO
+) : Serializable
+
+enum class CameraCaptureMode : Serializable {
+ PHOTO,
+ VIDEO
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraGridOverlay.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraGridOverlay.kt
new file mode 100644
index 000000000..b5560b18f
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraGridOverlay.kt
@@ -0,0 +1,74 @@
+package net.opendasharchive.openarchive.features.media.camera
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CameraGridOverlay(
+ modifier: Modifier = Modifier
+) {
+ val density = LocalDensity.current
+ val strokeWidth = with(density) { 1.dp.toPx() }
+
+ Canvas(modifier = modifier.fillMaxSize()) {
+ val width = size.width
+ val height = size.height
+
+ // Vertical lines (rule of thirds)
+ val verticalLine1X = width / 3f
+ val verticalLine2X = (width * 2f) / 3f
+
+ // Horizontal lines (rule of thirds)
+ val horizontalLine1Y = height / 3f
+ val horizontalLine2Y = (height * 2f) / 3f
+
+ val gridColor = Color.White.copy(alpha = 0.5f)
+ val pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
+
+ // Draw vertical lines
+ drawLine(
+ color = gridColor,
+ start = Offset(verticalLine1X, 0f),
+ end = Offset(verticalLine1X, height),
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ pathEffect = pathEffect
+ )
+
+ drawLine(
+ color = gridColor,
+ start = Offset(verticalLine2X, 0f),
+ end = Offset(verticalLine2X, height),
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ pathEffect = pathEffect
+ )
+
+ // Draw horizontal lines
+ drawLine(
+ color = gridColor,
+ start = Offset(0f, horizontalLine1Y),
+ end = Offset(width, horizontalLine1Y),
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ pathEffect = pathEffect
+ )
+
+ drawLine(
+ color = gridColor,
+ start = Offset(0f, horizontalLine2Y),
+ end = Offset(width, horizontalLine2Y),
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ pathEffect = pathEffect
+ )
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..d3d87fc5b
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPermissionScreen.kt
@@ -0,0 +1,201 @@
+package net.opendasharchive.openarchive.features.media.camera
+
+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.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.tooling.preview.Preview
+import androidx.compose.ui.res.stringResource
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme
+
+@Composable
+fun CameraPermissionScreen(
+ modifier: Modifier = Modifier,
+ isCameraPermissionPermanentlyDenied: Boolean = false,
+ isAudioPermissionPermanentlyDenied: Boolean = false,
+ needsAudioPermission: Boolean = false,
+ onRequestPermissions: () -> Unit,
+ onOpenSettings: () -> Unit = {},
+ onCancel: () -> Unit
+) {
+ val context = LocalContext.current
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // Header
+ Icon(
+ imageVector = Icons.Default.CameraAlt,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = Color.White
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = if (isCameraPermissionPermanentlyDenied)
+ stringResource(R.string.camera_access_blocked)
+ else
+ stringResource(R.string.camera_permission_required),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ color = Color.White
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = when {
+ isCameraPermissionPermanentlyDenied -> {
+ val audioText = if (needsAudioPermission && isAudioPermissionPermanentlyDenied)
+ stringResource(R.string.camera_and_microphone) else ""
+ stringResource(R.string.camera_access_permanently_denied, audioText)
+ }
+ needsAudioPermission -> {
+ stringResource(R.string.camera_microphone_permission_description)
+ }
+ else -> {
+ stringResource(R.string.camera_permission_description)
+ }
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = Color.White.copy(alpha = 0.8f)
+ )
+
+ // Action buttons
+ if (isCameraPermissionPermanentlyDenied) {
+ // If permanently denied, show only Open Settings and Cancel
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = Color.White
+ ),
+ border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
+ brush = androidx.compose.ui.graphics.SolidColor(Color.White)
+ )
+ ) {
+ Text(stringResource(R.string.lbl_Cancel), color = Color.White)
+ }
+
+ Button(
+ onClick = onOpenSettings,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(stringResource(R.string.open_settings))
+ }
+ }
+ }
+ } else {
+ // Normal permission request flow
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = Color.White
+ ),
+ border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
+ brush = androidx.compose.ui.graphics.SolidColor(Color.White)
+ )
+ ) {
+ Text(stringResource(R.string.lbl_Cancel), color = Color.White)
+ }
+
+ Button(
+ onClick = onRequestPermissions,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text(stringResource(R.string.grant_permission), textAlign = TextAlign.Center)
+ }
+ }
+ }
+
+ // Show additional settings link only if not permanently denied
+ if (!isCameraPermissionPermanentlyDenied) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ TextButton(
+ onClick = onOpenSettings
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = Color.White.copy(alpha = 0.7f)
+ )
+ Text(
+ text = stringResource(R.string.open_app_settings),
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 14.sp
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CameraPermissionScreenPreview() {
+ SaveAppTheme {
+ CameraPermissionScreen(
+ isCameraPermissionPermanentlyDenied = false,
+ needsAudioPermission = true,
+ onRequestPermissions = {},
+ onOpenSettings = {},
+ onCancel = {}
+ )
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..a2fa4031a
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraPreviewScreen.kt
@@ -0,0 +1,356 @@
+package net.opendasharchive.openarchive.features.media.camera
+
+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.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
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+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
+
+@Composable
+fun CameraPreviewScreen(
+ item: CapturedItem,
+ config: CameraConfig,
+ onConfirm: (CapturedItem) -> Unit,
+ onRetake: (CapturedItem) -> Unit,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ ) {
+ // Preview content
+ when (item.type) {
+ CameraCaptureMode.PHOTO -> {
+ // Photo preview
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(item.uri)
+ .build(),
+ contentDescription = stringResource(R.string.captured_photo),
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Fit
+ )
+ }
+ CameraCaptureMode.VIDEO -> {
+ // Video preview with playback capability
+ VideoPreviewPlayer(
+ uri = item.uri,
+ modifier = Modifier.fillMaxSize()
+ )
+
+ // Video duration overlay
+ VideoDurationOverlay(
+ uri = item.uri,
+ modifier = Modifier.align(Alignment.TopEnd)
+ )
+ }
+ }
+
+ // Top controls
+ Row(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .fillMaxWidth()
+ .statusBarsPadding()
+ .displayCutoutPadding()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Back/Cancel button
+ IconButton(
+ onClick = onCancel,
+ modifier = Modifier
+ .size(48.dp)
+ .background(Color.Black.copy(alpha = 0.6f), CircleShape)
+ ) {
+ Icon(
+ imageVector = Icons.Default.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ tint = Color.White
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Media type indicator
+ Row(
+ modifier = Modifier
+ .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(16.dp))
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ imageVector = when (item.type) {
+ CameraCaptureMode.PHOTO -> Icons.Default.Photo
+ CameraCaptureMode.VIDEO -> Icons.Default.Videocam
+ },
+ contentDescription = item.type.name,
+ tint = Color.White,
+ modifier = Modifier.size(16.dp)
+ )
+ Text(
+ text = when (item.type) {
+ CameraCaptureMode.PHOTO -> stringResource(R.string.photo_label)
+ CameraCaptureMode.VIDEO -> stringResource(R.string.video_label)
+ },
+ color = Color.White,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+
+ // Bottom controls
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(32.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Retake button
+ Button(
+ onClick = { onRetake(item) },
+ modifier = Modifier.height(56.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color.Transparent,
+ contentColor = Color.White
+ ),
+ border = ButtonDefaults.outlinedButtonBorder.copy(
+ brush = androidx.compose.ui.graphics.SolidColor(Color.White)
+ ),
+ shape = RoundedCornerShape(28.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = stringResource(R.string.retake),
+ modifier = Modifier.size(20.dp)
+ )
+ Text(
+ text = stringResource(R.string.retake),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+
+ // Confirm/Use button
+ Button(
+ onClick = { onConfirm(item) },
+ modifier = Modifier.height(56.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color.Blue,
+ contentColor = Color.White
+ ),
+ shape = RoundedCornerShape(28.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ 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),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun VideoDurationOverlay(
+ uri: Uri,
+ modifier: Modifier = Modifier
+) {
+ 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
+ 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
+ .padding(16.dp)
+ .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp))
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ color = Color.White,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+}
+
+@Composable
+private fun VideoPreviewPlayer(
+ uri: Uri,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+ var isPlaying by remember { mutableStateOf(false) }
+ var showControls by remember { mutableStateOf(true) }
+
+ // Create ExoPlayer
+ val exoPlayer = remember {
+ ExoPlayer.Builder(context)
+ .build()
+ .apply {
+ setMediaItem(MediaItem.fromUri(uri))
+ prepare()
+ playWhenReady = false
+ repeatMode = Player.REPEAT_MODE_ONE // Loop the video for preview
+ }
+ }
+
+ // Update playing state based on player state
+ LaunchedEffect(exoPlayer) {
+ val listener = object : Player.Listener {
+ override fun onIsPlayingChanged(playing: Boolean) {
+ isPlaying = playing
+ }
+ }
+ exoPlayer.addListener(listener)
+ }
+
+ // Cleanup player when composable is disposed
+ DisposableEffect(exoPlayer) {
+ onDispose {
+ exoPlayer.release()
+ }
+ }
+
+ Box(modifier = modifier) {
+ // Video player view
+ AndroidView(
+ factory = { ctx ->
+ PlayerView(ctx).apply {
+ player = exoPlayer
+ setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
+ controllerAutoShow = false
+ hideController()
+ }
+ },
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable {
+ showControls = !showControls
+ }
+ )
+
+ // Custom play/pause overlay
+ if (showControls) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .size(80.dp)
+ .background(Color.Black.copy(alpha = 0.6f), CircleShape)
+ .clickable {
+ if (isPlaying) {
+ exoPlayer.pause()
+ } else {
+ exoPlayer.play()
+ }
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ val context = LocalContext.current
+ Icon(
+ imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = if (isPlaying) stringResource(R.string.pause) else stringResource(R.string.play),
+ tint = Color.White,
+ modifier = Modifier.size(40.dp)
+ )
+ }
+
+ // Hide controls after a delay when playing
+ LaunchedEffect(isPlaying, showControls) {
+ if (isPlaying && showControls) {
+ kotlinx.coroutines.delay(3000) // Hide after 3 seconds
+ showControls = false
+ }
+ }
+ }
+
+ // Video indicator in top-left corner
+ val context = LocalContext.current
+ Row(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(16.dp)
+ .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(12.dp))
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Videocam,
+ contentDescription = stringResource(R.string.video),
+ tint = Color.White,
+ modifier = Modifier.size(14.dp)
+ )
+ Text(
+ text = stringResource(R.string.video_label),
+ color = Color.White,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..38c099069
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt
@@ -0,0 +1,432 @@
+package net.opendasharchive.openarchive.features.media.camera
+
+import android.content.Context
+import android.net.Uri
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+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.*
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import kotlinx.coroutines.delay
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.google.common.util.concurrent.ListenableFuture
+import androidx.compose.ui.res.stringResource
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.core.logger.AppLogger
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+@Composable
+fun CameraScreen(
+ modifier: Modifier = Modifier,
+ config: CameraConfig = CameraConfig(),
+ onCaptureComplete: (List) -> Unit,
+ onCancel: () -> Unit,
+ viewModel: CameraViewModel = viewModel()
+) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val cameraState by viewModel.state.collectAsState()
+
+ var imageCapture by remember { mutableStateOf(null) }
+ var videoCapture by remember { mutableStateOf?>(null) }
+ var cameraProvider by remember { mutableStateOf(null) }
+ var cameraExecutor by remember { mutableStateOf(null) }
+
+ // Initialize camera executor
+ LaunchedEffect(Unit) {
+ cameraExecutor = Executors.newSingleThreadExecutor()
+ }
+
+ // Cleanup on disposal
+ DisposableEffect(Unit) {
+ onDispose {
+ cameraExecutor?.shutdown()
+ }
+ }
+
+ // Show preview screen when item is captured
+ if (cameraState.showPreview && cameraState.currentPreviewItem != null) {
+ CameraPreviewScreen(
+ item = cameraState.currentPreviewItem!!,
+ config = config,
+ onConfirm = { item ->
+ val uris = viewModel.confirmCapture(item)
+ if (config.allowMultipleCapture) {
+ viewModel.hidePreview()
+ } else {
+ onCaptureComplete(uris)
+ }
+ },
+ onRetake = { item ->
+ viewModel.retakeCapture(item)
+ },
+ onCancel = {
+ viewModel.hidePreview(deleteFile = true)
+ }
+ )
+ } else {
+ // Main camera interface
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ ) {
+ // Camera preview
+ var previewView by remember { mutableStateOf(null) }
+
+ // Setup camera when preview view is ready or camera settings change
+ LaunchedEffect(previewView, cameraState.isFrontCamera) {
+ previewView?.let { preview ->
+ setupCamera(
+ context = context,
+ previewView = preview,
+ lifecycleOwner = lifecycleOwner,
+ cameraState = cameraState,
+ onCameraReady = { provider, imgCapture, vidCapture ->
+ cameraProvider = provider
+ imageCapture = imgCapture
+ videoCapture = vidCapture
+ },
+ onFlashSupportChanged = { isSupported ->
+ viewModel.updateFlashSupport(isSupported)
+ }
+ )
+ }
+ }
+
+ AndroidView(
+ factory = { ctx ->
+ PreviewView(ctx).apply {
+ scaleType = PreviewView.ScaleType.FILL_CENTER
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+ }.also { preview ->
+ previewView = preview
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+
+ // Grid overlay
+ if (cameraState.showGrid && config.showGridToggle) {
+ CameraGridOverlay(modifier = Modifier.fillMaxSize())
+ }
+
+ // Top controls with system bars and display cutout padding
+ CameraTopControls(
+ config = config,
+ cameraState = cameraState,
+ onFlashToggle = { viewModel.toggleFlashMode() },
+ onGridToggle = { viewModel.toggleGrid() },
+ onCancel = onCancel,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .statusBarsPadding()
+ .displayCutoutPadding()
+ )
+
+ // Bottom controls with navigation bars padding
+ CameraBottomControls(
+ config = config,
+ cameraState = cameraState,
+ onCameraSwitch = { viewModel.toggleCamera() },
+ onCaptureModeChange = { mode ->
+ viewModel.updateCaptureMode(mode)
+ },
+ onPhotoCapture = {
+ imageCapture?.let { capture ->
+ viewModel.capturePhoto(
+ context = context,
+ imageCapture = capture,
+ onSuccess = { uri ->
+ AppLogger.d("Photo captured: $uri")
+ },
+ onError = { error ->
+ AppLogger.e("Photo capture failed", error)
+ }
+ )
+ }
+ },
+ 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)
+ }
+ )
+ }
+ },
+ onVideoStop = {
+ viewModel.stopVideoRecording()
+ },
+ onDone = {
+ val allUris = viewModel.getAllCapturedUris()
+ if (allUris.isNotEmpty()) {
+ onCaptureComplete(allUris)
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .navigationBarsPadding()
+ )
+ }
+ }
+}
+
+@Composable
+private fun CameraTopControls(
+ config: CameraConfig,
+ cameraState: CameraState,
+ onFlashToggle: () -> Unit,
+ onGridToggle: () -> Unit,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Cancel button (left aligned)
+ IconButton(
+ onClick = onCancel,
+ modifier = Modifier
+ .size(48.dp)
+ .background(Color.Black.copy(alpha = 0.3f), CircleShape)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(R.string.close),
+ tint = Color.White
+ )
+ }
+
+ // Spacer to push grid to center
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Grid toggle OR Recording timer (centered)
+ if (config.showGridToggle) {
+ val recordingStartTime = cameraState.recordingStartTime
+ AnimatedContent(
+ targetState = cameraState.isRecording && recordingStartTime != null,
+ transitionSpec = {
+ fadeIn(animationSpec = tween(300)) togetherWith
+ fadeOut(animationSpec = tween(300))
+ },
+ label = "grid_timer_transition"
+ ) { isRecording ->
+ if (isRecording && recordingStartTime != null) {
+ // Show compact recording timer
+ RecordingTimerCompact(startTime = recordingStartTime)
+ } else {
+ // Show grid toggle button
+ IconButton(
+ onClick = onGridToggle,
+ modifier = Modifier
+ .size(48.dp)
+ .background(Color.Black.copy(alpha = 0.3f), CircleShape)
+ ) {
+ Icon(
+ imageVector = if (cameraState.showGrid) Icons.Default.GridOn else Icons.Default.GridOff,
+ contentDescription = stringResource(R.string.grid),
+ tint = if (cameraState.showGrid) Color.Yellow else Color.White
+ )
+ }
+ }
+ }
+ }
+
+ // Spacer to push flash to right
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Flash toggle (right aligned)
+ if (config.showFlashToggle && cameraState.isFlashSupported) {
+ IconButton(
+ onClick = onFlashToggle,
+ modifier = Modifier
+ .size(48.dp)
+ .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
+ }
+ Icon(
+ imageVector = flashIcon,
+ contentDescription = stringResource(R.string.flash),
+ tint = Color.White
+ )
+ }
+ } else {
+ // Empty space to balance layout when flash is not shown
+ Spacer(modifier = Modifier.size(48.dp))
+ }
+ }
+}
+
+@Composable
+private fun RecordingTimerCompact(
+ startTime: Long,
+ modifier: Modifier = Modifier
+) {
+ var elapsedTime by remember { mutableLongStateOf(0L) }
+
+ // Update elapsed time every 100ms for smooth display
+ LaunchedEffect(startTime) {
+ while (true) {
+ elapsedTime = System.currentTimeMillis() - startTime
+ delay(100)
+ }
+ }
+
+ // Pulsing animation for the red dot
+ val infiniteTransition = rememberInfiniteTransition(label = "recording_pulse")
+ val alpha by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 0.4f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(800, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "recording_alpha"
+ )
+
+ // Format time as MM:SS
+ val minutes = (elapsedTime / 1000) / 60
+ val seconds = (elapsedTime / 1000) % 60
+ val timeText = String.format("%02d:%02d", minutes, seconds)
+
+ Row(
+ modifier = modifier
+ .background(
+ color = Color.Black.copy(alpha = 0.5f),
+ shape = CircleShape
+ )
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Pulsing red dot
+ Box(
+ modifier = Modifier
+ .size(10.dp)
+ .clip(CircleShape)
+ .background(Color.Red.copy(alpha = alpha))
+ )
+
+ Spacer(modifier = Modifier.width(6.dp))
+
+ // Timer text
+ Text(
+ text = timeText,
+ color = Color.White,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+}
+
+private fun setupCamera(
+ context: Context,
+ previewView: PreviewView?,
+ lifecycleOwner: androidx.lifecycle.LifecycleOwner,
+ cameraState: CameraState,
+ onCameraReady: (ProcessCameraProvider, ImageCapture, VideoCapture) -> Unit,
+ onFlashSupportChanged: (Boolean) -> Unit
+) {
+ val cameraProviderFuture: ListenableFuture = ProcessCameraProvider.getInstance(context)
+ cameraProviderFuture.addListener({
+ try {
+ val cameraProvider = cameraProviderFuture.get()
+ bindCamera(cameraProvider, previewView, lifecycleOwner, cameraState, onCameraReady, onFlashSupportChanged)
+ } catch (e: Exception) {
+ AppLogger.e("Failed to get camera provider", e)
+ }
+ }, ContextCompat.getMainExecutor(context))
+}
+
+private fun bindCamera(
+ cameraProvider: ProcessCameraProvider,
+ previewView: PreviewView?,
+ lifecycleOwner: androidx.lifecycle.LifecycleOwner,
+ cameraState: CameraState,
+ onCameraReady: (ProcessCameraProvider, ImageCapture, VideoCapture) -> Unit,
+ onFlashSupportChanged: (Boolean) -> Unit
+) {
+ try {
+ cameraProvider.unbindAll()
+
+ val cameraSelector = if (cameraState.isFrontCamera) {
+ CameraSelector.DEFAULT_FRONT_CAMERA
+ } else {
+ CameraSelector.DEFAULT_BACK_CAMERA
+ }
+
+ val preview = Preview.Builder().build().also {
+ previewView?.let { pv ->
+ it.setSurfaceProvider(pv.surfaceProvider)
+ }
+ }
+
+ val imageCapture = ImageCapture.Builder()
+ .setFlashMode(cameraState.flashMode)
+ .build()
+
+ val recorder = Recorder.Builder()
+ .setQualitySelector(QualitySelector.from(Quality.HD))
+ .build()
+ val videoCapture = VideoCapture.withOutput(recorder)
+
+ val camera = cameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ cameraSelector,
+ preview,
+ imageCapture,
+ videoCapture
+ )
+
+ // Check flash support
+ val flashSupported = camera.cameraInfo.hasFlashUnit()
+ onFlashSupportChanged(flashSupported)
+
+ onCameraReady(cameraProvider, imageCapture, videoCapture)
+
+ } catch (e: Exception) {
+ AppLogger.e("Failed to bind camera", e)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraState.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraState.kt
new file mode 100644
index 000000000..64b8a3a8c
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraState.kt
@@ -0,0 +1,23 @@
+package net.opendasharchive.openarchive.features.media.camera
+
+import android.net.Uri
+import androidx.camera.core.ImageCapture
+
+data class CameraState(
+ val captureMode: CameraCaptureMode = CameraCaptureMode.PHOTO,
+ val flashMode: Int = ImageCapture.FLASH_MODE_OFF,
+ val isFlashSupported: Boolean = false,
+ val isFrontCamera: Boolean = false,
+ val showGrid: Boolean = false,
+ val isRecording: Boolean = false,
+ val recordingStartTime: Long? = null,
+ val capturedItems: List = emptyList(),
+ val showPreview: Boolean = false,
+ val currentPreviewItem: CapturedItem? = null
+)
+
+data class CapturedItem(
+ val uri: Uri,
+ val type: CameraCaptureMode,
+ val timestamp: Long = System.currentTimeMillis()
+)
\ No newline at end of file
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
new file mode 100644
index 000000000..ea55ace1d
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt
@@ -0,0 +1,340 @@
+package net.opendasharchive.openarchive.features.media.camera
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.video.FileOutputOptions
+import androidx.camera.video.Recording
+import androidx.camera.video.VideoRecordEvent
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import androidx.core.net.toFile
+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.launch
+import net.opendasharchive.openarchive.core.logger.AppLogger
+import net.opendasharchive.openarchive.util.Utility
+import java.io.File
+
+class CameraViewModel : ViewModel() {
+
+ private val _state = MutableStateFlow(CameraState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private var currentRecording: Recording? = null
+
+ fun updateCaptureMode(mode: CameraCaptureMode) {
+ _state.value = _state.value.copy(captureMode = mode)
+ }
+
+ fun toggleFlashMode() {
+ val currentFlashMode = _state.value.flashMode
+ val newFlashMode = when (currentFlashMode) {
+ ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_ON
+ ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO
+ else -> ImageCapture.FLASH_MODE_OFF
+ }
+ _state.value = _state.value.copy(flashMode = newFlashMode)
+ }
+
+ fun updateFlashSupport(isSupported: Boolean) {
+ _state.value = _state.value.copy(isFlashSupported = isSupported)
+ }
+
+ fun toggleCamera() {
+ _state.value = _state.value.copy(isFrontCamera = !_state.value.isFrontCamera)
+ }
+
+ fun toggleGrid() {
+ _state.value = _state.value.copy(showGrid = !_state.value.showGrid)
+ }
+
+ fun capturePhoto(
+ context: Context,
+ imageCapture: ImageCapture,
+ onSuccess: (Uri) -> Unit,
+ onError: (Exception) -> Unit
+ ) {
+ viewModelScope.launch {
+ try {
+ val filename = "IMG_${System.currentTimeMillis()}.jpg"
+ val outputFile = Utility.getOutputMediaFileByCache(context, filename)
+
+ if (outputFile == null) {
+ onError(Exception("Failed to create output file"))
+ return@launch
+ }
+
+ val outputOptions = ImageCapture.OutputFileOptions.Builder(outputFile).build()
+
+ imageCapture.takePicture(
+ outputOptions,
+ ContextCompat.getMainExecutor(context),
+ object : ImageCapture.OnImageSavedCallback {
+ override fun onImageSaved(output: ImageCapture.OutputFileResults) {
+ // Use FileProvider URI like other camera implementations
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.provider",
+ outputFile
+ )
+ val capturedItem = CapturedItem(uri, CameraCaptureMode.PHOTO)
+
+ val updatedItems = _state.value.capturedItems + capturedItem
+ _state.value = _state.value.copy(
+ capturedItems = updatedItems,
+ showPreview = true,
+ currentPreviewItem = capturedItem
+ )
+
+ AppLogger.d("Photo captured successfully: $uri")
+ onSuccess(uri)
+ }
+
+ override fun onError(exception: ImageCaptureException) {
+ AppLogger.e("Photo capture failed", exception)
+ onError(exception)
+ }
+ }
+ )
+ } catch (e: Exception) {
+ AppLogger.e("Error setting up photo capture", e)
+ onError(e)
+ }
+ }
+ }
+
+ fun startVideoRecording(
+ context: Context,
+ videoCapture: androidx.camera.video.VideoCapture,
+ onSuccess: (Uri) -> Unit,
+ onError: (Exception) -> Unit
+ ) {
+ if (_state.value.isRecording) {
+ AppLogger.w("Video recording already in progress")
+ return
+ }
+
+ try {
+ val filename = "VID_${System.currentTimeMillis()}.mp4"
+ val outputFile = Utility.getOutputMediaFileByCache(context, filename)
+
+ if (outputFile == null) {
+ onError(Exception("Failed to create output file"))
+ return
+ }
+
+ val fileOutputOptions = FileOutputOptions.Builder(outputFile).build()
+
+ val hasAudioPermission =
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+
+ var pendingRecording = videoCapture.output
+ .prepareRecording(context, fileOutputOptions)
+
+ if (hasAudioPermission) {
+ pendingRecording = pendingRecording.withAudioEnabled()
+ } else {
+ AppLogger.w("RECORD_AUDIO permission not granted, recording without audio")
+ }
+
+ currentRecording =
+ pendingRecording.start(ContextCompat.getMainExecutor(context)) { recordEvent ->
+ when (recordEvent) {
+ is VideoRecordEvent.Start -> {
+ _state.value = _state.value.copy(
+ isRecording = true,
+ recordingStartTime = System.currentTimeMillis()
+ )
+ AppLogger.d("Video recording started")
+ }
+
+ is VideoRecordEvent.Finalize -> {
+ _state.value = _state.value.copy(
+ isRecording = false,
+ recordingStartTime = null
+ )
+
+ if (!recordEvent.hasError()) {
+ // Use FileProvider URI like other camera implementations
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.provider",
+ outputFile
+ )
+ val capturedItem = CapturedItem(uri, CameraCaptureMode.VIDEO)
+
+ val updatedItems = _state.value.capturedItems + capturedItem
+ _state.value = _state.value.copy(
+ capturedItems = updatedItems,
+ showPreview = true,
+ currentPreviewItem = capturedItem
+ )
+
+ AppLogger.d("Video captured successfully: $uri")
+ onSuccess(uri)
+ } else {
+ val error =
+ Exception("Video recording failed: ${recordEvent.error}")
+ AppLogger.e("Video recording failed", error)
+ onError(error)
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ AppLogger.e("Error starting video recording", e)
+ onError(e)
+ }
+ }
+
+ fun stopVideoRecording() {
+ if (_state.value.isRecording) {
+ currentRecording?.stop()
+ currentRecording = null
+ }
+ }
+
+ fun showPreview(item: CapturedItem) {
+ _state.value = _state.value.copy(
+ showPreview = true,
+ currentPreviewItem = item
+ )
+ }
+
+ fun hidePreview(deleteFile: Boolean = false) {
+ // When cancelling from preview (deleteFile=true), remove item and delete file
+ // When hiding after confirm (deleteFile=false), just hide preview
+ _state.value.currentPreviewItem?.let { item ->
+ if (deleteFile) {
+ // Remove from captured items
+ val updatedItems = _state.value.capturedItems.filter { it != item }
+
+ // Delete the file
+ deleteFile(item.uri)
+ AppLogger.d("Deleted cancelled capture: ${item.uri}")
+
+ _state.value = _state.value.copy(
+ capturedItems = updatedItems,
+ showPreview = false,
+ currentPreviewItem = null
+ )
+ } else {
+ // Just hide preview without deleting (file was confirmed)
+ _state.value = _state.value.copy(
+ showPreview = false,
+ currentPreviewItem = null
+ )
+ }
+ } ?: run {
+ // No current preview item, just hide preview
+ _state.value = _state.value.copy(
+ showPreview = false,
+ currentPreviewItem = null
+ )
+ }
+ }
+
+ fun confirmCapture(item: CapturedItem): List {
+ return if (_state.value.capturedItems.contains(item)) {
+ listOf(item.uri)
+ } else {
+ emptyList()
+ }
+ }
+
+ fun retakeCapture(item: CapturedItem) {
+ // Delete the file being retaken
+ deleteFile(item.uri)
+
+ val updatedItems = _state.value.capturedItems.filter { it != item }
+ _state.value = _state.value.copy(
+ capturedItems = updatedItems,
+ showPreview = false,
+ currentPreviewItem = null
+ )
+
+ AppLogger.d("Deleted file for retake: ${item.uri}")
+ }
+
+ fun getAllCapturedUris(): List {
+ return _state.value.capturedItems.map { it.uri }
+ }
+
+ fun clearCaptures() {
+ // Delete all captured files that weren't confirmed
+ val itemsToDelete = _state.value.capturedItems
+ AppLogger.d("Deleting ${itemsToDelete.size} unconfirmed captured items")
+
+ itemsToDelete.forEach { item ->
+ deleteFile(item.uri)
+ }
+
+ _state.value = CameraState()
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ stopVideoRecording()
+ clearCaptures()
+ }
+
+ /**
+ * Deletes a file from the given URI.
+ * Handles both FileProvider URIs and file:// URIs.
+ */
+ private fun deleteFile(uri: Uri) {
+ try {
+ // Try to get the file from FileProvider URI
+ val file = when (uri.scheme) {
+ "content" -> {
+ // FileProvider URI - extract the file path
+ // The URI format is: content://package.provider/cache_path/filename
+ val path = uri.path
+ if (path != null) {
+ // Extract the actual file path after the authority
+ val segments = path.split("/")
+ if (segments.size >= 2) {
+ // Reconstruct the file path from cache directory
+ File(segments.drop(1).joinToString("/"))
+ } else null
+ } else null
+ }
+
+ "file" -> {
+ // Direct file URI
+ try {
+ uri.toFile()
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ else -> null
+ }
+
+ if (file != null && file.exists()) {
+ val deleted = file.delete()
+ if (deleted) {
+ AppLogger.d("Successfully deleted file: ${file.absolutePath}")
+ } else {
+ AppLogger.w("Failed to delete file: ${file.absolutePath}")
+ }
+ } else {
+ AppLogger.w("File not found or invalid URI: $uri")
+ }
+ } catch (e: Exception) {
+ AppLogger.e("Error deleting file for URI: $uri", e)
+ }
+ }
+}
\ No newline at end of file
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
index 1ce30fdfc..8069d9a51 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23Activity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23Activity.kt
@@ -7,6 +7,7 @@ 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
@@ -39,6 +40,14 @@ class Onboarding23Activity : BaseActivity() {
)
}
+ // 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,
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
index 49e8f42e7..cf4d0a11f 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23InstructionsActivity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23InstructionsActivity.kt
@@ -5,8 +5,11 @@ 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
@@ -14,20 +17,27 @@ import net.opendasharchive.openarchive.databinding.ActivityOnboarding23Instructi
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 {
@@ -63,20 +73,13 @@ class Onboarding23InstructionsActivity : BaseActivity() {
super.onPageSelected(position)
if (isLastPage()) {
mBinding.skipButton.visibility = View.INVISIBLE
- mBinding.fab.setImageDrawable(
- ContextCompat.getDrawable(
- mBinding.fab.context, com.esafirm.imagepicker.R.drawable.ef_ic_done_white,
- )
- )
+ 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_right,
- )
+ val icon = ContextCompat.getDrawable(mBinding.fab.context, R.drawable.ic_arrow_forward_ios,)
icon?.isAutoMirrored = true
- mBinding.fab.setImageDrawable(
- icon
- )
+ mBinding.fab.setImageDrawable(icon)
}
}
@@ -99,10 +102,10 @@ class Onboarding23InstructionsActivity : BaseActivity() {
private fun updateCoverImage() {
when (mBinding.viewPager.currentItem) {
- 0 -> mBinding.coverImage.setImageResource(R.drawable.onboarding23_cover_secure)
- 1 -> mBinding.coverImage.setImageResource(R.drawable.onboarding23_cover_archive)
- 2 -> mBinding.coverImage.setImageResource(R.drawable.onboarding23_cover_verify)
- 3 -> mBinding.coverImage.setImageResource(R.drawable.onboarding23_cover_encrypt)
+ 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()
@@ -119,6 +122,13 @@ class Onboarding23InstructionsActivity : BaseActivity() {
}
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))
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
index c9d55975d..d2a586fbc 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt
@@ -1,121 +1,120 @@
package net.opendasharchive.openarchive.features.onboarding
-import android.content.Context
-import android.content.Intent
import android.os.Bundle
-import android.view.View
-import android.view.inputmethod.InputMethodManager
+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.db.SnowbirdError
-import net.opendasharchive.openarchive.extensions.androidViewModel
-import net.opendasharchive.openarchive.extensions.onBackButtonPressed
import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.features.internetarchive.presentation.InternetArchiveFragment
-import net.opendasharchive.openarchive.features.main.MainActivity
-import net.opendasharchive.openarchive.features.settings.SpaceSetupFragment
-import net.opendasharchive.openarchive.features.settings.SpaceSetupSuccessFragment
-import net.opendasharchive.openarchive.services.gdrive.GDriveFragment
-import net.opendasharchive.openarchive.services.snowbird.SnowbirdCreateGroupFragment
-import net.opendasharchive.openarchive.services.snowbird.SnowbirdFileListFragment
-import net.opendasharchive.openarchive.services.snowbird.SnowbirdFragment
-import net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupListFragment
-import net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupViewModel
-import net.opendasharchive.openarchive.services.snowbird.SnowbirdJoinGroupFragment
-import net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoListFragment
-import net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoViewModel
-import net.opendasharchive.openarchive.services.snowbird.SnowbirdShareFragment
-import net.opendasharchive.openarchive.services.webdav.WebDavFragment
-import net.opendasharchive.openarchive.services.webdav.WebDavSetupLicenseFragment
-import net.opendasharchive.openarchive.util.FullScreenOverlayManager
-import net.opendasharchive.openarchive.util.Utility
-import kotlin.getValue
-
-interface ToolbarConfigurable {
- fun getToolbarTitle(): String
- fun getToolbarSubtitle(): String? = null
- fun shouldShowBackButton(): Boolean = true
-}
-
-abstract class BaseFragment : Fragment(), ToolbarConfigurable {
-
- val snowbirdGroupViewModel: SnowbirdGroupViewModel by androidViewModel()
- val snowbirdRepoViewModel: SnowbirdRepoViewModel by androidViewModel()
-
- 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) {
- Utility.showMaterialWarning(
- requireContext(),
- error.friendlyMessage
- )
- }
-
- open fun handleLoadingStatus(isLoading: Boolean) {
- if (isLoading) {
- FullScreenOverlayManager.show(this@BaseFragment)
- } else {
- FullScreenOverlayManager.hide()
- }
- }
-
- override fun onResume() {
- super.onResume()
- (activity as? SpaceSetupActivity)?.updateToolbarFromFragment(this)
- }
+import net.opendasharchive.openarchive.features.core.ToolbarConfigurable
+import net.opendasharchive.openarchive.features.settings.FoldersFragment
+
+enum class StartDestination {
+ SPACE_TYPE,
+ SPACE_LIST,
+ DWEB_DASHBOARD,
+ ADD_FOLDER,
+ ADD_NEW_FOLDER,
+ ARCHIVED_FOLDER_LIST,
+ APP_MASKING
}
class SpaceSetupActivity : BaseActivity() {
companion object {
- const val FRAGMENT_TAG = "ssa_fragment"
+ const val EXTRA_FOLDER_ID = "folder_id"
+ const val EXTRA_FOLDER_NAME = "folder_name"
+ const val LABEL_START_DESTINATION = "start_destination"
}
- private lateinit var mBinding: ActivitySpaceSetupBinding
+ 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)
- mBinding = ActivitySpaceSetupBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
+ binding = ActivitySpaceSetupBinding.inflate(layoutInflater)
+
+ setContentView(binding.root)
setupToolbar(
- title = "Servers",
showBackButton = true
)
- initSpaceSetupFragmentBindings()
- initWebDavFragmentBindings()
- initWebDavCreativeLicenseBindings()
- initSpaceSetupSuccessFragmentBindings()
- initInternetArchiveFragmentBindings()
- initGDriveFragmentBindings()
- initRavenBindings()
+
+// 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
+// }
+// }
- onBackButtonPressed {
- // Return "true" if you fully handle the back press yourself
- // Return "false" if you want to let the system handle it (i.e., finish the Activity)
+ initSpaceSetupNavigation()
+ }
- 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
- }
- }
+ 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)
- intent.getBooleanExtra("snowbird", false).let {
- if (it) {
- navigateToFragment(SnowbirdFragment.newInstance())
+ 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.APP_MASKING -> {
+ navGraph.setStartDestination(R.id.fragment_app_masking)
+ }
+ 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) {
@@ -132,240 +131,26 @@ class SpaceSetupActivity : BaseActivity() {
}
}
- private fun initSpaceSetupSuccessFragmentBindings() {
- supportFragmentManager.setFragmentResultListener(
- SpaceSetupSuccessFragment.RESP_DONE,
- this
- ) { key, bundle ->
- finishAffinity()
- startActivity(Intent(this, MainActivity::class.java))
- }
- }
-
- private fun initSpaceSetupFragmentBindings() {
- supportFragmentManager.setFragmentResultListener(
- SpaceSetupFragment.RESULT_REQUEST_KEY,
- this
- ) { _, bundle ->
- when (bundle.getString(SpaceSetupFragment.RESULT_BUNDLE_KEY)) {
- SpaceSetupFragment.RESULT_VAL_INTERNET_ARCHIVE -> {
- navigateToFragment(InternetArchiveFragment.newInstance())
- }
-
- SpaceSetupFragment.RESULT_VAL_WEBDAV -> {
- navigateToFragment(WebDavFragment.newInstance())
- }
-
- SpaceSetupFragment.RESULT_VAL_GDRIVE -> {
- navigateToFragment(GDriveFragment())
- }
-
- SpaceSetupFragment.RESULT_VAL_RAVEN -> {
- navigateToFragment(SnowbirdFragment.newInstance())
- }
- }
- }
- }
-
- /**
- * Init NextCloud credentials
- *
- */
- private fun initWebDavFragmentBindings() {
- supportFragmentManager.setFragmentResultListener(
- WebDavFragment.RESP_SAVED,
- this
- ) { key, bundle ->
- val spaceId = bundle.getLong(WebDavFragment.ARG_SPACE_ID)
- val fragment =
- WebDavSetupLicenseFragment.newInstance(spaceId = spaceId, isEditing = false)
- navigateToFragment(fragment)
- }
-
-
- supportFragmentManager.setFragmentResultListener(
- WebDavFragment.RESP_CANCEL,
- this
- ) { key, bundle ->
- navigateToFragment(SpaceSetupFragment())
- }
- }
-
- /**
- * Init select Creative Commons Licensing
- *
- */
- private fun initWebDavCreativeLicenseBindings() {
- supportFragmentManager.setFragmentResultListener(
- WebDavSetupLicenseFragment.RESP_SAVED,
- this
- ) { key, bundle ->
- val message = getString(R.string.you_have_successfully_connected_to_a_private_server)
- val fragment = SpaceSetupSuccessFragment.newInstance(message)
- navigateToFragment(fragment)
- }
-
- supportFragmentManager.setFragmentResultListener(
- WebDavSetupLicenseFragment.RESP_CANCEL,
- this
- ) { key, bundle ->
- navigateToFragment(SpaceSetupFragment())
- }
- }
-
- private fun initInternetArchiveFragmentBindings() {
- supportFragmentManager.setFragmentResultListener(
- InternetArchiveFragment.RESP_SAVED,
- this
- ) { key, bundle ->
- val fragment =
- SpaceSetupSuccessFragment.newInstance(getString(R.string.you_have_successfully_connected_to_the_internet_archive))
- navigateToFragment(fragment)
- }
-
- supportFragmentManager.setFragmentResultListener(
- InternetArchiveFragment.RESP_CANCEL,
- this
- ) { key, bundle ->
- navigateToFragment(SpaceSetupFragment())
- }
- }
-
- private fun initGDriveFragmentBindings() {
- supportFragmentManager.setFragmentResultListener(
- GDriveFragment.RESP_CANCEL,
- this
- ) { key, bundle ->
-
- navigateToFragment(SpaceSetupFragment())
- }
-
- supportFragmentManager.setFragmentResultListener(
- GDriveFragment.RESP_AUTHENTICATED,
- this
- ) { key, bundle ->
- val fragment =
- SpaceSetupSuccessFragment.newInstance(getString(R.string.you_have_successfully_connected_to_gdrive))
- navigateToFragment(fragment)
- }
- }
-
- private fun initRavenBindings() {
-
- initSnowbirdFragmentBindings()
-
- initSnowbirdGroupListFragmentBindings()
-
- initSnowbirdCreateGroupFragmentBindings()
-
- initSnowbirdRepoListFragmentBindings()
-
+ override fun onSupportNavigateUp(): Boolean {
+ return findNavController(R.id.space_nav_host_fragment).navigateUp() || super.onSupportNavigateUp()
}
- private fun initSnowbirdFragmentBindings() {
- supportFragmentManager.setFragmentResultListener(
- SnowbirdFragment.RESULT_REQUEST_KEY,
- this
- ) { key, bundle ->
- when (bundle.getString(SnowbirdFragment.RESULT_BUNDLE_KEY)) {
+ override fun onDestroy() {
+ super.onDestroy()
- SnowbirdFragment.RESULT_VAL_RAVEN_MY_GROUPS -> {
- navigateToFragment(SnowbirdGroupListFragment.newInstance())
- }
+ // Clear any pending messages or callbacks in the main thread handler
+ window?.decorView?.handler?.removeCallbacksAndMessages(null)
+ binding.commonAppBar.commonToolbar.setNavigationOnClickListener(null)
- SnowbirdFragment.RESULT_VAL_RAVEN_CREATE_GROUP -> {
- val fragment = SnowbirdCreateGroupFragment.newInstance()
- navigateToFragment(fragment)
- }
-
- SnowbirdFragment.RESULT_VAL_RAVEN_JOIN_GROUPS -> {
- val uriString = bundle.getString(SnowbirdFragment.RESULT_VAL_RAVEN_JOIN_GROUPS_ARG) ?: ""
- navigateToFragment(SnowbirdJoinGroupFragment.newInstance(uriString))
- }
- }
- }
- }
-
- private fun initSnowbirdGroupListFragmentBindings() {
- supportFragmentManager.setFragmentResultListener(
- SnowbirdGroupListFragment.RESULT_REQUEST_KEY,
- this
- ) { key, bundle ->
-
- when (bundle.getString(SnowbirdGroupListFragment.RESULT_BUNDLE_NAVIGATION_KEY)) {
- SnowbirdGroupListFragment.RESULT_VAL_RAVEN_CREATE_GROUP_SCREEN -> {
- val fragment = SnowbirdCreateGroupFragment.newInstance()
- navigateToFragment(fragment)
- }
- SnowbirdGroupListFragment.RESULT_VAL_RAVEN_REPO_LIST_SCREEN -> {
- val groupKey = bundle.getString(SnowbirdGroupListFragment.RESULT_BUNDLE_GROUP_KEY) ?: ""
- val fragment = SnowbirdRepoListFragment.newInstance(groupKey)
- navigateToFragment(fragment)
- }
- SnowbirdGroupListFragment.RESULT_VAL_RAVEN_SHARE_SCREEN -> {
- val groupKey = bundle.getString(SnowbirdGroupListFragment.RESULT_BUNDLE_GROUP_KEY) ?: ""
- val fragment = SnowbirdShareFragment.newInstance(groupKey)
- navigateToFragment(fragment)
+ // 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)
}
}
}
}
-
- private fun initSnowbirdCreateGroupFragmentBindings() {
- supportFragmentManager.setFragmentResultListener(
- SnowbirdCreateGroupFragment.RESULT_REQUEST_KEY,
- this
- ) { key, bundle ->
- when(bundle.getString(SnowbirdCreateGroupFragment.RESULT_NAVIGATION_KEY)) {
- SnowbirdCreateGroupFragment.RESULT_NAVIGATION_VAL_SHARE_SCREEN -> {
- val groupKey =
- bundle.getString(SnowbirdCreateGroupFragment.RESULT_BUNDLE_GROUP_KEY) ?: ""
- val fragment = SnowbirdShareFragment.newInstance(groupKey)
- navigateToFragment(fragment)
- }
- }
- }
- }
-
- private fun initSnowbirdRepoListFragmentBindings() {
- supportFragmentManager.setFragmentResultListener(
- SnowbirdRepoListFragment.RESULT_REQUEST_KEY,
- this
- ) { key, bundle ->
- val groupKey = bundle.getString(SnowbirdRepoListFragment.RESULT_VAL_RAVEN_GROUP_KEY) ?: ""
- val repoKey = bundle.getString(SnowbirdRepoListFragment.RESULT_VAL_RAVEN_REPO_KEY) ?: ""
- val fragment = SnowbirdFileListFragment.newInstance(
- groupKey = groupKey,
- repoKey = repoKey
- )
- navigateToFragment(fragment)
- }
- }
-
-
-// @Deprecated("Deprecated in Java")
-// override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-// super.onActivityResult(requestCode, resultCode, data)
-// supportFragmentManager.findFragmentByTag(FRAGMENT_TAG)?.let {
-// onActivityResult(requestCode, resultCode, data)
-// }
-// }
-
- private fun navigateToFragment(
- fragment: BaseFragment,
- addToBackstack: Boolean = true
- ) {
- supportFragmentManager
- .beginTransaction()
- .setCustomAnimations(
- R.anim.slide_in_right,
- R.anim.slide_out_left,
- R.anim.slide_in_left,
- R.anim.slide_out_right
- )
- .replace(mBinding.spaceSetupFragment.id, fragment, FRAGMENT_TAG)
- .apply {
- if (addToBackstack) addToBackStack(null)
- }.commit()
- }
}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/CcSelector.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/CcSelector.kt
deleted file mode 100644
index 175db133f..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/CcSelector.kt
+++ /dev/null
@@ -1,119 +0,0 @@
-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 CcSelector {
-
- private const val CC_DOMAIN = "creativecommons.org"
- private const val CC_URL = "https://%s/licenses/%s/4.0/"
-
- fun init(cc: ContentCcBinding, license: String? = null, enabled: Boolean = true, update: ((license: String?) -> Unit)? = null) {
- set(cc, license, enabled)
-
- cc.swCc.setOnCheckedChangeListener { _, isChecked ->
- toggle(cc, isChecked)
-
- @Suppress("NAME_SHADOWING")
- val license = get(cc)
-
- update?.invoke(license)
- }
-
- cc.swNd.setOnCheckedChangeListener { _, isChecked ->
- cc.swSa.isEnabled = isChecked
-
- @Suppress("NAME_SHADOWING")
- val license = get(cc)
-
- update?.invoke(license)
- }
-
- cc.swSa.setOnCheckedChangeListener { _, _ ->
- @Suppress("NAME_SHADOWING")
- val license = get(cc)
-
- update?.invoke(license)
- }
- cc.swNc.setOnCheckedChangeListener { _, _ ->
- @Suppress("NAME_SHADOWING")
- val license = get(cc)
-
- update?.invoke(license)
- }
-
- cc.tvLicense.setOnClickListener {
- it?.context?.openBrowser(cc.tvLicense.text.toString())
- }
-
- cc.btLearnMore.styleAsLink()
- cc.btLearnMore.setOnClickListener {
- it?.context?.openBrowser("https://creativecommons.org/about/cclicenses/")
- }
- }
-
- fun set(cc: ContentCcBinding, license: String?, enabled: Boolean = true) {
- val isCc = license?.contains(CC_DOMAIN, true) ?: false
-
- cc.swCc.isChecked = isCc
- toggle(cc, isCc)
-
- cc.swNd.isChecked = isCc && !(license?.contains("-nd", true) ?: false)
- cc.swSa.isEnabled = cc.swNd.isChecked
- cc.swSa.isChecked = isCc && cc.swNd.isChecked && license?.contains("-sa", true) ?: false
- cc.swNc.isChecked = isCc && !(license?.contains("-nc", true) ?: false)
-
- cc.tvLicense.text = license
- cc.tvLicense.styleAsLink()
-
- cc.swCc.isEnabled = enabled
- cc.swNd.isEnabled = enabled
- cc.swSa.isEnabled = enabled
- cc.swNc.isEnabled = enabled
- }
-
- fun get(cc: ContentCcBinding): String? {
- var license: String? = null
-
- if (cc.swCc.isChecked) {
- license = "by"
-
- if (cc.swNd.isChecked) {
- if (!cc.swNc.isChecked) {
- license += "-nc"
- }
-
- if (cc.swSa.isChecked) {
- license += "-sa"
- }
- }
- else {
- cc.swSa.isChecked = false
-
- if (!cc.swNc.isChecked) {
- license += "-nc"
- }
-
- license += "-nd"
- }
- }
-
- if (license != null) {
- license = String.format(CC_URL, CC_DOMAIN, license)
- }
-
- cc.tvLicense.text = license
- cc.tvLicense.styleAsLink()
-
- return license
- }
-
- private fun toggle(cc: ContentCcBinding, value: Boolean) {
- cc.row1.toggle(value)
- cc.row2.toggle(value)
- cc.row3.toggle(value)
- cc.tvLicense.toggle(value)
- }
-}
\ 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
new file mode 100644
index 000000000..f7572b98a
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/CreativeCommonsLicenseManager.kt
@@ -0,0 +1,205 @@
+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"
+ private const val CC_LICENSE_URL_FORMAT = "https://%s/licenses/%s/4.0/"
+ private const val CC0_LICENSE_URL_FORMAT = "https://%s/publicdomain/zero/1.0/"
+
+ /**
+ * Generates a Creative Commons license URL based on the provided options
+ * @param ccEnabled Whether Creative Commons licensing is enabled
+ * @param allowRemix Whether derivative works are allowed
+ * @param requireShareAlike Whether derivative works must be shared under the same license (only applies if allowRemix is true)
+ * @param allowCommercial Whether commercial use is allowed
+ * @param cc0Enabled Whether CC0 (no restrictions) is enabled
+ * @return The generated license URL, or null if neither CC nor CC0 is enabled
+ */
+ fun generateLicenseUrl(
+ ccEnabled: Boolean = false,
+ allowRemix: Boolean = false,
+ requireShareAlike: Boolean = false,
+ allowCommercial: Boolean = false,
+ cc0Enabled: Boolean = false
+ ): String? {
+ // First check if CC is enabled at all
+ if (!ccEnabled) return null
+
+ // If CC is enabled and CC0 is selected, return CC0 license
+ if (cc0Enabled) {
+ return String.format(CC0_LICENSE_URL_FORMAT, CC_DOMAIN)
+ }
+
+ // Generate regular CC license
+ var license = "by"
+
+ if (allowRemix) {
+ if (!allowCommercial) {
+ license += "-nc"
+ }
+ if (requireShareAlike) {
+ license += "-sa"
+ }
+ } else {
+ // When remix is not allowed, ShareAlike should be automatically disabled
+ if (!allowCommercial) {
+ license += "-nc"
+ }
+ license += "-nd"
+ }
+
+ 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/EditFolderActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderActivity.kt
deleted file mode 100644
index 004e7ce36..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderActivity.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-package net.opendasharchive.openarchive.features.settings
-
-import android.os.Bundle
-import android.view.MenuItem
-import android.view.inputmethod.EditorInfo
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.ActivityEditFolderBinding
-import net.opendasharchive.openarchive.db.Project
-import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.util.AlertHelper
-import net.opendasharchive.openarchive.util.extensions.Position
-import net.opendasharchive.openarchive.util.extensions.setDrawable
-
-class EditFolderActivity : BaseActivity() {
-
- companion object {
- const val EXTRA_CURRENT_PROJECT_ID = "archive_extra_current_project_id"
- }
-
- private lateinit var mProject: Project
- private lateinit var mBinding: ActivityEditFolderBinding
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- val project = Project.getById(intent.getLongExtra(EXTRA_CURRENT_PROJECT_ID, -1L))
- ?: return finish()
-
- mProject = project
-
- mBinding = ActivityEditFolderBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
-
- setupToolbar("Edit Folder")
-
- mBinding.folderName.setOnEditorActionListener { _, actionId, _ ->
- if (actionId == EditorInfo.IME_ACTION_DONE) {
- val newName = mBinding.folderName.text.toString()
-
- if (newName.isNotBlank()) {
- mProject.description = newName
- mProject.save()
-
- supportActionBar?.title = newName
- mBinding.folderName.hint = newName
-
-
- setupToolbar(newName)
- }
- }
-
- false
- }
-
- mBinding.btRemove.setDrawable(R.drawable.ic_delete, Position.Start, 0.5)
- mBinding.btRemove.setOnClickListener {
- removeProject()
- }
-
- mBinding.btArchive.setOnClickListener {
- archiveProject()
- }
-
- CcSelector.init(mBinding.cc, null) {
- mProject.licenseUrl = it
- mProject.save()
- }
-
- updateUi()
- }
-
- private fun removeProject() {
- AlertHelper.show(this, R.string.action_remove_project, R.string.remove_from_app, buttons = listOf(
- AlertHelper.positiveButton(R.string.remove) { _, _ ->
- mProject.delete()
-
- finish()
- },
- AlertHelper.negativeButton()))
- }
-
- private fun archiveProject() {
- mProject.isArchived = !mProject.isArchived
- mProject.save()
-
- updateUi()
- }
-
- private fun updateUi() {
- supportActionBar?.title = mProject.description
-
- 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.tvCc.setText(R.string.set_the_same_creative_commons_license_for_all_folders_on_this_server)
- }
-
- CcSelector.set(mBinding.cc, mProject.licenseUrl, !mProject.isArchived && !global)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- finish()
- return true
- }
- }
-
- return super.onOptionsItemSelected(item)
- }
-}
\ 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
new file mode 100644
index 000000000..be436e4c8
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderDetailFragment.kt
@@ -0,0 +1,133 @@
+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/FoldersActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersActivity.kt
deleted file mode 100644
index 35c438790..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersActivity.kt
+++ /dev/null
@@ -1,137 +0,0 @@
-package net.opendasharchive.openarchive.features.settings
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-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.ActivityFoldersBinding
-import net.opendasharchive.openarchive.db.Project
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.features.folders.AddFolderActivity
-
-class FoldersActivity : BaseActivity(), FolderAdapterListener {
-
- companion object {
- 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: ActivityFoldersBinding
- private lateinit var mAdapter: FolderAdapter
-
- private var mArchived = false
- private var mSelectedSpaceId = -1L
- private var mSelectedProjectId: Long = -1L
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- mArchived = intent.getBooleanExtra(EXTRA_SHOW_ARCHIVED, false)
- mSelectedSpaceId = intent.getLongExtra(EXTRA_SELECTED_SPACE_ID, -1L)
- mSelectedProjectId = intent.getLongExtra(EXTRA_SELECTED_PROJECT_ID, -1L)
-
- mBinding = ActivityFoldersBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
- setupToolbar(
- title = getString(if (mArchived) R.string.archived_folders else R.string.folders),
- showBackButton = true
- )
-
- setupRecyclerView()
-
- setupButtons()
- }
-
- private fun setupRecyclerView() {
- mAdapter = FolderAdapter(context = this, listener = this, isArchived = mArchived)
- mBinding.rvProjects.layoutManager = LinearLayoutManager(this)
- mBinding.rvProjects.adapter = mAdapter
- }
-
- private fun setupButtons() {
- mBinding.fabAdd.apply {
- visibility = if (mArchived) View.INVISIBLE else View.VISIBLE
- setOnClickListener { addFolder() }
- }
- }
-
- override fun onResume() {
- super.onResume()
- refreshProjects()
- invalidateOptionsMenu()
- }
-
- private fun refreshProjects() {
- val projects = if (mArchived) {
- Space.current?.archivedProjects
- } else {
- Space.current?.projects?.filter { !it.isArchived }
- } ?: emptyList()
-
- mAdapter.update(projects)
- }
-
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- menuInflater.inflate(R.menu.menu_folder_list, menu)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
- val archivedCount = Space.get(mSelectedSpaceId)?.archivedProjects?.size ?: 0
- menu?.findItem(R.id.action_archived_folders)?.isVisible = (!mArchived && archivedCount > 0)
- return super.onPrepareOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
-
- R.id.action_archived_folders -> {
- navigateToArchivedFolders()
- true
- }
-
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private fun navigateToArchivedFolders() {
- val intent = Intent(this, FoldersActivity::class.java).apply {
- putExtra(EXTRA_SHOW_ARCHIVED, true)
- putExtra(EXTRA_SELECTED_SPACE_ID, mSelectedSpaceId)
- putExtra(EXTRA_SELECTED_PROJECT_ID, mSelectedProjectId)
- }
- startActivity(intent)
- }
-
- private fun addFolder() {
- val intent = Intent(this, AddFolderActivity::class.java)
- startActivity(intent)
- }
-
- override fun getSelectedProject(): Project? {
- return Space.current?.projects?.find { it.id == mSelectedProjectId }
- }
-
- override fun projectClicked(project: Project) {
- val resultIntent = Intent()
- resultIntent.putExtra("SELECTED_FOLDER_ID", project.id)
- setResult(RESULT_OK, resultIntent)
- finish() // Close FoldersActivity and return to MainActivity
- }
-
- override fun projectEdit(project: Project) {
- val intent = Intent(this, EditFolderActivity::class.java).apply {
- putExtra(EditFolderActivity.EXTRA_CURRENT_PROJECT_ID, project.id)
- }
- startActivity(intent)
- }
-}
\ No newline at end of file
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
new file mode 100644
index 000000000..ef70129b5
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersFragment.kt
@@ -0,0 +1,147 @@
+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/GeneralSettingsActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt
deleted file mode 100644
index 42b101409..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt
+++ /dev/null
@@ -1,177 +0,0 @@
-package net.opendasharchive.openarchive.features.settings
-
-import android.app.Activity
-import android.content.Intent
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AlertDialog
-import androidx.preference.Preference
-import androidx.preference.PreferenceFragmentCompat
-import androidx.preference.SwitchPreferenceCompat
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.ActivitySettingsContainerBinding
-import net.opendasharchive.openarchive.features.core.BaseActivity
-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 org.koin.android.ext.android.inject
-
-
-class GeneralSettingsActivity: BaseActivity() {
-
- class Fragment: PreferenceFragmentCompat() {
-
- private val passcodeRepository by inject()
-
-// private var mCiConsentPref: SwitchPreferenceCompat? = null
-
- 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 onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
- setPreferencesFromResource(R.xml.prefs_general, rootKey)
-
- passcodePreference = findPreference(Prefs.PASSCODE_ENABLED)
-
-// findPreference(Prefs.PASSCODE_ENABLED)?.setOnPreferenceChangeListener { _, newValue ->
-// //Prefs.lockWithPasscode = newValue as Boolean
-// if (newValue as? Boolean == true) {
-//
-// val intent = Intent(context, PasscodeSetupActivity::class.java)
-// activityResultLauncher.launch(intent)
-// }
-// false
-// }
-
-
- 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
- AlertDialog.Builder(requireContext())
- .setTitle("Disable Passcode")
- .setMessage("Are you sure you want to disable the passcode?")
- .setPositiveButton("Yes") { _, _ ->
- passcodeRepository.clearPasscode()
- passcodePreference?.isChecked = false
-
- // Update the FLAG_SECURE dynamically
- (activity as? BaseActivity)?.updateScreenshotPrevention()
- }
- .setNegativeButton("No") { _, _ ->
- passcodePreference?.isChecked = true
- }
- .show()
- }
- // Return false to avoid the preference updating immediately
- false
- }
-
-// findPreference(Prefs.USE_TOR)?.setOnPreferenceChangeListener { _, newValue ->
-// val activity = activity ?: return@setOnPreferenceChangeListener true
-//
-// if (newValue as Boolean) {
-// if (!OrbotHelper.isOrbotInstalled(activity) && !OrbotHelper.isTorServicesInstalled(activity)) {
-// AlertHelper.show(activity,
-// R.string.prefs_install_tor_summary,
-// R.string.prefs_use_tor_title,
-// buttons = listOf(
-// AlertHelper.positiveButton(R.string.action_install) { _, _ ->
-// activity.startActivity(
-// OrbotHelper.getOrbotInstallIntent(activity))
-// },
-// AlertHelper.negativeButton(R.string.action_cancel)
-// ))
-//
-// return@setOnPreferenceChangeListener false
-// }
-// }
-//
-// true
-// }
-
- findPreference("proof_mode")?.setOnPreferenceClickListener {
- startActivity(Intent(context, ProofModeSettingsActivity::class.java))
-
- true
- }
-
- findPreference(Prefs.THEME)?.setOnPreferenceChangeListener { _, newValue ->
- Theme.set(Theme.get(newValue as? String))
-
- true
- }
-
- findPreference(Prefs.PROHIBIT_SCREENSHOTS)?.setOnPreferenceClickListener { _ ->
- 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
- }
-
-// mCiConsentPref = findPreference("health_checks")
-//
-// mCiConsentPref?.setOnPreferenceChangeListener { _, newValue ->
-// if (newValue as? Boolean == false) {
-// CleanInsightsManager.deny()
-// }
-// else {
-// startActivity(Intent(context, ConsentActivity::class.java))
-// }
-//
-// true
-// }
- }
-
-// override fun onResume() {
-// super.onResume()
-//
-// mCiConsentPref?.isChecked = CleanInsightsManager.hasConsent()
-// }
- }
-
-
- private lateinit var mBinding: ActivitySettingsContainerBinding
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- mBinding = ActivitySettingsContainerBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
- setupToolbar(title = getString(R.string.general))
-
- supportFragmentManager
- .beginTransaction()
- .replace(mBinding.container.id, Fragment())
- .commit()
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- if (item.itemId == android.R.id.home) {
- finish()
- return true
- }
-
- return super.onOptionsItemSelected(item)
- }
-}
\ No newline at end of file
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
new file mode 100644
index 000000000..5d21d775a
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/ProofModeScreen.kt
@@ -0,0 +1,199 @@
+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
index 000b96fc1..a6d3652c8 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/ProofModeSettingsActivity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/ProofModeSettingsActivity.kt
@@ -3,16 +3,19 @@ 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.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.core.text.HtmlCompat
import androidx.fragment.app.FragmentActivity
-import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
@@ -31,31 +34,40 @@ import java.io.IOException
import java.util.UUID
import javax.crypto.SecretKey
-class ProofModeSettingsActivity: BaseActivity() {
+class ProofModeSettingsActivity : BaseActivity() {
- class Fragment: PreferenceFragmentCompat() {
+ class Fragment : PreferenceFragmentCompat() {
- private val enrollBiometrics = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- findPreference(Prefs.USE_PROOFMODE_KEY_ENCRYPTION)?.let {
- MainScope().launch {
- enableProofModeKeyEncryption(it)
+ 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)
- findPreference("share_proofmode")?.onPreferenceClickListener =
- Preference.OnPreferenceClickListener {
- shareKey(requireActivity())
- true
- }
+ 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)
+ }
- findPreference(Prefs.USE_PROOFMODE)?.setOnPreferenceChangeListener { preference, newValue ->
+ getPrefByKey(R.string.pref_key_use_proof_mode)?.setOnPreferenceChangeListener { preference, newValue ->
if (newValue as Boolean) {
PermissionX.init(this)
- .permissions(Manifest.permission.READ_PHONE_STATE)
+ .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)
@@ -65,11 +77,17 @@ class ProofModeSettingsActivity: BaseActivity() {
.request { allGranted, _, _ ->
if (!allGranted) {
(preference as? SwitchPreferenceCompat)?.isChecked = false
- Toast.makeText(activity,"Please allow all permissions", Toast.LENGTH_LONG).show()
+ 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
}
}
}
@@ -77,20 +95,23 @@ class ProofModeSettingsActivity: BaseActivity() {
true
}
- val pkePreference = findPreference(Prefs.USE_PROOFMODE_KEY_ENCRYPTION)
+ 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
+ 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
+ Hbks.BiometryType.DeviceCredential -> R.string.prefs_proofmode_key_encryption_title_passcode
- else -> R.string.prefs_proofmode_key_encryption_title_all
- })
+ else -> R.string.prefs_proofmode_key_encryption_title_all
+ }
+ )
pkePreference?.setOnPreferenceChangeListener { _, newValue ->
if (newValue as Boolean) {
@@ -99,8 +120,7 @@ class ProofModeSettingsActivity: BaseActivity() {
} else {
enableProofModeKeyEncryption(pkePreference)
}
- }
- else {
+ } else {
if (Prefs.proofModeEncryptedPassphrase != null) {
Prefs.proofModeEncryptedPassphrase = null
@@ -112,8 +132,7 @@ class ProofModeSettingsActivity: BaseActivity() {
true
}
- }
- else {
+ } else {
pkePreference?.isVisible = false
}
}
@@ -141,6 +160,11 @@ class ProofModeSettingsActivity: BaseActivity() {
// 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
@@ -158,6 +182,29 @@ class ProofModeSettingsActivity: BaseActivity() {
.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 {
@@ -182,13 +229,16 @@ class ProofModeSettingsActivity: BaseActivity() {
intent.putExtra(Intent.EXTRA_TEXT, pubKey)
activity.startActivity(intent)
}
- }
- catch (ioe: IOException) {
+ } catch (ioe: IOException) {
Timber.d("error publishing key")
}
}
- private fun createPassphrase(key: SecretKey, activity: FragmentActivity?, completed: (passphrase: String?) -> Unit) {
+ private fun createPassphrase(
+ key: SecretKey,
+ activity: FragmentActivity?,
+ completed: (passphrase: String?) -> Unit
+ ) {
val passphrase = UUID.randomUUID().toString()
Hbks.encrypt(passphrase, key, activity) { ciphertext, _ ->
@@ -198,7 +248,11 @@ class ProofModeSettingsActivity: BaseActivity() {
Prefs.proofModeEncryptedPassphrase = ciphertext
- Hbks.decrypt(Prefs.proofModeEncryptedPassphrase, key, activity) { decrpytedPassphrase, _ ->
+ Hbks.decrypt(
+ Prefs.proofModeEncryptedPassphrase,
+ key,
+ activity
+ ) { decrpytedPassphrase, _ ->
if (decrpytedPassphrase == null || decrpytedPassphrase != passphrase) {
Prefs.proofModeEncryptedPassphrase = null
@@ -210,4 +264,6 @@ class ProofModeSettingsActivity: BaseActivity() {
}
}
}
-}
\ No newline at end of file
+}
+
+
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
index f9c600eb5..9139935c1 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt
@@ -1,7 +1,6 @@
package net.opendasharchive.openarchive.features.settings
import android.app.Activity
-import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.view.View
@@ -11,18 +10,29 @@ import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import net.opendasharchive.openarchive.R
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.AppConfig
import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository
import net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupActivity
-import net.opendasharchive.openarchive.features.spaces.SpacesActivity
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 appConfig by inject()
+
private val passcodeRepository by inject()
+ private val dialogManager: DialogStateManager by activityViewModel()
+
private var passcodePreference: SwitchPreferenceCompat? = null
@@ -71,20 +81,26 @@ class SettingsFragment : PreferenceFragmentCompat() {
activityResultLauncher.launch(intent)
} else {
// Show confirmation dialog
- AlertDialog.Builder(requireContext())
- .setTitle("Disable Passcode")
- .setMessage("Are you sure you want to disable the passcode?")
- .setPositiveButton("Yes") { _, _ ->
- passcodeRepository.clearPasscode()
- passcodePreference?.isChecked = false
-
- // Update the FLAG_SECURE dynamically
- (activity as? BaseActivity)?.updateScreenshotPrevention()
+ 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()
+ }
}
- .setNegativeButton("No") { _, _ ->
- passcodePreference?.isChecked = true
+ neutralButton {
+ action = {
+ passcodePreference?.isChecked = true
+ }
}
- .show()
+ }
}
// Return false to avoid the preference updating immediately
false
@@ -100,30 +116,98 @@ class SettingsFragment : PreferenceFragmentCompat() {
true
}
+ // Check if app masking is enabled in the app config
+ if (appConfig.appMaskingEnabled) {
+ getPrefByKey(R.string.pref_app_masking)?.setOnPreferenceClickListener {
+ val intent = Intent(context, SpaceSetupActivity::class.java).apply {
+ putExtra(
+ SpaceSetupActivity.LABEL_START_DESTINATION,
+ StartDestination.APP_MASKING.name
+ )
+ }
+ startActivity(intent)
+ true
+ }
+ } else {
+ // Remove the app masking preference if the feature is disabled
+ findPreference(getString(R.string.pref_app_masking))?.isVisible = false
+ }
+
getPrefByKey(R.string.pref_media_servers)?.setOnPreferenceClickListener {
- startActivity(Intent(context, SpacesActivity::class.java))
+ 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 {
- startActivity(Intent(context, FoldersActivity::class.java))
+ 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
}
- findPreference("proof_mode")?.setOnPreferenceClickListener {
+ getPrefByKey(R.string.pref_key_proof_mode)?.setOnPreferenceClickListener {
startActivity(Intent(context, ProofModeSettingsActivity::class.java))
true
}
findPreference(Prefs.USE_TOR)?.setOnPreferenceChangeListener { _, newValue ->
- //Prefs.useTor = (newValue as Boolean)
+ Prefs.useTor = (newValue as Boolean)
//torViewModel.updateTorServiceState()
- false
+ 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)
true
}
@@ -138,39 +222,14 @@ class SettingsFragment : PreferenceFragmentCompat() {
val packageManager = requireActivity().packageManager
val versionText = packageManager.getVersionName(requireActivity().packageName)
- findPreference("app_version")?.summary = versionText
+ getPrefByKey(R.string.pref_key_app_version)?.summary = versionText
}
- private fun getPrefByKey(key: Int): T? {
+ private fun getPrefByKey(key: Int): T? {
return findPreference(getString(key))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
-
- view.setPadding(0, 16.dpToPx(), 0, 0)
}
-
- fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt()
-
-
-// mBinding.btAbout.text = getString(R.string.action_about, getString(R.string.app_name))
-// mBinding.btAbout.styleAsLink()
-// mBinding.btAbout.setOnClickListener {
-// context?.openBrowser("https://open-archive.org/save")
-// }
-//
-// mBinding.btPrivacy.styleAsLink()
-// mBinding.btPrivacy.setOnClickListener {
-// context?.openBrowser("https://open-archive.org/privacy")
-// }
-//
-// val activity = activity
-//
-// if (activity != null) {
-// mBinding.version.text = getString(
-// R.string.version__,
-// activity.packageManager.getVersionName(activity.packageName)
-// )
-// }
-}
\ 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 d612e3341..aa2002d30 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
@@ -3,26 +3,27 @@ package net.opendasharchive.openarchive.features.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Info
-import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.rememberNavController
+import kotlinx.serialization.Serializable
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 net.opendasharchive.openarchive.features.internetarchive.presentation.login.DefaultScaffoldPreview
+import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview
@Composable
-fun SettingsScreen() {
+fun SettingsScreen(
+ onNavigateToCache: () -> Unit = {}
+) {
val context = LocalContext.current
@@ -47,6 +48,14 @@ fun SettingsScreen() {
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()
+ }
+ )
// Verify Category
preferenceCategory(title = { Text("Verify") }, key = "verify")
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
index 3d0962820..9fdf4a624 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupFragment.kt
@@ -4,69 +4,59 @@ 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.setFragmentResult
-import net.opendasharchive.openarchive.databinding.FragmentSpaceSetupBinding
+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.onboarding.BaseFragment
+import net.opendasharchive.openarchive.features.core.BaseFragment
import net.opendasharchive.openarchive.features.settings.passcode.AppConfig
-import net.opendasharchive.openarchive.util.extensions.hide
-import net.opendasharchive.openarchive.util.extensions.show
+import net.opendasharchive.openarchive.features.spaces.SpaceSetupScreen
import org.koin.android.ext.android.inject
-import kotlin.getValue
class SpaceSetupFragment : BaseFragment() {
private val appConfig by inject()
- private lateinit var binding: FragmentSpaceSetupBinding
-
override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?,
+ inflater: LayoutInflater,
+ container: ViewGroup?,
savedInstanceState: Bundle?
- ): View {
- binding = FragmentSpaceSetupBinding.inflate(inflater)
+ ): View = content {
- binding.webdav.setOnClickListener {
- setFragmentResult(RESULT_REQUEST_KEY, bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_WEBDAV))
+ // Prepare click lambdas that use the fragment’s business logic.
+ val onWebDavClick = {
+ findNavController().navigate(R.id.action_fragment_space_setup_to_fragment_web_dav)
}
- if (Space.has(Space.Type.INTERNET_ARCHIVE)) {
- this@SpaceSetupFragment.binding.internetArchive.hide()
- } else {
- binding.internetArchive.setOnClickListener {
- setFragmentResult(
- RESULT_REQUEST_KEY,
- bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_INTERNET_ARCHIVE)
- )
- }
+ // 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)
}
- if (appConfig.snowbirdEnabled) {
- binding.snowbird.show()
- } else {
- binding.snowbird.hide()
+ // Show/hide Snowbird based on config
+ val isDwebEnabled = appConfig.isDwebEnabled
+ val onDwebClicked = {
+ val action =
+ SpaceSetupFragmentDirections.actionFragmentSpaceSetupToFragmentSnowbird()
+ findNavController().navigate(action)
}
-
- binding.snowbird.setOnClickListener {
- setFragmentResult(RESULT_REQUEST_KEY, bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_RAVEN))
+ SaveAppTheme {
+ SpaceSetupScreen(
+ onWebDavClick = onWebDavClick,
+ isInternetArchiveAllowed = isInternetArchiveAllowed,
+ onInternetArchiveClick = onInternetArchiveClick,
+ isDwebEnabled = isDwebEnabled,
+ onDwebClicked = onDwebClicked
+ )
}
- return binding.root
- }
-
- companion object {
- const val RESULT_REQUEST_KEY = "space_setup_fragment_result"
- const val RESULT_BUNDLE_KEY = "space_setup_result_key"
- const val RESULT_VAL_DROPBOX = "dropbox"
- const val RESULT_VAL_WEBDAV = "webdav"
- const val RESULT_VAL_RAVEN = "raven"
- const val RESULT_VAL_INTERNET_ARCHIVE = "internet_archive"
- const val RESULT_VAL_GDRIVE = "gdrive"
}
- override fun getToolbarTitle() = "Select a Server"
+ override fun getToolbarTitle() = getString(R.string.space_setup_title)
override fun getToolbarSubtitle(): String? = null
override fun shouldShowBackButton() = true
}
\ No newline at end of file
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
index 5c94f6f1f..e08e741ce 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessFragment.kt
@@ -1,16 +1,21 @@
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.os.bundleOf
+import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.setFragmentResult
+import net.opendasharchive.openarchive.R
import net.opendasharchive.openarchive.databinding.FragmentSpaceSetupSuccessBinding
-import net.opendasharchive.openarchive.features.onboarding.BaseFragment
+import net.opendasharchive.openarchive.features.core.BaseFragment
+import net.opendasharchive.openarchive.features.main.MainActivity
+import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets
-class SpaceSetupSuccessFragment private constructor(): BaseFragment() {
- private lateinit var mBinding: FragmentSpaceSetupSuccessBinding
+class SpaceSetupSuccessFragment : BaseFragment() {
+ private lateinit var binding: FragmentSpaceSetupSuccessBinding
private var message = ""
override fun onCreate(savedInstanceState: Bundle?) {
@@ -24,33 +29,38 @@ class SpaceSetupSuccessFragment private constructor(): BaseFragment() {
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
- mBinding = FragmentSpaceSetupSuccessBinding.inflate(inflater)
+ 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 (message.isNotEmpty()) {
- mBinding.successMessage.text = message
+ binding.successMessage.text = message
}
- mBinding.btAuthenticate.setOnClickListener { _ ->
- setFragmentResult(RESP_DONE, bundleOf())
+ 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 mBinding.root
+ return binding.root
}
companion object {
- const val RESP_DONE = "space_setup_success_fragment_resp_done"
-
- const val ARG_MESSAGE = "space_setup_success_fragment_arg_message"
-
- @JvmStatic
- fun newInstance(message: String) =
- SpaceSetupSuccessFragment().apply {
- arguments = Bundle().apply {
- putString(ARG_MESSAGE, message)
- }
- }
+ const val ARG_MESSAGE = "message"
}
- override fun getToolbarTitle() = "Setup complete"
+ 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/app_masking/AppMaskingScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/app_masking/AppMaskingScreen.kt
new file mode 100644
index 000000000..9e1cd4f13
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/app_masking/AppMaskingScreen.kt
@@ -0,0 +1,546 @@
+package net.opendasharchive.openarchive.features.settings.app_masking
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.compose.foundation.BorderStroke
+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.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Snackbar
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+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.Brush
+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.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.dp
+import androidx.compose.ui.res.colorResource
+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.util.Prefs
+
+private val maskGradients = mapOf(
+ AppMaskId.DEFAULT to listOf(Color(0xFF00B4A6), Color(0xFF00B4A6)),
+ AppMaskId.CALCULATOR to listOf(Color(0xFFF44336), Color(0xFF9C27B0)),
+ AppMaskId.DICTIONARY to listOf(Color(0xFFFF9800), Color(0xFFFFC107)),
+ AppMaskId.CALENDAR to listOf(Color(0xFF009688), Color(0xFF673AB7))
+)
+
+private sealed class MaskingUiState {
+ object Idle : MaskingUiState()
+ object Applying : MaskingUiState()
+ data class Success(val mask: AppMask) : MaskingUiState()
+}
+
+@Composable
+fun AppMaskingScreen() {
+ val context = LocalContext.current
+ val maskOptions = remember(context) { AppMaskingUtils.getMaskOptions(context) }
+ var currentMask by remember { mutableStateOf(AppMaskingUtils.getCurrentMask(context)) }
+ var pendingMask by remember { mutableStateOf(null) }
+ var uiState by remember { mutableStateOf(MaskingUiState.Idle) }
+ var errorMessage by remember { mutableStateOf(null) }
+ val activity = context.findActivity()
+
+ SaveAppTheme {
+ AppMaskingScreenContent(
+ masks = maskOptions,
+ currentMask = currentMask,
+ uiState = uiState,
+ errorMessage = errorMessage,
+ onErrorDismissed = { errorMessage = null },
+ onMaskSelected = { mask ->
+ if (uiState is MaskingUiState.Idle) {
+ pendingMask = mask
+ }
+ }
+ )
+
+ MaskConfirmSheet(
+ mask = pendingMask,
+ onDismiss = {
+ if (uiState is MaskingUiState.Idle) {
+ pendingMask = null
+ }
+ },
+ onConfirm = { mask ->
+ pendingMask = null
+ uiState = MaskingUiState.Applying
+
+ // Track start time for minimum progress display
+ val startTime = System.currentTimeMillis()
+
+ // Apply the mask change
+ val result = AppMaskingUtils.setLauncherActivityAlias(context.applicationContext, mask)
+
+ result.fold(
+ onSuccess = {
+ Prefs.returnToSettingsAfterRestart = true
+ currentMask = mask
+
+ // Ensure progress is shown for minimum 600ms for better UX
+ val elapsed = System.currentTimeMillis() - startTime
+ val remainingDelay = (600 - elapsed).coerceAtLeast(0)
+
+ // Show progress for minimum duration, then transition to success
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ uiState = MaskingUiState.Success(mask)
+
+ // Show success state, then restart
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ AppMaskingUtils.restartApp(activity)
+ }, 800)
+ }, remainingDelay)
+ },
+ onFailure = {
+ uiState = MaskingUiState.Idle
+ errorMessage = context.getString(R.string.app_mask_error)
+ }
+ )
+ }
+ )
+ }
+}
+
+@Composable
+private fun AppMaskingScreenContent(
+ masks: List,
+ currentMask: AppMask,
+ uiState: MaskingUiState,
+ errorMessage: String?,
+ onErrorDismissed: () -> Unit,
+ onMaskSelected: (AppMask) -> Unit
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ androidx.compose.runtime.LaunchedEffect(errorMessage) {
+ errorMessage?.let {
+ snackbarHostState.showSnackbar(it)
+ onErrorDismissed()
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ item {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp)) {
+ Text(
+ text = stringResource(id = R.string.app_mask_subheading),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ InfoBanner()
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+
+ items(masks, key = { it.id }) { mask ->
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ MaskCard(
+ mask = mask,
+ isActive = mask.alias == currentMask.alias,
+ onSelect = { onMaskSelected(mask) }
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+ }
+ }
+
+ // Progress overlay while applying mask
+ if (uiState is MaskingUiState.Applying) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.tertiary,
+ strokeWidth = 3.dp
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(id = R.string.app_mask_applying),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+
+ // Success overlay with new mask icon before restart
+ if (uiState is MaskingUiState.Success) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ MaskIcon(
+ mask = uiState.mask,
+ size = 96.dp,
+ iconSize = 48.dp,
+ cornerRadius = 28.dp
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = stringResource(id = R.string.app_mask_success),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(id = R.string.app_mask_restarting),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+
+ SnackbarHost(
+ hostState = snackbarHostState,
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ }
+}
+
+@Composable
+private fun InfoBanner() {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ 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.tertiary,
+ contentDescription = null
+ )
+ Text(
+ text = stringResource(id = R.string.app_mask_tip_description),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+}
+
+@Composable
+private fun MaskCard(
+ mask: AppMask,
+ isActive: Boolean,
+ onSelect: () -> Unit
+) {
+ val gradient = gradientFor(mask)
+ val badgeColor = gradient.first()
+ ElevatedCard(
+ onClick = {
+ if (!isActive) {
+ onSelect()
+ }
+ },
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ disabledContainerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ MaskIcon(mask = mask, size = 68.dp, iconSize = 36.dp)
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = stringResource(id = mask.titleRes),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(id = mask.descriptionRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ if (isActive) {
+ ActiveBadge(primaryTint = badgeColor)
+ } else {
+ TextButton(
+ onClick = onSelect,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.tertiary
+ )
+ ) {
+ Text(text = stringResource(id = R.string.app_mask_use_this_look))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ActiveBadge(primaryTint: Color) {
+ Surface(
+ color = primaryTint.copy(alpha = 0.12f),
+ border = BorderStroke(1.dp, primaryTint.copy(alpha = 0.4f)),
+ shape = RoundedCornerShape(50)
+ ) {
+ Text(
+ text = stringResource(id = R.string.app_mask_active_badge),
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+
+private fun gradientFor(mask: AppMask): List {
+ return maskGradients[mask.id] ?: listOf(Color(0xFF00B4A6), Color(0xFF00B4A6))
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun MaskConfirmSheet(
+ mask: AppMask?,
+ onDismiss: () -> Unit,
+ onConfirm: (AppMask) -> Unit
+) {
+ val sheetMask = mask ?: return
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ dragHandle = null,
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.onSurface
+ ) {
+
+ MaskConfirmSheetContent(
+ sheetMask = sheetMask,
+ onDismiss = onDismiss,
+ onConfirm = onConfirm
+ )
+
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun MaskConfirmSheetContent(
+ sheetMask: AppMask,
+ onDismiss: () -> Unit,
+ onConfirm: (AppMask) -> Unit
+) {
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+
+ MaskIcon(mask = sheetMask, size = 96.dp, iconSize = 48.dp, cornerRadius = 28.dp)
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(id = R.string.app_mask_confirm_title),
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(
+ id = R.string.app_mask_confirm_description,
+ stringResource(id = sheetMask.titleRes)
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ TextButton(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = colorResource(R.color.colorOnBackground)
+ )
+ ) {
+ Text(text = stringResource(id = R.string.action_cancel))
+ }
+
+ Button(
+ onClick = { onConfirm(sheetMask) },
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.tertiary,
+ contentColor = colorResource(R.color.black)
+ )
+ ) {
+ Text(text = stringResource(id = R.string.app_mask_confirm_action))
+ }
+ }
+ }
+
+}
+
+private tailrec fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
+
+@Preview
+@Composable
+private fun AppMaskingScreenPreview() {
+ val mockMasks = listOf(
+ AppMask(
+ AppMaskId.DEFAULT,
+ "a",
+ R.string.app_mask_default_label,
+ R.string.app_mask_default_description,
+ R.drawable.ic_mask_save_icon
+ ),
+ AppMask(
+ AppMaskId.CALCULATOR,
+ "b",
+ R.string.app_mask_calculator_label,
+ R.string.app_mask_calculator_description,
+ R.drawable.ic_mask_save_calculator
+ ),
+ AppMask(
+ AppMaskId.CALENDAR,
+ "c",
+ R.string.app_mask_calendar_label,
+ R.string.app_mask_calendar_description,
+ R.drawable.ic_mask_save_calendar
+ )
+ )
+ DefaultScaffoldPreview {
+ AppMaskingScreenContent(
+ masks = mockMasks,
+ currentMask = mockMasks.first(),
+ uiState = MaskingUiState.Applying,
+ errorMessage = null,
+ onErrorDismissed = {},
+ onMaskSelected = {}
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MaskConfirmSheetPreview() {
+ DefaultScaffoldPreview {
+
+ MaskConfirmSheetContent(
+ sheetMask = AppMask(
+ AppMaskId.DICTIONARY,
+ "b",
+ R.string.app_mask_dictionary_label,
+ R.string.app_mask_dictionary_description,
+ R.drawable.ic_mask_save_dictionary
+ ),
+ onDismiss = {},
+ onConfirm = {}
+
+ )
+ }
+}
+
+@Composable
+private fun MaskIcon(
+ mask: AppMask,
+ size: Dp = 68.dp,
+ iconSize: Dp = 36.dp,
+ cornerRadius: Dp = 20.dp
+) {
+ val gradient = gradientFor(mask)
+ Box(
+ modifier = Modifier
+ .size(size)
+ .background(
+ brush = Brush.linearGradient(gradient),
+ shape = RoundedCornerShape(cornerRadius)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(id = mask.iconRes),
+ contentDescription = null,
+ tint = Color.Unspecified,
+ modifier = Modifier.size(iconSize)
+ )
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/app_masking/AppMaskingUtils.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/app_masking/AppMaskingUtils.kt
new file mode 100644
index 000000000..fceefbb2c
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/app_masking/AppMaskingUtils.kt
@@ -0,0 +1,129 @@
+package net.opendasharchive.openarchive.features.settings.app_masking
+
+import android.app.Activity
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.core.content.edit
+import net.opendasharchive.openarchive.R
+
+enum class AppMaskId {
+ DEFAULT,
+ CALCULATOR,
+ DICTIONARY,
+ CALENDAR
+}
+
+data class AppMask(
+ val id: AppMaskId,
+ val alias: String,
+ @param:StringRes val titleRes: Int,
+ @param:StringRes val descriptionRes: Int,
+ @param:DrawableRes val iconRes: Int
+)
+
+object AppMaskingUtils {
+
+ private const val PREFS_NAME = "app_masking_prefs"
+ private const val KEY_ENABLED_ALIAS = "key_enabled_alias"
+
+ private const val DEFAULT_ALIAS_SUFFIX = "SaveAlias"
+ private const val CALCULATOR_ALIAS_SUFFIX = "MaskAlias"
+ private const val DICTIONARY_ALIAS_SUFFIX = "DictionaryAlias"
+ private const val CALENDAR_ALIAS_SUFFIX = "CalendarAlias"
+
+ fun getMaskOptions(context: Context): List {
+ val packageName = context.packageName
+ return listOf(
+ AppMask(
+ id = AppMaskId.DEFAULT,
+ alias = packageName.alias(DEFAULT_ALIAS_SUFFIX),
+ titleRes = R.string.app_mask_default_label,
+ descriptionRes = R.string.app_mask_default_description,
+ iconRes = R.drawable.ic_mask_save_icon
+ ),
+ AppMask(
+ id = AppMaskId.CALCULATOR,
+ alias = packageName.alias(CALCULATOR_ALIAS_SUFFIX),
+ titleRes = R.string.app_mask_calculator_label,
+ descriptionRes = R.string.app_mask_calculator_description,
+ iconRes = R.drawable.ic_mask_save_calculator
+ ),
+ AppMask(
+ id = AppMaskId.DICTIONARY,
+ alias = packageName.alias(DICTIONARY_ALIAS_SUFFIX),
+ titleRes = R.string.app_mask_dictionary_label,
+ descriptionRes = R.string.app_mask_dictionary_description,
+ iconRes = R.drawable.ic_mask_save_dictionary
+ ),
+ AppMask(
+ id = AppMaskId.CALENDAR,
+ alias = packageName.alias(CALENDAR_ALIAS_SUFFIX),
+ titleRes = R.string.app_mask_calendar_label,
+ descriptionRes = R.string.app_mask_calendar_description,
+ iconRes = R.drawable.ic_mask_save_calendar
+ )
+ )
+ }
+
+ fun getCurrentAlias(context: Context): String {
+ return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .getString(
+ KEY_ENABLED_ALIAS,
+ context.packageName.alias(DEFAULT_ALIAS_SUFFIX)
+ ) ?: context.packageName.alias(DEFAULT_ALIAS_SUFFIX)
+ }
+
+ fun getCurrentMask(context: Context): AppMask {
+ val currentAlias = getCurrentAlias(context)
+ return getMaskOptions(context).firstOrNull { it.alias == currentAlias }
+ ?: getMaskOptions(context).first()
+ }
+
+ fun getCurrentAliasDisplayName(context: Context): String {
+ val mask = getCurrentMask(context)
+ return context.getString(mask.titleRes)
+ }
+
+ fun setLauncherActivityAlias(context: Context, mask: AppMask): Result {
+ return try {
+ val pm = context.packageManager
+ val allAliases = getMaskOptions(context).map { it.alias }.distinct()
+
+ allAliases.forEach { alias ->
+ val newState =
+ if (alias == mask.alias) {
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+ } else {
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
+ }
+ val componentName = ComponentName(context, alias)
+ pm.setComponentEnabledSetting(componentName, newState, PackageManager.DONT_KILL_APP)
+ }
+
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
+ putString(KEY_ENABLED_ALIAS, mask.alias)
+ }
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ fun restartApp(activity: Activity?) {
+ activity ?: return
+ val launchIntent = activity.packageManager
+ .getLaunchIntentForPackage(activity.packageName)
+ ?.apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ } ?: return
+ activity.startActivity(launchIntent)
+ activity.finishAffinity()
+ }
+
+ private fun String.alias(suffix: String): String = "$this.alias.$suffix"
+}
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
new file mode 100644
index 000000000..9f82b1498
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseFragment.kt
@@ -0,0 +1,147 @@
+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
+ )
+ 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
+ )
+ 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
new file mode 100644
index 000000000..941f8f728
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/license/SetupLicenseScreen.kt
@@ -0,0 +1,437 @@
+package net.opendasharchive.openarchive.features.settings.license
+
+import androidx.compose.foundation.layout.*
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+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.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.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
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SetupLicenseScreen(
+ onNext: () -> Unit = {},
+ onCancel: () -> Unit = {},
+ viewModel: SetupLicenseViewModel = viewModel()
+) {
+
+ val state by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collect { event ->
+ when (event) {
+ is SetupLicenseEvent.NavigateNext -> onNext()
+ is SetupLicenseEvent.NavigateBack -> onCancel()
+ }
+ }
+ }
+
+ SetupLicenseScreenContent(
+ state = state,
+ onAction = viewModel::onAction
+ )
+}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SetupLicenseScreenContent(
+ state: SetupLicenseState,
+ onAction: (SetupLicenseAction) -> Unit
+) {
+
+
+
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp)
+ .padding(top = 48.dp, bottom = 16.dp)
+ ) {
+ // Content section
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ 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
+ )
+ }
+
+ // 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
+ )
+ )
+
+ // Creative Commons License Section
+ 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(SetupLicenseAction.UpdateCcEnabled(enabled))
+ }
+
+ override fun onAllowRemixChange(allowed: Boolean) {
+ onAction(SetupLicenseAction.UpdateAllowRemix(allowed))
+ }
+
+ override fun onRequireShareAlikeChange(required: Boolean) {
+ onAction(SetupLicenseAction.UpdateRequireShareAlike(required))
+ }
+
+ override fun onAllowCommercialChange(allowed: Boolean) {
+ onAction(SetupLicenseAction.UpdateAllowCommercial(allowed))
+ }
+
+ override fun onCc0EnabledChange(enabled: Boolean) {
+ onAction(SetupLicenseAction.UpdateCc0Enabled(enabled))
+ }
+ },
+ ccLabelText = stringResource(R.string.set_creative_commons_license_for_all_folders_on_this_server)
+ )
+ }
+
+ // Button bar (hidden in edit mode)
+ if (!state.isEditing) {
+ Row(
+ 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
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun WebDavSetupLicenseScreenPreview() {
+ SaveAppTheme {
+ SetupLicenseScreenContent(
+ state = SetupLicenseState(
+ ccEnabled = true
+ ),
+ 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/passcode/AppConfig.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/AppConfig.kt
index d837f93b5..5f7f678a0 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/AppConfig.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/AppConfig.kt
@@ -6,5 +6,8 @@ data class AppConfig(
val maxRetryLimitEnabled: Boolean = false,
val biometricAuthEnabled: Boolean = false,
val maxFailedAttempts: Int = 5,
- val snowbirdEnabled: Boolean = false
+ val isDwebEnabled: Boolean = false,
+ val appMaskingEnabled: Boolean = true,
+ val multipleProjectSelectionMode: Boolean = false,
+ val useCustomCamera: Boolean = false,
)
\ 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 fa6b2322a..edf3e8e8e 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
@@ -28,4 +28,8 @@ class HapticManager(
}
}
}
+
+ fun clear() {
+ hapticFeedback = null
+ }
}
\ No newline at end of file
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 5b379134b..827ca7b9d 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
@@ -26,6 +26,7 @@ object MessageManager {
@Composable
fun DefaultScaffold(
modifier: Modifier = Modifier,
+ topAppBar: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit
) {
@@ -39,6 +40,9 @@ fun DefaultScaffold(
Scaffold(
modifier = modifier.fillMaxSize(),
+ topBar = {
+ topAppBar?.invoke()
+ },
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
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 f4bd63bce..9b56aed2d 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
@@ -19,8 +19,10 @@ 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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -28,12 +30,15 @@ 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.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.core.presentation.theme.SaveAppTheme
+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
@@ -42,13 +47,15 @@ private val keys = listOf(
"1", "2", "3",
"4", "5", "6",
"7", "8", "9",
- "", "0"
+ "delete", "0", "submit"
)
@Composable
fun NumericKeypad(
isEnabled: Boolean = true,
onNumberClick: (String) -> Unit,
+ onDeleteClick: () -> Unit,
+ onSubmitClick: () -> Unit
) {
Box(
@@ -73,7 +80,11 @@ fun NumericKeypad(
label = label,
enabled = isEnabled,
onClick = {
- onNumberClick(label)
+ when (label) {
+ "delete" -> onDeleteClick()
+ "submit" -> onSubmitClick()
+ else -> onNumberClick(label)
+ }
}
)
} else {
@@ -88,36 +99,33 @@ fun NumericKeypad(
@Preview
@Composable
private fun NumericKeypadPreview() {
- SaveAppTheme {
- Scaffold {
- Box(
- modifier = Modifier.padding(it),
- contentAlignment = Alignment.Center
- ) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background),
- verticalArrangement = Arrangement.SpaceAround,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
+ DefaultScaffoldPreview {
- // Custom numeric keypad
- NumericKeypad(
- isEnabled = true,
- onNumberClick = { number ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ verticalArrangement = Arrangement.SpaceAround,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
- }
- )
+ // Custom numeric keypad
+ NumericKeypad(
+ isEnabled = true,
+ onNumberClick = { number ->
- Spacer(modifier = Modifier.height(16.dp))
+ },
+ onDeleteClick = {},
+ onSubmitClick = {}
+ )
+ Spacer(modifier = Modifier.height(16.dp))
- }
- }
}
+
}
+
}
@Composable
@@ -130,8 +138,41 @@ private fun NumberButton(
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
+
+ // Determine background color based on button type and pressed state
val backgroundColor by animateColorAsState(
- targetValue = if (isPressed) MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) else Color.Transparent,
+ 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)
+ }
+
+ 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)
+ }
+
+ else -> when (label) {
+ "delete" -> Color.Transparent
+ "submit" -> Color.Transparent
+ else -> MaterialTheme.colorScheme.tertiary
+ }
+ },
animationSpec = spring(),
label = ""
)
@@ -144,21 +185,35 @@ private fun NumberButton(
indication = null,
enabled = enabled,
onClick = {
- hapticManager.performHapticFeedback(AppHapticFeedbackType.KeyPress)
+ hapticManager?.performHapticFeedback(AppHapticFeedbackType.KeyPress)
onClick()
}
)
- .border(width = 2.dp, color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.5f), shape = CircleShape)
+ .border(width = 2.dp, color = borderColor, shape = CircleShape)
.size(72.dp),
contentAlignment = Alignment.Center
) {
- Text(
- text = label,
- style = TextStyle(
- fontSize = 24.sp,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground
+
+ when (label) {
+ "delete" -> Icon(
+ painter = painterResource(R.drawable.ic_backspace),
+ contentDescription = "Delete",
+ tint = MaterialTheme.colorScheme.onBackground
+ )
+
+ "submit" -> Icon(
+ painter = painterResource(R.drawable.ic_arrow_submit),
+ contentDescription = "Submit",
+ tint = MaterialTheme.colorScheme.onBackground
)
- )
+
+ else -> Text(
+ text = label,
+ style = MaterialTheme.typography.headlineSmall.copy(
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ )
+ }
}
}
\ No newline at end of file
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 1e143abb6..9b093b75d 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
@@ -4,8 +4,10 @@ import android.os.Bundle
import android.widget.Toast
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.settings.passcode.PasscodeRepository
import net.opendasharchive.openarchive.features.settings.passcode.components.DefaultScaffold
import org.koin.android.ext.android.inject
@@ -13,6 +15,7 @@ import org.koin.android.ext.android.inject
class PasscodeEntryActivity : BaseActivity() {
private val repository: PasscodeRepository by inject()
+ private val hapticManager: HapticManager by inject()
private val onBackPressedCallback = object : OnBackPressedCallback(enabled = true) {
override fun handleOnBackPressed() {
@@ -32,7 +35,7 @@ class PasscodeEntryActivity : BaseActivity() {
if (repository.isLockedOut()) {
Toast.makeText(
this,
- "App is locked due to multiple failed attempts. Please try again later.",
+ getString(R.string.multiple_failed_attempts_message),
Toast.LENGTH_LONG
).show()
finishAndRemoveTask()
@@ -54,4 +57,9 @@ class PasscodeEntryActivity : BaseActivity() {
}
}
}
+
+ 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_entry/PasscodeEntryScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/passcode/passcode_entry/PasscodeEntryScreen.kt
index 38b01d7e0..f7e2495ca 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
@@ -28,9 +28,13 @@ 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.settings.passcode.AppHapticFeedbackType
@@ -52,6 +56,8 @@ fun PasscodeEntryScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
val hapticFeedback = LocalHapticFeedback.current
@@ -66,21 +72,21 @@ fun PasscodeEntryScreen(
PasscodeEntryUiEvent.Success -> onPasscodeSuccess()
PasscodeEntryUiEvent.PasscodeNotSet -> {
- MessageManager.showMessage("Passcode not set")
+ MessageManager.showMessage(context.getString(R.string.passcode_not_set))
}
is PasscodeEntryUiEvent.IncorrectPasscode -> {
hapticManager.performHapticFeedback(AppHapticFeedbackType.Error)
event.remainingAttempts?.let {
- val message = "Incorrect passcode. $it attempts remaining."
+ val message = context.getString(R.string.passcode_remaining_attempts, it)//"Incorrect passcode. $it attempts remaining."
MessageManager.showMessage(message)
}
}
PasscodeEntryUiEvent.LockedOut -> {
- MessageManager.showMessage("Too many failed attempts. App is locked.")
+ MessageManager.showMessage(context.getString(R.string.passcode_too_many_failed_attempts))
onExit()
}
}
@@ -137,7 +143,8 @@ fun PasscodeEntryScreenContent(
) {
Text(
- text = "Enter Your Passcode", style = TextStyle(
+ text = stringResource(R.string.enter_passcode),
+ style = MaterialTheme.typography.bodyLarge.copy(
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
@@ -160,52 +167,56 @@ fun PasscodeEntryScreenContent(
isEnabled = !state.isProcessing,
onNumberClick = { number ->
onAction(PasscodeEntryScreenAction.OnNumberClick(number))
+ },
+ onDeleteClick = {
+ onAction(PasscodeEntryScreenAction.OnBackspaceClick)
+ },
+ onSubmitClick = {
+
}
)
Spacer(modifier = Modifier.height(16.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceAround
- ) {
- TextButton(
- onClick = {
- onExit()
- }
- ) {
- Text(
- text = "Exit",
- modifier = Modifier.padding(8.dp),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground
- ),
- )
- }
-
- TextButton(
- enabled = state.passcode.isNotEmpty(),
- onClick = {
- onAction(PasscodeEntryScreenAction.OnBackspaceClick)
- }
- ) {
- Text(
- text = "Delete",
- modifier = Modifier.padding(8.dp),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground
- ),
- )
- }
-
-
- }
-
+ Spacer(modifier = Modifier.height(16.dp))
+// Row(
+// modifier = Modifier.fillMaxWidth(),
+// horizontalArrangement = Arrangement.SpaceAround
+// ) {
+// TextButton(
+// onClick = {
+// onExit()
+// }
+// ) {
+// Text(
+// text = "Exit",
+// modifier = Modifier.padding(8.dp),
+// style = TextStyle(
+// fontSize = 16.sp,
+// fontWeight = FontWeight.Bold,
+// color = MaterialTheme.colorScheme.onBackground
+// ),
+// )
+// }
+//
+// TextButton(
+// enabled = state.passcode.isNotEmpty(),
+// onClick = {
+// onAction(PasscodeEntryScreenAction.OnBackspaceClick)
+// }
+// ) {
+// Text(
+// text = "Delete",
+// modifier = Modifier.padding(8.dp),
+// style = TextStyle(
+// fontSize = 16.sp,
+// fontWeight = FontWeight.Bold,
+// color = MaterialTheme.colorScheme.onBackground
+// ),
+// )
+// }
+// }
}
}
}
@@ -216,15 +227,15 @@ fun PasscodeEntryScreenContent(
@Composable
private fun PasscodeEntryScreenPreview() {
- DefaultScaffoldPreview {
- SaveAppTheme {
- PasscodeEntryScreenContent(
- state = PasscodeEntryScreenState(
- passcodeLength = 6
- ),
- onAction = {},
- onExit = {},
- )
- }
+ DefaultEmptyScaffoldPreview {
+
+ PasscodeEntryScreenContent(
+ state = PasscodeEntryScreenState(
+ passcodeLength = 6
+ ),
+ onAction = {},
+ onExit = {},
+ )
+
}
}
\ 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 1f9f8b57b..0cb3f2a70 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
@@ -38,6 +38,7 @@ class PasscodeEntryViewModel(
when (action) {
is PasscodeEntryScreenAction.OnNumberClick -> onNumberClick(action.number)
PasscodeEntryScreenAction.OnBackspaceClick -> onBackspaceClick()
+ PasscodeEntryScreenAction.OnSubmit -> onSubmit()
}
}
@@ -71,6 +72,10 @@ class PasscodeEntryViewModel(
}
}
+ private fun onSubmit() {
+
+ }
+
private fun checkPasscode() = viewModelScope.launch {
val currentState = uiState.value
val currentPasscode = currentState.passcode
@@ -126,6 +131,7 @@ data class PasscodeEntryScreenState(
sealed class PasscodeEntryScreenAction {
data class OnNumberClick(val number: String) : PasscodeEntryScreenAction()
data object OnBackspaceClick : PasscodeEntryScreenAction()
+ data object OnSubmit: PasscodeEntryScreenAction()
}
sealed class PasscodeEntryUiEvent {
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 c36ca9190..506d5dabb 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
@@ -4,34 +4,48 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
-import androidx.activity.OnBackPressedCallback
+import androidx.activity.compose.BackHandler
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.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() {
+ private val hapticManager: HapticManager by inject()
+
companion object {
const val EXTRA_PASSCODE_ENABLED = "passcode_enabled"
}
- private val onBackPressedCallback = object : OnBackPressedCallback(enabled = true) {
- override fun handleOnBackPressed() {
- setResult(RESULT_CANCELED)
- finish()
- }
- }
-
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- onBackPressedDispatcher.addCallback(onBackPressedCallback)
-
setContent {
SaveAppTheme {
- DefaultScaffold {
+ DefaultScaffold(
+ topAppBar = {
+ ComposeAppBar(
+ title = stringResource(R.string.passcode_lock_app),
+ onNavigationAction = {
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+ )
+ }
+ ) {
+
+ // Handle back press inside Compose
+ BackHandler {
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+
PasscodeSetupScreen(
onPasscodeSet = {
// Passcode successfully set
@@ -60,4 +74,9 @@ 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 229de7831..00c0aa191 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
@@ -1,29 +1,26 @@
package net.opendasharchive.openarchive.features.settings.passcode.passcode_setup
import android.content.res.Configuration.UI_MODE_NIGHT_YES
-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
import androidx.compose.foundation.layout.height
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.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.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.res.colorResource
+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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -51,6 +48,8 @@ fun PasscodeSetupScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(Unit) {
@@ -64,7 +63,7 @@ fun PasscodeSetupScreen(
PasscodeSetupUiEvent.PasscodeSet -> onPasscodeSet()
PasscodeSetupUiEvent.PasscodeDoNotMatch -> {
hapticManager.performHapticFeedback(AppHapticFeedbackType.Error)
- MessageManager.showMessage("Passcodes do not match. Try again.")
+ MessageManager.showMessage(context.getString(R.string.passcode_do_not_match))
}
PasscodeSetupUiEvent.PasscodeCancelled -> onCancel()
@@ -99,27 +98,41 @@ private fun PasscodeSetupScreenContent(
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxWidth()
+ .padding(top = 32.dp)
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
) {
- Image(
- painter = painterResource(R.drawable.savelogo),
- contentDescription = null,
- modifier = Modifier.size(100.dp),
- contentScale = ContentScale.Fit
+ Text(
+ text = if (state.isConfirming) stringResource(R.string.confirm_passcode) else stringResource(R.string.set_passcode),
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold,
+ ),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
- text = "Remember this PIN. If you forget it, you will need to reset the application and all data will be erased.",
- color = MaterialTheme.colorScheme.error,
- textAlign = TextAlign.Center,
- fontWeight = FontWeight.Light,
- style = MaterialTheme.typography.labelMedium
+ text = stringResource(R.string.set_passcode_warning),
+ style = MaterialTheme.typography.bodySmall.copy(
+ color = colorResource(R.color.red_bg),
+ fontSize = 15.sp, // overrides labelMedium’s size
+ fontWeight = FontWeight.Medium, // ensures consistent weight
+ textAlign = TextAlign.Center,
+ fontStyle = FontStyle.Normal
+ )
)
}
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Passcode dots display
+ PasscodeDots(
+ passcodeLength = state.passcodeLength,
+ currentPasscodeLength = state.passcode.length,
+ shouldShake = state.shouldShake
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
// Middle section with prompt and passcode dots
Column(
@@ -129,76 +142,61 @@ private fun PasscodeSetupScreenContent(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
- Text(
- text = if (state.isConfirming) "Confirm Your Passcode" else "Set Your Passcode",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground
- )
- )
-
-
- Spacer(modifier = Modifier.height(32.dp))
-
- // Passcode dots display
- PasscodeDots(
- passcodeLength = state.passcodeLength,
- currentPasscodeLength = state.passcode.length,
- shouldShake = state.shouldShake
- )
-
- Spacer(modifier = Modifier.height(32.dp))
// Custom numeric keypad
NumericKeypad(
isEnabled = !state.isProcessing,
onNumberClick = { number ->
onAction(PasscodeSetupUiAction.OnNumberClick(number))
+ },
+ onDeleteClick = {
+ onAction(PasscodeSetupUiAction.OnBackspaceClick)
+ },
+ onSubmitClick = {
+ onAction(PasscodeSetupUiAction.OnSubmit)
}
)
- Spacer(modifier = Modifier.height(16.dp))
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceAround
- ) {
- TextButton(
- onClick = {
- onAction(PasscodeSetupUiAction.OnCancel)
- }
- ) {
- Text(
- text = "Cancel",
- modifier = Modifier.padding(8.dp),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Bold,
- ),
- )
- }
-
- TextButton(
- enabled = state.passcode.isNotEmpty(),
- onClick = {
- onAction(PasscodeSetupUiAction.OnBackspaceClick)
- }
- ) {
- Text(
- text = "Delete",
- modifier = Modifier.padding(8.dp),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Bold
- ),
- )
- }
-
-
- }
+ Spacer(modifier = Modifier.height(64.dp))
+
+
+// Row(
+// modifier = Modifier.fillMaxWidth(),
+// horizontalArrangement = Arrangement.SpaceAround
+// ) {
+// TextButton(
+// onClick = {
+// onAction(PasscodeSetupUiAction.OnCancel)
+// }
+// ) {
+// Text(
+// text = "Cancel",
+// modifier = Modifier.padding(8.dp),
+// style = TextStyle(
+// fontSize = 16.sp,
+// fontWeight = FontWeight.Bold,
+// ),
+// )
+// }
+//
+// TextButton(
+// enabled = state.passcode.isNotEmpty(),
+// onClick = {
+// onAction(PasscodeSetupUiAction.OnBackspaceClick)
+// }
+// ) {
+// Text(
+// text = "Delete",
+// modifier = Modifier.padding(8.dp),
+// style = TextStyle(
+// fontSize = 16.sp,
+// fontWeight = FontWeight.Bold
+// ),
+// )
+// }
+//
+//
+// }
}
}
}
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 01d3e7a83..1af8b2ae7 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
@@ -30,6 +30,7 @@ class PasscodeSetupViewModel(
is PasscodeSetupUiAction.OnNumberClick -> onNumberClick(action.number)
PasscodeSetupUiAction.OnBackspaceClick -> onBackspaceClick()
PasscodeSetupUiAction.OnCancel -> onCancel()
+ PasscodeSetupUiAction.OnSubmit -> onSubmit()
}
}
@@ -56,11 +57,11 @@ class PasscodeSetupViewModel(
else state.copy(passcode = state.passcode + number)
}
- // Process passcode only when the required length is reached
- if (_uiState.value.passcode.length == config.passcodeLength) {
- _uiState.update { it.copy(isProcessing = true) }
- processPasscodeEntry()
- }
+// // Process passcode only when the required length is reached
+// if (_uiState.value.passcode.length == config.passcodeLength) {
+// _uiState.update { it.copy(isProcessing = true) }
+// processPasscodeEntry()
+// }
}
private fun onBackspaceClick() {
@@ -75,6 +76,16 @@ class PasscodeSetupViewModel(
}
}
+ private fun onSubmit() {
+ val state = _uiState.value
+
+ // Ensure passcode length is correct before submission
+ if (state.passcode.length == config.passcodeLength) {
+ _uiState.update { it.copy(isProcessing = true) }
+ processPasscodeEntry()
+ }
+ }
+
private fun processPasscodeEntry() = viewModelScope.launch {
val state = uiState.value // current state
if (state.isConfirming) {
@@ -138,6 +149,7 @@ sealed class PasscodeSetupUiAction {
data class OnNumberClick(val number: String) : PasscodeSetupUiAction()
data object OnBackspaceClick : PasscodeSetupUiAction()
data object OnCancel : PasscodeSetupUiAction()
+ data object OnSubmit: PasscodeSetupUiAction()
}
sealed class PasscodeSetupUiEvent {
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
new file mode 100644
index 000000000..7c3c8bc5b
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/ServerOptionItem.kt
@@ -0,0 +1,173 @@
+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
+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.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.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
+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.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 androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.core.presentation.theme.DefaultBoxPreview
+
+@Composable
+fun ServerOptionItem(
+ @DrawableRes iconRes: Int,
+ title: String,
+ subtitle: String,
+ onClick: () -> Unit
+) {
+ // You can customize this look to match your original design
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .clickable(onClick = onClick),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.background
+ ),
+ border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.surfaceVariant),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.Top)
+ .padding(top = 4.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = null,
+ tint = colorResource(R.color.colorTertiary),
+ modifier = Modifier
+ .size(24.dp)
+
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.Top)
+ .weight(1f),
+ verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.Top)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp
+ )
+ )
+
+ Row {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ )
+ Spacer(modifier = Modifier.weight(0.5f))
+ }
+ }
+
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .align(Alignment.CenterVertically),
+ painter = painterResource(R.drawable.ic_arrow_forward_ios),
+ contentDescription = null,
+ )
+ }
+
+
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun ServerOptionItemPreview() {
+ DefaultBoxPreview {
+
+ Column {
+ ServerOptionItem(
+ iconRes = R.drawable.ic_private_server,
+ title = stringResource(R.string.private_server),
+ subtitle = stringResource(R.string.send_directly_to_a_private_server),
+ onClick = {}
+ )
+ }
+
+
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun ServerOptionsItemPreview() {
+ DefaultBoxPreview {
+
+ Column {
+ ServerOptionItem(
+ iconRes = R.drawable.ic_private_server,
+ title = stringResource(R.string.private_server),
+ subtitle = stringResource(R.string.send_directly_to_a_private_server),
+ onClick = {}
+ )
+
+ ServerOptionItem(
+ iconRes = R.drawable.ic_internet_archive,
+ title = stringResource(R.string.internet_archive),
+ subtitle = stringResource(R.string.upload_to_the_internet_archive),
+ onClick = {}
+ )
+
+ ServerOptionItem(
+ iconRes = R.drawable.ic_dweb,
+ title = stringResource(R.string.dweb_title),
+ subtitle = stringResource(R.string.dweb_description),
+ onClick = {}
+ )
+ }
+
+
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..8d8cc5903
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt
@@ -0,0 +1,87 @@
+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
new file mode 100644
index 000000000..907f090af
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListScreen.kt
@@ -0,0 +1,255 @@
+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
+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.heightIn
+import androidx.compose.foundation.layout.padding
+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
+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.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.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.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.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()
+) {
+
+ val spaceList by viewModel.spaceList.collectAsStateWithLifecycle()
+
+ // This will get called again when the screen resumes (see Fragment below)
+ LaunchedEffect(Unit) {
+ viewModel.refreshSpaces()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ SpaceListScreenContent(
+ spaceList = spaceList,
+ onSpaceClicked = onSpaceClicked,
+ onAddServerClicked = onAddServerClicked
+ )
+ }
+}
+
+@Composable
+fun SpaceListScreenContent(
+ onSpaceClicked: (Space) -> Unit,
+ onAddServerClicked: () -> Unit,
+ spaceList: List = emptyList()
+) {
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (spaceList.isEmpty()) {
+ // Empty state with centered message
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = stringResource(R.string.lbl_no_servers),
+ style = MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ )
+ }
+ } else {
+ // List state
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ spaceList.forEach { space ->
+ SpaceListItem(
+ space = space,
+ onClick = {
+ onSpaceClicked(space)
+ }
+ )
+ }
+ }
+ }
+
+ // Add Server button at bottom center (visible in both states)
+ Button(
+ onClick = onAddServerClicked,
+ modifier = Modifier
+ .heightIn(ThemeDimensions.touchable)
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth(0.7f)
+ .padding(bottom = 48.dp),
+ 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)
+ )
+ ) {
+ Text(
+ text = "+ Add Server",
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ )
+ }
+
+
+/**
+ 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()
+ }
+ },
+ ) {
+ 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,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onClick()
+ },
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ SpaceIcon(
+ type = space.tType,
+ modifier = Modifier.size(42.dp)
+ )
+
+ Column(
+ verticalArrangement = Arrangement.Top
+ ) {
+ Text(
+ text = space.friendlyName,
+ style = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.onBackground,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 1.sp
+ )
+ )
+
+ Text(
+ text = space.tType.friendlyName,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 14.sp,
+ lineHeight = 1.sp
+ )
+ )
+ }
+ }
+}
+
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun SpaceListScreenPreview() {
+
+ DefaultScaffoldPreview {
+
+ SpaceListScreenContent(
+ spaceList = dummySpaceList,
+ onSpaceClicked = {},
+ onAddServerClicked = {}
+ )
+ }
+}
+
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun SpaceListEmptyScreenPreview() {
+
+ DefaultScaffoldPreview {
+
+ SpaceListScreenContent(
+ spaceList = emptyList(),
+ onSpaceClicked = {},
+ onAddServerClicked = {}
+ )
+ }
+}
\ 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
new file mode 100644
index 000000000..e38159954
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt
@@ -0,0 +1,113 @@
+package net.opendasharchive.openarchive.features.spaces
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+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.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.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 net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview
+
+@Composable
+fun SpaceSetupScreen(
+ onWebDavClick: () -> Unit,
+ isInternetArchiveAllowed: Boolean,
+ onInternetArchiveClick: () -> Unit,
+ isDwebEnabled: Boolean,
+ onDwebClicked: () -> Unit
+) {
+ // Use a scrollable Column to mimic ScrollView + LinearLayout
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(8.dp)
+ ) {
+ Spacer(modifier = Modifier.height(48.dp))
+ // Header texts
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(R.string.to_get_started_connect_to_a_server_to_store_your_media),
+ style = MaterialTheme.typography.titleLarge.copy(
+ textAlign = TextAlign.Center,
+ 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)
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // WebDav option
+ ServerOptionItem(
+ iconRes = R.drawable.ic_private_server,
+ title = stringResource(R.string.private_server),
+ subtitle = stringResource(R.string.send_directly_to_a_private_server),
+ onClick = onWebDavClick
+ )
+
+
+ // Internet Archive option (conditionally visible)
+ if (isInternetArchiveAllowed) {
+ ServerOptionItem(
+ iconRes = R.drawable.ic_internet_archive,
+ title = stringResource(R.string.internet_archive),
+ subtitle = stringResource(R.string.upload_to_the_internet_archive),
+ onClick = onInternetArchiveClick
+ )
+ }
+
+ // Snowbird (Raven) option (conditionally visible)
+ if (isDwebEnabled) {
+ ServerOptionItem(
+ iconRes = R.drawable.ic_dweb,
+ title = stringResource(R.string.dweb_title),
+ subtitle = stringResource(R.string.dweb_description),
+ onClick = onDwebClicked
+ )
+ }
+ }
+}
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun SpaceSetupScreenPreview() {
+ DefaultScaffoldPreview {
+ SpaceSetupScreen(
+ onWebDavClick = {},
+ isInternetArchiveAllowed = true,
+ onInternetArchiveClick = {},
+ isDwebEnabled = true,
+ onDwebClicked = {},
+ )
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpacesActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpacesActivity.kt
deleted file mode 100644
index db97e20b5..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpacesActivity.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-package net.opendasharchive.openarchive.features.spaces
-
-import android.content.Intent
-import android.os.Bundle
-import androidx.recyclerview.widget.LinearLayoutManager
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.SpaceAdapter
-import net.opendasharchive.openarchive.SpaceAdapterListener
-import net.opendasharchive.openarchive.SpaceItemDecoration
-import net.opendasharchive.openarchive.databinding.ActivitySpacesBinding
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.features.internetarchive.presentation.InternetArchiveActivity
-import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity
-import net.opendasharchive.openarchive.services.gdrive.GDriveActivity
-import net.opendasharchive.openarchive.services.webdav.WebDavActivity
-
-class SpacesActivity : BaseActivity(), SpaceAdapterListener {
-
- private lateinit var mBinding: ActivitySpacesBinding
- private lateinit var mAdapter: SpaceAdapter
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
-
- mBinding = ActivitySpacesBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
- setupToolbar(title = "Servers", showBackButton = true)
-
- mAdapter = SpaceAdapter(context = this, listener = this)
-
- mBinding.rvProjects.layoutManager = LinearLayoutManager(this)
- val spacing = resources.getDimensionPixelSize(R.dimen.list_item_spacing)
- mBinding.rvProjects.addItemDecoration(SpaceItemDecoration(spacing))
- mBinding.rvProjects.adapter = mAdapter
-
-
- mBinding.fabAdd.setOnClickListener {
- startActivity(Intent(this, SpaceSetupActivity::class.java))
- }
- }
-
- override fun onResume() {
- super.onResume()
-
- val projects = Space.Companion.getAll().asSequence().toList()
-
- mAdapter.update(projects)
- }
-
- override fun spaceClicked(space: Space) {
- Space.Companion.current = space
- finish()
- }
-
- override fun editSpaceClicked(spaceId: Long?) {
- startSpaceAuthActivity(spaceId)
- }
-
- override fun getSelectedSpace(): Space? {
- return Space.Companion.current
- }
-
- private fun startSpaceAuthActivity(spaceId: Long?) {
- val space = Space.Companion.get(spaceId ?: return) ?: return
-
- val clazz = when (space.tType) {
- Space.Type.INTERNET_ARCHIVE -> InternetArchiveActivity::class.java
- Space.Type.GDRIVE -> GDriveActivity::class.java
- else -> WebDavActivity::class.java
- }
-
- val intent = Intent(this@SpacesActivity, clazz)
- intent.putExtra(EXTRA_DATA_SPACE, space.id)
-
- startActivity(intent)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/fragments/VideoRequestHandler.kt b/app/src/main/java/net/opendasharchive/openarchive/fragments/VideoRequestHandler.kt
deleted file mode 100644
index b345c02db..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/fragments/VideoRequestHandler.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package net.opendasharchive.openarchive.fragments
-
-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
-
-class VideoRequestHandler(private val mContext: Context) : RequestHandler() {
- override fun canHandleRequest(data: Request): Boolean {
- val scheme = data.uri.scheme
- return SCHEME_VIDEO == scheme
- }
-
- @Throws(IOException::class)
- override fun load(data: Request, arg1: Int): Result? {
- val bm: Bitmap?
- try {
- bm = retrieveVideoFrameFromVideo(mContext, data.uri.toString().substring(6).toUri())
- if (bm != null) return Result(bm, Picasso.LoadedFrom.DISK)
- } catch (throwable: Throwable) {
- throwable.printStackTrace()
- }
- return null
- }
-
- companion object {
- const val SCHEME_VIDEO = "video"
- @Throws(Throwable::class)
- fun retrieveVideoFrameFromVideo(context: Context?, videoPath: Uri?): Bitmap? {
- val bitmap: Bitmap?
- var mediaMetadataRetriever: MediaMetadataRetriever? = null
- try {
- mediaMetadataRetriever = MediaMetadataRetriever()
- mediaMetadataRetriever.setDataSource(context, videoPath)
- bitmap =
- mediaMetadataRetriever.getFrameAtTime(1, MediaMetadataRetriever.OPTION_CLOSEST)
- } catch (e: Exception) {
- throw Throwable("Exception in retrieveVideoFrameFromVideo(String videoPath)" + e.message)
- } finally {
- mediaMetadataRetriever?.release()
- }
- return bitmap
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt
index a4b50fd0e..98ad07900 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt
@@ -3,7 +3,6 @@ package net.opendasharchive.openarchive.services
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
-import android.net.Uri
import android.webkit.MimeTypeMap
import com.google.common.net.UrlEscapers
import com.google.gson.GsonBuilder
@@ -11,14 +10,12 @@ import net.opendasharchive.openarchive.R
import net.opendasharchive.openarchive.core.logger.AppLogger
import net.opendasharchive.openarchive.db.Media
import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.services.gdrive.GDriveConduit
import net.opendasharchive.openarchive.services.internetarchive.IaConduit
import net.opendasharchive.openarchive.services.webdav.WebDavConduit
import net.opendasharchive.openarchive.upload.BroadcastManager
import net.opendasharchive.openarchive.util.Prefs
import okhttp3.HttpUrl
-import org.witness.proofmode.ProofMode
-import org.witness.proofmode.crypto.HashUtils
+import org.witness.proofmode.storage.DefaultStorageProvider
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
@@ -51,34 +48,17 @@ abstract class Conduit(
fun getProof(): Array {
if (!Prefs.useProofMode) return emptyArray()
-
- // Don't use geolocation and network information.
- Prefs.proofModeLocation = false
- Prefs.proofModeNetwork = false
-
try {
- var hash = ProofMode.generateProof(
- mContext,
- Uri.parse(mMedia.originalFilePath),
- mMedia.mediaHashString
- )
-
- if (hash == null) {
- val proofHash = HashUtils.getSHA256FromFileContent(
- mContext.contentResolver.openInputStream(mMedia.fileUri)
- )
-
- hash = ProofMode.generateProof(mContext, mMedia.fileUri, proofHash)
- }
-
- return ProofMode.getProofDir(mContext, hash).listFiles() ?: emptyArray()
+ // 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()
}
}
@@ -256,8 +236,6 @@ abstract class Conduit(
Space.Type.WEBDAV -> WebDavConduit(media, context)
- Space.Type.GDRIVE -> GDriveConduit(media, context)
-
else -> null
}
}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClientFactory.kt b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClientFactory.kt
new file mode 100644
index 000000000..fbbef1714
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClientFactory.kt
@@ -0,0 +1,30 @@
+package net.opendasharchive.openarchive.services
+
+import android.content.Context
+import okhttp3.OkHttpClient
+
+/**
+ * Factory interface for creating OkHttpClient instances with authentication.
+ * This abstraction allows injecting client creation without directly depending on Context.
+ */
+interface SaveClientFactory {
+ /**
+ * Creates an OkHttpClient with optional authentication credentials.
+ *
+ * @param username Optional username for authentication
+ * @param password Optional password for authentication
+ * @return Configured OkHttpClient instance
+ */
+ suspend fun createClient(username: String = "", password: String = ""): OkHttpClient
+}
+
+/**
+ * Default implementation of SaveClientFactory that uses SaveClient.
+ */
+class SaveClientFactoryImpl(
+ private val context: Context
+) : SaveClientFactory {
+ override suspend fun createClient(username: String, password: String): OkHttpClient {
+ return SaveClient.get(context, username, password)
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveActivity.kt
deleted file mode 100644
index 592cda970..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveActivity.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package net.opendasharchive.openarchive.services.gdrive
-
-import android.os.Bundle
-import android.view.MenuItem
-import com.google.android.gms.auth.api.signin.GoogleSignIn
-import com.google.android.gms.auth.api.signin.GoogleSignInOptions
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.ActivityGdriveBinding
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.util.AlertHelper
-
-class GDriveActivity : BaseActivity() {
-
- private lateinit var mBinding: ActivityGdriveBinding
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- var space: Space? = null
-
- if (intent.hasExtra(EXTRA_DATA_SPACE)) {
- space = Space.get(intent.getLongExtra(EXTRA_DATA_SPACE, -1L))
- }
-
- mBinding = ActivityGdriveBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
- mBinding.btRemove.setOnClickListener {
- if (space != null) removeSpace(space)
- }
-
- setupToolbar(getString(R.string.gdrive))
-
- mBinding.gdriveId.setText(space?.displayname ?: "")
- }
-
- private fun removeSpace(space: Space) {
- AlertHelper.show(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()))
- }
-
- // boilerplate to make back button in app bar work
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- if (item.itemId == android.R.id.home) {
- finish()
- return true
- }
- return super.onOptionsItemSelected(item)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveConduit.kt
deleted file mode 100644
index a28d025f2..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveConduit.kt
+++ /dev/null
@@ -1,269 +0,0 @@
-@file:Suppress("DEPRECATION")
-
-package net.opendasharchive.openarchive.services.gdrive
-
-import android.content.Context
-import androidx.core.content.ContextCompat
-import com.google.android.gms.auth.api.signin.GoogleSignIn
-import com.google.android.gms.common.Scopes
-import com.google.android.gms.common.api.Scope
-import com.google.api.client.extensions.android.http.AndroidHttp
-import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
-import com.google.api.client.googleapis.media.MediaHttpUploader
-import com.google.api.client.http.HttpTransport
-import com.google.api.client.http.InputStreamContent
-import com.google.api.client.http.apache.ApacheHttpTransport
-import com.google.api.client.json.gson.GsonFactory
-import com.google.api.services.drive.Drive
-import com.google.api.services.drive.DriveScopes
-import com.google.api.services.drive.model.File
-import info.guardianproject.netcipher.proxy.OrbotHelper
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.db.Media
-import net.opendasharchive.openarchive.features.folders.Folder
-import net.opendasharchive.openarchive.services.Conduit
-import net.opendasharchive.openarchive.util.Prefs
-import org.apache.http.conn.ClientConnectionManager
-import org.apache.http.conn.params.ConnManagerParams
-import org.apache.http.conn.params.ConnPerRouteBean
-import org.apache.http.conn.scheme.PlainSocketFactory
-import org.apache.http.conn.scheme.Scheme
-import org.apache.http.conn.scheme.SchemeRegistry
-import org.apache.http.conn.ssl.SSLSocketFactory
-import org.apache.http.impl.client.DefaultHttpClient
-import org.apache.http.impl.client.DefaultHttpRequestRetryHandler
-import org.apache.http.impl.conn.ProxySelectorRoutePlanner
-import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager
-import org.apache.http.params.BasicHttpParams
-import org.apache.http.params.HttpConnectionParams
-import timber.log.Timber
-import java.io.IOException
-import java.io.InputStream
-import java.net.InetSocketAddress
-import java.net.Proxy
-import java.net.ProxySelector
-import java.net.SocketAddress
-import java.net.URI
-import java.util.Date
-
-/**
- * This class contains all communication with / integration of Google Drive
- *
- * The only actually working documentation I could find about Googles Android GDrive API is this:
- * https://stackoverflow.com/questions/56949872/
- * There's also this official documentation by Google for accessing GDrive, however it was pretty
- * useless to me, since it doesn't explain what's going on at all. (I also couldn't get it to run
- * in a reasonable amount of time):
- * https://github.com/googleworkspace/android-samples/tree/master/drive/deprecation
- * The official documentation doesn't mention Android and the Java Sample is only useful for
- * integrating GDrive into backends. However it's still helpful for figuring building queries:
- * https://developers.google.com/drive/api/guides/about-sdk
- * Another important resource is this official guide on authenticating an Android app with Google:
- * https://developers.google.com/identity/sign-in/android/start-integrating
- */
-class GDriveConduit(media: Media, context: Context) : Conduit(media, context) {
-
- private var mDrive: Drive = getDrive(mContext)
-
- companion object {
- const val NAME = "Google Drive"
- var SCOPES =
- arrayOf(Scope(DriveScopes.DRIVE_FILE), Scope(Scopes.EMAIL))
-
- fun permissionsGranted(context: Context): Boolean {
- Timber.v("GDriveConduit.permissionGranted()")
- return GoogleSignIn.hasPermissions(
- GoogleSignIn.getLastSignedInAccount(context),
- *SCOPES
- )
- }
-
- fun getDrive(context: Context): Drive {
- val credential =
- GoogleAccountCredential.usingOAuth2(
- context,
- setOf(DriveScopes.DRIVE_FILE, Scopes.EMAIL)
- )
- credential.selectedAccount = GoogleSignIn.getLastSignedInAccount(context)?.account
-
- // in case we need to debug authentication:
- // Timber.v("GDriveConduit.getDrive(): credential $credential")
- // Timber.v("GDriveConduit.getDrive(): credential.selectedAccount ${credential.selectedAccount}")
- // Timber.v("GDriveConduit.getDrive(): credential.selectedAccount.name ${credential.selectedAccount?.name}")
-
- val transport: HttpTransport = if (Prefs.useTor) {
- // initialization code copied from: ApacheHttpTransport.newDefaultHttpParams()
- // This is the simplest solution I could come up with for actually sending traffic
- // to GDrive through Tor. Note that all calls to deprecated functions are copied
- // from the only known to work version of GDrive API.
- val params = BasicHttpParams()
- HttpConnectionParams.setStaleCheckingEnabled(params, false)
- HttpConnectionParams.setSocketBufferSize(params, 8192)
- ConnManagerParams.setMaxTotalConnections(params, 200)
- ConnManagerParams.setMaxConnectionsPerRoute(params, ConnPerRouteBean(20))
- val registry = SchemeRegistry()
- registry.register(Scheme("http", PlainSocketFactory.getSocketFactory(), 80))
- registry.register(Scheme("https", SSLSocketFactory.getSocketFactory(), 443))
- val connectionManager: ClientConnectionManager =
- ThreadSafeClientConnManager(params, registry)
- val defaultHttpClient = DefaultHttpClient(connectionManager, params)
- defaultHttpClient.httpRequestRetryHandler = DefaultHttpRequestRetryHandler(0, false)
- val proxySelector = object : ProxySelector() {
- override fun select(uri: URI?): MutableList {
- return mutableListOf(
- // tried SOCKS here, but in my tests when specifying SOCKS, the uploads
- // seamed to bypass proxy settings altogether and connect directly instead
- Proxy(
- Proxy.Type.HTTP,
- InetSocketAddress(
- OrbotHelper.DEFAULT_PROXY_HOST,
- OrbotHelper.DEFAULT_PROXY_HTTP_PORT
- )
- )
- )
- }
-
- override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
- Timber.e("proxy connection Failed ($uri, $sa)", ioe)
- }
- }
- defaultHttpClient.routePlanner = ProxySelectorRoutePlanner(
- registry,
- proxySelector
- )
-
- ApacheHttpTransport(defaultHttpClient)
- } else {
- AndroidHttp.newCompatibleTransport()
- }
-
- return Drive.Builder(transport, GsonFactory(), credential)
- .setApplicationName(ContextCompat.getString(context, R.string.app_name)).build()
- }
-
- private fun createFolder(gdrive: Drive, folderName: String, parent: File?): File {
- val parentId: String = parent?.id ?: "root"
- val folders =
- gdrive.files().list().setPageSize(1)
- .setQ("mimeType='application/vnd.google-apps.folder' and name = '$folderName' and trashed = false and '$parentId' in parents")
- .setFields("files(id, name)").execute()
-
- if (folders.files.isNotEmpty()) {
- // folder exists, return it now
- return folders.files.first()
- }
-
- // create new folder
- val folderMeta = File()
- folderMeta.name = folderName
- folderMeta.parents = listOf(parentId)
- folderMeta.mimeType = "application/vnd.google-apps.folder"
-
- // return newly created folders
- return gdrive.files().create(folderMeta).setFields("id").execute()
- }
-
- fun createFolders(mDrive: Drive, destinationPath: List): File {
- var parentFolder: File? = null
- for (pathElement in destinationPath) {
- parentFolder = createFolder(mDrive, pathElement, parentFolder)
- }
- if (parentFolder == null) {
- throw Exception("could not create folders $destinationPath")
- }
- return parentFolder
- }
-
- fun listFoldersInRoot(gdrive: Drive): List {
- val result = ArrayList()
- try {
- var pageToken: String? = null
- do {
- val folders =
- gdrive.files().list().setPageSize(1000).setPageToken(pageToken)
- .setQ("mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed = false")
- .setFields("nextPageToken, files(id, name, createdTime)").execute()
- for (f in folders.files) {
- val date = Date(f.createdTime.value)
- result.add(Folder(f.name, date))
- }
- pageToken = folders.nextPageToken
- } while (pageToken != null)
- } catch (e: java.lang.IllegalArgumentException) {
- Timber.e(e)
- }
- return result
- }
- }
-
- override suspend fun upload(): Boolean {
- val destinationPath = getPath() ?: return false
- val destinationFileName = getUploadFileName(mMedia)
- sanitize()
-
- try {
- val folder = createFolders(mDrive, destinationPath)
- uploadMetadata(folder, destinationFileName)
- if (mCancelled) throw Exception("Cancelled")
- uploadFile(mMedia.file, folder, destinationFileName)
- } catch (e: Exception) {
- jobFailed(e)
- return false
- }
-
- jobSucceeded()
-
- return true
- }
-
- override suspend fun createFolder(url: String) {
- throw NotImplementedError("the createFolder calls defined in Conduit don't map to GDrive API. use GDriveConduit.createFolder instead")
- }
-
- private fun uploadMetadata(parent: File, fileName: String) {
- val metadataFileName = "$fileName.meta.json"
-
- if (mCancelled) throw java.lang.Exception("Cancelled")
-
- uploadFile(getMetadata().byteInputStream(), parent, metadataFileName)
-
- for (file in getProof()) {
- if (mCancelled) throw java.lang.Exception("Cancelled")
-
- uploadFile(file, parent, file.name)
- }
- }
-
- private fun uploadFile(
- sourceFile: java.io.File,
- parentFolder: File,
- targetFileName: String,
- ) {
- uploadFile(sourceFile.inputStream(), parentFolder, targetFileName)
- }
-
- private fun uploadFile(
- inputStream: InputStream,
- parentFolder: File,
- targetFileName: String,
- ) {
- try {
- val fMeta = File()
- fMeta.name = targetFileName
- fMeta.parents = listOf(parentFolder.id)
- val request =
- mDrive.files().create(fMeta, InputStreamContent(null, inputStream))
- request.mediaHttpUploader.isDirectUploadEnabled = false
- request.mediaHttpUploader.chunkSize =
- 262144 // magic minimum chunk-size number (smaller number will cause exception)
- request.mediaHttpUploader.setProgressListener {
- if (it.uploadState == MediaHttpUploader.UploadState.MEDIA_IN_PROGRESS) {
- jobProgress(it.numBytesUploaded)
- }
- }
- val response = request.execute()
- } catch (e: Exception) {
- Timber.e("gdrive upload of '$targetFileName' failed", e)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFragment.kt
deleted file mode 100644
index 0c58fbd61..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFragment.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-package net.opendasharchive.openarchive.services.gdrive
-
-import android.app.Activity.RESULT_OK
-import android.content.Intent
-import android.os.Bundle
-import android.text.method.LinkMovementMethod
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.os.bundleOf
-import androidx.core.text.HtmlCompat
-import androidx.fragment.app.setFragmentResult
-import com.google.android.gms.auth.api.Auth
-import com.google.android.gms.auth.api.signin.GoogleSignIn
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.launch
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.FragmentGdriveBinding
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.onboarding.BaseFragment
-
-class GDriveFragment : BaseFragment() {
-
- private lateinit var mBinding: FragmentGdriveBinding
-
- companion object {
- const val RESP_CANCEL = "gdrive_fragment_resp_cancel"
- const val RESP_AUTHENTICATED = "gdrive_fragment_resp_authenticated"
-
- const val REQUEST_CODE_GOOGLE_AUTH = 21701
- }
-
- override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- mBinding = FragmentGdriveBinding.inflate(inflater)
-
- mBinding.disclaimer1.text = HtmlCompat.fromHtml(
- 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
- )
- mBinding.disclaimer1.movementMethod = LinkMovementMethod.getInstance()
- mBinding.disclaimer2.text = getString(
- R.string.gdrive_disclaimer_2,
- getString(R.string.google_name),
- getString(R.string.gdrive),
- getString(R.string.app_name),
- )
- mBinding.error.visibility = View.GONE
-
- mBinding.btBack.setOnClickListener {
- setFragmentResult(RESP_CANCEL, bundleOf())
- }
-
- mBinding.btAuthenticate.setOnClickListener {
- mBinding.error.visibility = View.GONE
- authenticate()
- mBinding.btBack.isEnabled = false
- mBinding.btAuthenticate.isEnabled = false
- }
-
- return mBinding.root
- }
-
- private fun authenticate() {
- if (!GDriveConduit.permissionsGranted(requireContext())) {
- GoogleSignIn.requestPermissions(
- requireActivity(),
- REQUEST_CODE_GOOGLE_AUTH,
- GoogleSignIn.getLastSignedInAccount(requireActivity()),
- *GDriveConduit.SCOPES
- )
- } else {
- // permission was already granted, we're already signed in, continue.
- setFragmentResult(RESP_AUTHENTICATED, bundleOf())
- }
- }
-
- @Deprecated("Deprecated in Java")
- override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
- super.onActivityResult(requestCode, resultCode, data)
-
- if (requestCode == REQUEST_CODE_GOOGLE_AUTH) {
- when (resultCode) {
- RESULT_OK -> {
- CoroutineScope(Dispatchers.IO).launch {
- val space = Space(Space.Type.GDRIVE)
- // we don't really know the host here, that's hidden by Drive Api
- space.host = "what's the host of google drive? :shrug:"
- data?.let {
- val result = Auth.GoogleSignInApi.getSignInResultFromIntent(it)
- if (result?.isSuccess == true) {
- result.signInAccount?.let { account ->
- space.displayname = account.email ?: ""
- }
- }
- }
-
- if (GDriveConduit.permissionsGranted(requireContext())) {
- space.save()
- Space.current = space
-
-// CleanInsightsManager.getConsent(requireActivity()) {
-// CleanInsightsManager.measureEvent(
-// "backend",
-// "new",
-// Space.Type.GDRIVE.friendlyName
-// )
-// }
-
- MainScope().launch {
- setFragmentResult(RESP_AUTHENTICATED, bundleOf())
- }
- } else {
- authFailed(
- getString(
- R.string.gdrive_auth_insufficient_permissions,
- getString(R.string.app_name),
- getString(R.string.gdrive)
- )
- )
- }
- }
- }
-
- else -> authFailed()
- }
- }
- }
-
- private fun authFailed() {
- authFailed(null)
- }
-
- private fun authFailed(errorMessage: String?) {
- MainScope().launch {
- errorMessage?.let {
- mBinding.error.text = errorMessage
- mBinding.error.visibility = View.VISIBLE
- }
- mBinding.btBack.isEnabled = true
- mBinding.btAuthenticate.isEnabled = true
- }
- }
-
- override fun getToolbarTitle() = "Google Drive"
-}
\ No newline at end of file
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
index 6c7ddda22..7ac8a1228 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt
@@ -43,7 +43,8 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) {
val fileName = getUploadFileName(mMedia, true)
val metaJson = gson.toJson(mMedia)
-// val proof = getProof()
+ // 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
@@ -59,10 +60,11 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) {
// upload metadata and proofs async, and report failures
client.uploadMetaData(metaJson, fileName)
- /// Upload ProofMode metadata, if enabled and successfully created.
-// for (file in proof) {
-// client.uploadProofFiles(file)
-// }
+ // Commenting out proof generation - 17th April 2025
+ // Upload ProofMode metadata, if enabled and successfully created.
+ // for (file in proof) {
+ // client.uploadProofFiles(file)
+ // }
jobSucceeded()
@@ -88,9 +90,12 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) {
Uri.parse(mediaUri),
mMedia.contentLength,
mimeType.toMediaTypeOrNull(),
- createListener(cancellable = { !mCancelled }, onProgress = {
- jobProgress(it)
- })
+ createListener(
+ cancellable = { !mCancelled },
+ onProgress = {
+ jobProgress(it)
+ }
+ )
)
val request = Request.Builder()
@@ -179,7 +184,7 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) {
}
if (mMedia.location.isNotEmpty()) {
- builder.add("x-archive-meta-location", mMedia.location)
+ builder.add("x-archive-meta-location", sanitizeHeaderValue(mMedia.location))
}
if (mMedia.tags.isNotEmpty()) {
@@ -191,7 +196,7 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) {
}
if (mMedia.description.isNotEmpty()) {
- builder.add("x-archive-meta-description", mMedia.description)
+ builder.add("x-archive-meta-description", sanitizeHeaderValue(mMedia.description))
}
if (mMedia.title.isNotEmpty()) {
@@ -213,7 +218,7 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) {
private fun metadataHeader(): Headers {
return Headers.Builder()
.add("x-amz-auto-make-bucket", "1")
- .add("x-archive-meta-language", "eng") // FIXME set based on locale or selected
+ .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")
@@ -246,4 +251,8 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) {
})
}
+
+ 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/snowbird/BaseSnowbirdFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/BaseSnowbirdFragment.kt
deleted file mode 100644
index 84c1c73eb..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/BaseSnowbirdFragment.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package net.opendasharchive.openarchive.services.snowbird
-
-import android.content.Context
-import android.view.View
-import android.view.inputmethod.InputMethodManager
-import androidx.fragment.app.Fragment
-import net.opendasharchive.openarchive.db.SnowbirdError
-import net.opendasharchive.openarchive.extensions.androidViewModel
-import net.opendasharchive.openarchive.util.Utility
-
-open class BaseSnowbirdFragment : Fragment() {
- val snowbirdGroupViewModel: SnowbirdGroupViewModel by androidViewModel()
- val snowbirdRepoViewModel: SnowbirdRepoViewModel by androidViewModel()
-
- 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) {
- Utility.showMaterialWarning(
- requireContext(),
- error.friendlyMessage
- )
- }
-
- open fun handleLoadingStatus(isLoading: Boolean) {
- if (isLoading) {
- //FullScreenOverlayManager.show(this@BaseSnowbirdFragment)
- } else {
- //FullScreenOverlayManager.hide()
- }
- }
-}
\ 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
index 908056484..5dc49d1d5 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdCreateGroupFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdCreateGroupFragment.kt
@@ -4,22 +4,23 @@ 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.setFragmentResult
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.databinding.FragmentSnowbirdCreateGroupBinding
import net.opendasharchive.openarchive.db.SnowbirdError
import net.opendasharchive.openarchive.db.SnowbirdGroup
import net.opendasharchive.openarchive.db.SnowbirdRepo
-import net.opendasharchive.openarchive.features.onboarding.BaseFragment
+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.FullScreenOverlayCreateGroupManager
-import net.opendasharchive.openarchive.util.Utility
import timber.log.Timber
-class SnowbirdCreateGroupFragment private constructor() : BaseFragment() {
+class SnowbirdCreateGroupFragment: BaseFragment() {
private lateinit var viewBinding: FragmentSnowbirdCreateGroupBinding
@@ -125,45 +126,29 @@ class SnowbirdCreateGroupFragment private constructor() : BaseFragment() {
private fun showConfirmation(repo: SnowbirdRepo?) {
val group = SnowbirdGroup.get(repo!!.groupKey)!!
- Utility.showMaterialPrompt(
- requireContext(),
- title = "Raven Group Created",
- message = "Would you like to share your new group with a QR code?",
- positiveButtonText = "Yes",
- negativeButtonText = "No",
- completion = { affirm ->
- if (affirm) {
- setFragmentResult(
- RESULT_REQUEST_KEY,
- bundleOf(
- RESULT_NAVIGATION_KEY to RESULT_NAVIGATION_VAL_SHARE_SCREEN,
- RESULT_BUNDLE_GROUP_KEY to group.key
- )
- )
- //findNavController().navigate(SnowbirdCreateGroupFragmentDirections.navigateToShareScreen(group.key))
- } else {
+ dialogManager.showDialog(dialogManager.requireResourceProvider()) {
+ type = DialogType.Success
+ title = UiText.DynamicString("Raven Group Created")
+ message = UiText.DynamicString("Would you like to share your new group with a QR code?")
+ positiveButton {
+ text = UiText.DynamicString("Yes")
+ action = {
+ val action =
+ SnowbirdCreateGroupFragmentDirections.actionFragmentSnowbirdCreateGroupToFragmentSnowbirdShareGroup(group.key)
+ findNavController().navigate(action)
+ }
+ }
+ neutralButton {
+ text = UiText.DynamicString("No")
+ action = {
parentFragmentManager.popBackStack()
}
}
- )
+ }
}
override fun getToolbarTitle(): String {
return "Create Raven Group"
}
- companion object {
-
- const val RESULT_REQUEST_KEY = "create_group_result"
-
- const val RESULT_NAVIGATION_KEY = "create_group_navigation"
-
- const val RESULT_NAVIGATION_VAL_SHARE_SCREEN = "share_screen"
-
- const val RESULT_BUNDLE_GROUP_KEY = "raven_create_group_fragment_bundle_group_id"
-
- @JvmStatic
- fun newInstance() = SnowbirdCreateGroupFragment()
- }
-
}
\ 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
index 40b24a041..e26b869bc 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt
@@ -21,9 +21,11 @@ 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.onboarding.BaseFragment
+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 net.opendasharchive.openarchive.util.Utility
import timber.log.Timber
class SnowbirdFileListFragment : BaseFragment() {
@@ -136,16 +138,20 @@ class SnowbirdFileListFragment : BaseFragment() {
private fun onClick(item: SnowbirdFileItem) {
// if (!item.isDownloaded) {
- Utility.showMaterialPrompt(
- requireContext(),
- title = "Download Media?",
- message = "Are you sure you want to download this media?",
- positiveButtonText = "Yes",
- negativeButtonText = "No") { affirm ->
- if (affirm) {
+ 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")
+ }
+ }
// }
}
@@ -188,10 +194,14 @@ class SnowbirdFileListFragment : BaseFragment() {
private fun onFileDownloaded(uri: Uri) {
handleLoadingStatus(false)
Timber.d("File successfully downloaded: $uri")
- Utility.showMaterialMessage(
- requireContext(),
- title = "Success",
- message = "File successfully downloaded")
+ dialogManager.showDialog(dialogManager.requireResourceProvider()) {
+ type = DialogType.Success
+ title = UiText.StringResource(R.string.label_success_title)
+ message = UiText.DynamicString("File successfully downloaded")
+ positiveButton {
+ text = UiText.StringResource(R.string.label_got_it)
+ }
+ }
}
private fun onFileUploaded(result: FileUploadResult) {
@@ -239,17 +249,7 @@ class SnowbirdFileListFragment : BaseFragment() {
}
companion object {
- const val RESULT_REQUEST_KEY = "raven_fragment_file_list_result"
- const val RESULT_VAL_RAVEN_GROUP_KEY = "raven_fragment_file_list_group_key"
- const val RESULT_VAL_RAVEN_REPO_KEY = "raven_fragment_file_list_repo_key"
-
- @JvmStatic
- fun newInstance(groupKey: String, repoKey: String) =
- SnowbirdFileListFragment().apply {
- arguments = Bundle().apply {
- putString(RESULT_VAL_RAVEN_GROUP_KEY, groupKey)
- putString(RESULT_VAL_RAVEN_REPO_KEY, repoKey)
- }
- }
+ 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/SnowbirdFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt
index 741055a29..89f516d7f 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt
@@ -11,35 +11,41 @@ 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 com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.launch
+import net.opendasharchive.openarchive.R
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.onboarding.BaseFragment
-import net.opendasharchive.openarchive.features.settings.SpaceSetupFragment
-import net.opendasharchive.openarchive.features.settings.SpaceSetupFragment.Companion.RESULT_VAL_INTERNET_ARCHIVE
-import net.opendasharchive.openarchive.util.Utility
+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.QRScannerActivity
import timber.log.Timber
-class SnowbirdFragment private constructor(): BaseFragment() {
- private val CANNED_URI = "save+dweb::?dht=82fd345d484393a96b6e0c5d5e17a85a61c9184cc5a3311ab069d6efa0bf1410&enc=6fa27396fe298f92c91013ac54d8f316c2d45dc3bed0edec73078040aa10feed&pk=f4b404d294817cf11ea7f8ef7231626e03b74f6fafe3271b53918608afa82d12&sk=5482a8f490081be684fbadb8bde7f0a99bab8acdcf1ec094826f0f18e327e399"
+class SnowbirdFragment : BaseFragment() {
+ private val CANNED_URI =
+ "save+dweb::?dht=82fd345d484393a96b6e0c5d5e17a85a61c9184cc5a3311ab069d6efa0bf1410&enc=6fa27396fe298f92c91013ac54d8f316c2d45dc3bed0edec73078040aa10feed&pk=f4b404d294817cf11ea7f8ef7231626e03b74f6fafe3271b53918608afa82d12&sk=5482a8f490081be684fbadb8bde7f0a99bab8acdcf1ec094826f0f18e327e399"
private lateinit var viewBinding: FragmentSnowbirdBinding
private var canNavigate = false
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)
- }
- }
+// val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
+// if (scanResult != null) {
+// if (scanResult.contents != null) {
+// processScannedData(scanResult.contents)
+// }
+// }
}
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
viewBinding = FragmentSnowbirdBinding.inflate(inflater)
return viewBinding.root
@@ -54,18 +60,16 @@ class SnowbirdFragment private constructor(): BaseFragment() {
viewBinding.myGroupsButton.setOnClickListener {
- setFragmentResult(
- RESULT_REQUEST_KEY,
- bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_RAVEN_MY_GROUPS)
- )
+ val action =
+ SnowbirdFragmentDirections.actionFragmentSnowbirdToFragmentSnowbirdGroupList()
+ findNavController().navigate(action)
}
viewBinding.createGroupButton.setOnClickListener {
+ val action =
+ SnowbirdFragmentDirections.actionFragmentSnowbirdToFragmentSnowbirdCreateGroup()
+ findNavController().navigate(action)
- setFragmentResult(
- RESULT_REQUEST_KEY,
- bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_RAVEN_CREATE_GROUP)
- )
}
initializeViewModelObservers()
@@ -74,7 +78,13 @@ class SnowbirdFragment private constructor(): BaseFragment() {
private fun initializeViewModelObservers() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
- launch { snowbirdGroupViewModel.groupState.collect { state -> handleGroupStateUpdate(state) } }
+ launch {
+ snowbirdGroupViewModel.groupState.collect { state ->
+ handleGroupStateUpdate(
+ state
+ )
+ }
+ }
}
}
}
@@ -90,52 +100,53 @@ class SnowbirdFragment private constructor(): BaseFragment() {
}
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)
+// 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 processScannedData(uriString: String) {
val name = uriString.getQueryParameter("name")
if (name == null) {
- Utility.showMaterialWarning(
- requireContext(),
- "Unable to determine group name from QR code.")
+ 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)) {
- Utility.showMaterialWarning(
- requireContext(),
- "You have already joined this group.")
+ 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
}
- setFragmentResult(
- RESULT_REQUEST_KEY,
- bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_RAVEN_JOIN_GROUPS, RESULT_VAL_RAVEN_JOIN_GROUPS_ARG to uriString)
- )
- }
- companion object {
- const val RESULT_REQUEST_KEY = "raven_fragment_result"
- const val RESULT_BUNDLE_KEY = "raven_fragment_result_key"
- const val RESULT_VAL_RAVEN_MY_GROUPS = "raven_my_group"
- const val RESULT_VAL_RAVEN_JOIN_GROUPS = "raven_join_group"
- const val RESULT_VAL_RAVEN_JOIN_GROUPS_ARG = "raven_join_group_argument_uri"
- const val RESULT_VAL_RAVEN_CREATE_GROUP = "raven_create_group"
+ val action =
+ SnowbirdFragmentDirections.actionFragmentSnowbirdToFragmentSnowbirdJoinGroup(
+ uriString
+ )
+ findNavController().navigate(action)
- @JvmStatic
- fun newInstance() = SnowbirdFragment()
}
override fun getToolbarTitle(): String {
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
index 0c11a8b30..a3dce89f1 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListFragment.kt
@@ -7,27 +7,30 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
-import androidx.core.bundle.bundleOf
+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.FragmentSnowbirdListGroupsBinding
+import net.opendasharchive.openarchive.databinding.FragmentSnowbirdGroupListBinding
import net.opendasharchive.openarchive.db.SnowbirdError
import net.opendasharchive.openarchive.db.SnowbirdGroup
-import net.opendasharchive.openarchive.features.onboarding.BaseFragment
+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 net.opendasharchive.openarchive.util.Utility
import timber.log.Timber
-class SnowbirdGroupListFragment private constructor(): BaseFragment() {
+class SnowbirdGroupListFragment : BaseFragment() {
- private lateinit var viewBinding: FragmentSnowbirdListGroupsBinding
+ private lateinit var viewBinding: FragmentSnowbirdGroupListBinding
private lateinit var adapter: SnowbirdGroupsAdapter
override fun onCreateView(
@@ -35,7 +38,7 @@ class SnowbirdGroupListFragment private constructor(): BaseFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
- viewBinding = FragmentSnowbirdListGroupsBinding.inflate(inflater)
+ viewBinding = FragmentSnowbirdGroupListBinding.inflate(inflater)
return viewBinding.root
}
@@ -71,11 +74,10 @@ class SnowbirdGroupListFragment private constructor(): BaseFragment() {
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_add -> {
- setFragmentResult(
- RESULT_REQUEST_KEY,
- bundleOf(RESULT_BUNDLE_NAVIGATION_KEY to RESULT_VAL_RAVEN_CREATE_GROUP_SCREEN)
- )
- //findNavController().navigate(SnowbirdGroupListFragmentDirections.navigateToSnowbirdCreateGroupScreen())
+
+ val action =
+ SnowbirdGroupListFragmentDirections.actionFragmentSnowbirdGroupListToFragmentSnowbirdCreateGroup()
+ findNavController().navigate(action)
true
}
@@ -105,33 +107,26 @@ class SnowbirdGroupListFragment private constructor(): BaseFragment() {
}
private fun onClick(groupKey: String) {
- setFragmentResult(
- RESULT_REQUEST_KEY, bundleOf(
- RESULT_BUNDLE_NAVIGATION_KEY to RESULT_VAL_RAVEN_REPO_LIST_SCREEN,
- RESULT_BUNDLE_GROUP_KEY to groupKey
- )
- )
- //findNavController()
- // .navigate(SnowbirdGroupListFragmentDirections.navigateToSnowbirdListReposScreen(groupKey))
+
+ val action = SnowbirdGroupListFragmentDirections.actionFragmentSnowbirdGroupListToFragmentSnowbirdListRepos(groupKey)
+ findNavController().navigate(action)
}
private fun onLongPress(groupKey: String) {
AppLogger.d("Long press!")
- Utility.showMaterialPrompt(
- requireContext(),
- title = "Share Group",
- message = "Would you like to share this group?",
- positiveButtonText = "Yes",
- negativeButtonText = "No"
- ) { affirm ->
- if (affirm) {
- setFragmentResult(RESULT_REQUEST_KEY,
- bundleOf(
- RESULT_BUNDLE_NAVIGATION_KEY to RESULT_VAL_RAVEN_SHARE_SCREEN,
- RESULT_BUNDLE_GROUP_KEY to groupKey
- )
- )
- //findNavController().navigate(SnowbirdGroupListFragmentDirections.navigateToSnowbirdShareScreen(groupKey))
+ 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")
}
}
}
@@ -190,20 +185,6 @@ class SnowbirdGroupListFragment private constructor(): BaseFragment() {
}
}
- companion object {
- const val RESULT_REQUEST_KEY = "raven_group_list_fragment_result"
- const val RESULT_BUNDLE_NAVIGATION_KEY = "raven_group_list_fragment_bundle_navigation_key"
-
- const val RESULT_VAL_RAVEN_CREATE_GROUP_SCREEN = "raven_create_group"
- const val RESULT_VAL_RAVEN_REPO_LIST_SCREEN = "raven_repo_list_screen"
- const val RESULT_VAL_RAVEN_SHARE_SCREEN = "raven_share_group_screen"
-
- const val RESULT_BUNDLE_GROUP_KEY = "raven_group_list_fragment_bundle_group_id"
-
- @JvmStatic
- fun newInstance() = SnowbirdGroupListFragment()
- }
-
override fun getToolbarTitle(): String {
return "My Groups"
}
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
index e5cc9dd2e..4d0b366fb 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupOverviewFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupOverviewFragment.kt
@@ -5,7 +5,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import net.opendasharchive.openarchive.databinding.FragmentSnowbirdGroupOverviewBinding
-import net.opendasharchive.openarchive.features.onboarding.BaseFragment
+import net.opendasharchive.openarchive.features.core.BaseFragment
class SnowbirdGroupOverviewFragment private constructor(): BaseFragment() {
private lateinit var viewBinding: FragmentSnowbirdGroupOverviewBinding
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
index 4c92fbc7e..e76c8e3c6 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdJoinGroupFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdJoinGroupFragment.kt
@@ -8,19 +8,21 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
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.onboarding.BaseFragment
-import net.opendasharchive.openarchive.features.settings.SpaceSetupSuccessFragment
+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.FullScreenOverlayCreateGroupManager
-import net.opendasharchive.openarchive.util.Utility
import timber.log.Timber
-class SnowbirdJoinGroupFragment private constructor(): BaseFragment() {
+class SnowbirdJoinGroupFragment: BaseFragment() {
private lateinit var viewBinding: FragmentSnowbirdJoinGroupBinding
private lateinit var uriString: String
@@ -116,12 +118,16 @@ class SnowbirdJoinGroupFragment private constructor(): BaseFragment() {
repo.save()
handleCreateGroupLoadingStatus(false)
snowbirdRepoViewModel.fetchRepos(groupKey, false)
- Utility.showMaterialMessage(
- requireContext(),
- title = "Success!",
- // message = "Successfully joined"
- ) {
- parentFragmentManager.popBackStack()
+ 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 = {
+ parentFragmentManager.popBackStack()
+ }
+ }
}
}
@@ -144,16 +150,8 @@ class SnowbirdJoinGroupFragment private constructor(): BaseFragment() {
companion object {
-
const val ARG_RAVEN_GROUP_URI_STRING = "space_setup_success_fragment_arg_message"
- @JvmStatic
- fun newInstance(uriString: String) =
- SnowbirdJoinGroupFragment().apply {
- arguments = Bundle().apply {
- putString(ARG_RAVEN_GROUP_URI_STRING, uriString)
- }
- }
}
override fun getToolbarTitle(): String {
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
index 5532ed311..6547f2fc8 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoListFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoListFragment.kt
@@ -7,13 +7,13 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
-import android.widget.Toast
-import androidx.core.bundle.bundleOf
+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
@@ -21,12 +21,14 @@ import net.opendasharchive.openarchive.core.logger.AppLogger
import net.opendasharchive.openarchive.databinding.FragmentSnowbirdListReposBinding
import net.opendasharchive.openarchive.db.SnowbirdError
import net.opendasharchive.openarchive.db.SnowbirdRepo
-import net.opendasharchive.openarchive.features.onboarding.BaseFragment
+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 net.opendasharchive.openarchive.util.Utility
import timber.log.Timber
-class SnowbirdRepoListFragment private constructor() : BaseFragment() {
+class SnowbirdRepoListFragment: BaseFragment() {
private lateinit var viewBinding: FragmentSnowbirdListReposBinding
private lateinit var adapter: SnowbirdRepoListAdapter
@@ -96,11 +98,14 @@ class SnowbirdRepoListFragment private constructor() : BaseFragment() {
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"
- )
+ 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
}
@@ -114,14 +119,13 @@ class SnowbirdRepoListFragment private constructor() : BaseFragment() {
adapter = SnowbirdRepoListAdapter { repoKey ->
AppLogger.d("Click!!")
- //findNavController().navigate(SnowbirdRepoListFragmentDirections.navigateToSnowbirdListFilesScreen(groupKey, repoKey))
- setFragmentResult(
- RESULT_REQUEST_KEY,
- bundleOf(
- RESULT_VAL_RAVEN_GROUP_KEY to groupKey,
- RESULT_VAL_RAVEN_REPO_KEY to repoKey
- )
- )
+
+ val action =
+ SnowbirdRepoListFragmentDirections.actionFragmentSnowbirdListReposToFragmentSnowbirdListMedia(
+ dwebGroupKey = groupKey,
+ dwebRepoKey = repoKey
+ )
+ findNavController().navigate(action)
}
val spacingInPixels = resources.getDimensionPixelSize(R.dimen.list_item_spacing)
@@ -145,11 +149,17 @@ class SnowbirdRepoListFragment private constructor() : BaseFragment() {
adapter.submitList(repos)
if (isRefresh && repos.isEmpty()) {
- Utility.showMaterialMessage(
- requireContext(),
- title = "Info",
- message = "No new repositories found."
- )
+ 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()
+ }
+ }
+ }
}
}
@@ -190,16 +200,8 @@ class SnowbirdRepoListFragment private constructor() : BaseFragment() {
companion object {
- const val RESULT_REQUEST_KEY = "raven_fragment_repo_list_result"
- const val RESULT_VAL_RAVEN_GROUP_KEY = "raven_fragment_repo_list_group_key"
- const val RESULT_VAL_RAVEN_REPO_KEY = "raven_fragment_repo_list_repo_key"
+ const val RESULT_VAL_RAVEN_GROUP_KEY = "dweb_group_key"
+
- @JvmStatic
- fun newInstance(groupKey: String) =
- SnowbirdRepoListFragment().apply {
- arguments = Bundle().apply {
- putString(RESULT_VAL_RAVEN_GROUP_KEY, groupKey)
- }
- }
}
}
\ No newline at end of file
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
index ac4a0e56a..b49290d32 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdShareFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdShareFragment.kt
@@ -8,9 +8,9 @@ import net.opendasharchive.openarchive.databinding.FragmentSnowbirdShareGroupBin
import net.opendasharchive.openarchive.db.SnowbirdGroup
import net.opendasharchive.openarchive.extensions.asQRCode
import net.opendasharchive.openarchive.extensions.urlEncode
-import net.opendasharchive.openarchive.features.onboarding.BaseFragment
+import net.opendasharchive.openarchive.features.core.BaseFragment
-class SnowbirdShareFragment private constructor(): BaseFragment() {
+class SnowbirdShareFragment: BaseFragment() {
private lateinit var viewBinding: FragmentSnowbirdShareGroupBinding
private lateinit var groupKey: String
@@ -48,15 +48,6 @@ class SnowbirdShareFragment private constructor(): BaseFragment() {
companion object {
- const val RESULT_VAL_RAVEN_GROUP_KEY = "RESULT_VAL_RAVEN_GROUP_KEY"
-
- @JvmStatic
- fun newInstance(groupKey: String): SnowbirdShareFragment {
- return SnowbirdShareFragment().apply {
- arguments = Bundle().apply {
- putString(RESULT_VAL_RAVEN_GROUP_KEY, groupKey)
- }
- }
- }
+ 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/webdav/CreativeCommonsLicenseContent.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/CreativeCommonsLicenseContent.kt
new file mode 100644
index 000000000..d57e8e9cb
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/CreativeCommonsLicenseContent.kt
@@ -0,0 +1,216 @@
+package net.opendasharchive.openarchive.services.webdav
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+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 net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme
+import net.opendasharchive.openarchive.util.extensions.openBrowser
+
+@Composable
+fun CreativeCommonsLicenseContent(
+ modifier: Modifier = Modifier,
+ licenseState: LicenseState,
+ licenseCallbacks: LicenseCallbacks,
+ enabled: Boolean = true,
+ ccLabelText: String = ""
+) {
+ val context = LocalContext.current
+
+ // Main container - matches LinearLayout in content_cc.xml
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ // Main CC License Switch - matches RelativeLayout lines 18-41 in content_cc.xml
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = ccLabelText.ifEmpty { stringResource(R.string.set_creative_commons_license_for_all_folders_on_this_server) },
+ modifier = Modifier.weight(1f),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Switch(
+ checked = licenseState.ccEnabled,
+ onCheckedChange = licenseCallbacks::onCcEnabledChange,
+ enabled = enabled,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colorScheme.surface,
+ checkedTrackColor = MaterialTheme.colorScheme.tertiary
+ )
+ )
+ }
+
+ // Show license options only when CC is enabled
+ if (licenseState.ccEnabled) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // CC0 License Switch - waive all restrictions, requirements, and attribution (first option)
+ LicenseOptionRow(
+ text = stringResource(R.string.info_license_cc0),
+ checked = licenseState.cc0Enabled,
+ onCheckedChange = licenseCallbacks::onCc0EnabledChange,
+ enabled = enabled
+ )
+
+ // Allow Remix Switch - matches RelativeLayout lines 44-68 in content_cc.xml
+ LicenseOptionRow(
+ text = stringResource(R.string.info_license_deriv),
+ checked = licenseState.allowRemix,
+ onCheckedChange = licenseCallbacks::onAllowRemixChange,
+ enabled = enabled
+ )
+
+ // Require Share Alike Switch - matches RelativeLayout lines 71-95 in content_cc.xml
+ LicenseOptionRow(
+ text = stringResource(R.string.info_license_sharealike),
+ checked = licenseState.requireShareAlike,
+ onCheckedChange = licenseCallbacks::onRequireShareAlikeChange,
+ enabled = enabled && licenseState.allowRemix && licenseState.ccEnabled
+ )
+
+ // Allow Commercial Use Switch - matches RelativeLayout lines 98-122 in content_cc.xml
+ LicenseOptionRow(
+ text = stringResource(R.string.info_license_comm),
+ checked = licenseState.allowCommercial,
+ onCheckedChange = licenseCallbacks::onAllowCommercialChange,
+ enabled = enabled
+ )
+ }
+
+ // Show license URL when CC is enabled
+ if (licenseState.ccEnabled) {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // License URL - matches TextView lines 132-138 in content_cc.xml
+ licenseState.licenseUrl?.let { url ->
+ Text(
+ text = url,
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.tertiary,
+ textDecoration = TextDecoration.Underline,
+ modifier = Modifier
+ .clickable { context.openBrowser(url) }
+ .padding(vertical = 4.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Learn More Link - matches TextView lines 140-147 in content_cc.xml
+ Text(
+ text = stringResource(R.string.learn_more_about_creative_commons),
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.tertiary,
+ textDecoration = TextDecoration.Underline,
+ modifier = Modifier
+ .clickable { context.openBrowser("https://creativecommons.org/about/cclicenses/") }
+ .padding(vertical = 4.dp)
+ )
+ }
+}
+
+@Composable
+private fun LicenseOptionRow(
+ text: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = text,
+ modifier = Modifier.weight(1f),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ enabled = enabled,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colorScheme.surface,
+ checkedTrackColor = MaterialTheme.colorScheme.tertiary
+ )
+ )
+ }
+}
+
+@Preview(showBackground = true, name = "CC Collapsed (No License)")
+@Composable
+fun CreativeCommonsLicenseContentPreview() {
+ SaveAppTheme {
+ CreativeCommonsLicenseContent(
+ licenseState = LicenseState(
+ ccEnabled = false,
+ allowRemix = true,
+ requireShareAlike = false,
+ allowCommercial = false,
+ licenseUrl = null
+ ),
+ licenseCallbacks = object : LicenseCallbacks {
+ override fun onCcEnabledChange(enabled: Boolean) {}
+ override fun onAllowRemixChange(allowed: Boolean) {}
+ override fun onRequireShareAlikeChange(required: Boolean) {}
+ override fun onAllowCommercialChange(allowed: Boolean) {}
+ override fun onCc0EnabledChange(enabled: Boolean) {}
+ },
+ enabled = true,
+ )
+ }
+}
+
+@Preview(showBackground = true, name = "CC Expanded with License")
+@Composable
+fun CreativeCommonsLicenseContentWithLicensePreview() {
+ SaveAppTheme {
+ CreativeCommonsLicenseContent(
+ licenseState = LicenseState(
+ ccEnabled = true,
+ allowRemix = true,
+ requireShareAlike = true,
+ allowCommercial = false,
+ licenseUrl = "https://creativecommons.org/licenses/by-nc-sa/4.0/"
+ ),
+ licenseCallbacks = object : LicenseCallbacks {
+ override fun onCcEnabledChange(enabled: Boolean) {}
+ override fun onAllowRemixChange(allowed: Boolean) {}
+ override fun onRequireShareAlikeChange(required: Boolean) {}
+ override fun onAllowCommercialChange(allowed: Boolean) {}
+ override fun onCc0EnabledChange(enabled: Boolean) {}
+ },
+ enabled = true,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/LicenseState.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/LicenseState.kt
new file mode 100644
index 000000000..1e6d5b1cb
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/LicenseState.kt
@@ -0,0 +1,21 @@
+package net.opendasharchive.openarchive.services.webdav
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class LicenseState(
+ val ccEnabled: Boolean = false,
+ val allowRemix: Boolean = true,
+ val requireShareAlike: Boolean = false,
+ val allowCommercial: Boolean = false,
+ val cc0Enabled: Boolean = false,
+ val licenseUrl: String? = null
+)
+
+interface LicenseCallbacks {
+ fun onCcEnabledChange(enabled: Boolean)
+ fun onAllowRemixChange(allowed: Boolean)
+ fun onRequireShareAlikeChange(required: Boolean)
+ fun onAllowCommercialChange(allowed: Boolean)
+ fun onCc0EnabledChange(enabled: Boolean)
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavActivity.kt
deleted file mode 100644
index a6bd2af70..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavActivity.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-package net.opendasharchive.openarchive.services.webdav
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.fragment.app.commit
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.ActivityWebdavBinding
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.core.BaseActivity
-import net.opendasharchive.openarchive.features.main.MainActivity
-import kotlin.properties.Delegates
-
-class WebDavActivity : BaseActivity() {
-
- companion object {
- const val FRAGMENT_TAG = "webdav_fragment"
- }
-
- private lateinit var mBinding: ActivityWebdavBinding
- private var mSpaceId by Delegates.notNull()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- mBinding = ActivityWebdavBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
- setupToolbar(title = "Edit Private Server", showBackButton = true)
-
- mSpaceId = intent.getLongExtra(EXTRA_DATA_SPACE, WebDavFragment.ARG_VAL_NEW_SPACE)
-
- if (mSpaceId != WebDavFragment.ARG_VAL_NEW_SPACE) {
- supportFragmentManager.commit {
- replace(mBinding.webDavFragment.id, WebDavFragment.newInstance(mSpaceId))
- }
- }
-
- supportFragmentManager.setFragmentResultListener(WebDavFragment.RESP_SAVED, this) { _, _ ->
- finishAffinity()
- startActivity(Intent(this, MainActivity::class.java))
- }
-
- supportFragmentManager.setFragmentResultListener(WebDavFragment.RESP_DELETED, this) { _, _ ->
- Space.navigate(this)
- }
-
- supportFragmentManager.setFragmentResultListener(WebDavFragment.RESP_LICENSE, this) { _, _ ->
- // Navigate to license fragment
- // also update title with server name if available - like breadcrumb
- supportFragmentManager
- .beginTransaction()
- .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left)
- .replace(
- mBinding.webDavFragment.id,
- WebDavSetupLicenseFragment.newInstance(spaceId = mSpaceId, isEditing = true),
- FRAGMENT_TAG,
- )
- .commit()
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- // handle appbar back button tap
- if (item.itemId == android.R.id.home) {
- finish()
- return true
- }
- return super.onOptionsItemSelected(item)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavConduit.kt
index e95860e7d..9826d2c5d 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavConduit.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavConduit.kt
@@ -9,7 +9,6 @@ import net.opendasharchive.openarchive.services.Conduit
import net.opendasharchive.openarchive.services.SaveClient
import okhttp3.HttpUrl
import java.io.IOException
-import java.util.*
class WebDavConduit(media: Media, context: Context) : Conduit(media, context) {
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
index b935a9ab9..db4bd05f5 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavFragment.kt
@@ -1,47 +1,73 @@
package net.opendasharchive.openarchive.services.webdav
-import android.content.Context
+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 android.view.inputmethod.InputMethodManager
+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.fragment.app.setFragmentResult
-import com.google.android.material.snackbar.Snackbar
+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.BuildConfig
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.onboarding.BaseFragment
+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.AlertHelper
-import net.opendasharchive.openarchive.util.Utility
-import net.opendasharchive.openarchive.util.extensions.makeSnackBar
+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 var mSpaceId: Long? = null
+
private lateinit var mSpace: Space
- private lateinit var mSnackbar: Snackbar
+ private var isLoading = false
private lateinit var binding: FragmentWebDavBinding
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- mSpaceId = arguments?.getLong(ARG_SPACE_ID) ?: ARG_VAL_NEW_SPACE
- }
+ private var originalName: String? = null
+ private var isNameChanged = false
+
+ private val args: WebDavScreenFragmentArgs by navArgs()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
@@ -49,12 +75,17 @@ class WebDavFragment : BaseFragment() {
// Inflate the layout for this fragment
binding = FragmentWebDavBinding.inflate(inflater)
- mSpaceId = arguments?.getLong(ARG_SPACE_ID) ?: ARG_VAL_NEW_SPACE
+ binding.buttonBar.applyEdgeToEdgeInsets(
+ typeMask = WindowInsetsCompat.Type.navigationBars()
+ ) { insets ->
- if (mSpaceId != ARG_VAL_NEW_SPACE) {
- // setup views for editing and existing space
+ bottomMargin = insets.bottom
+ }
+
+ if (args.spaceId != ARG_VAL_NEW_SPACE) {
+ // setup views for editing an existing space
- mSpace = Space.get(mSpaceId!!) ?: Space(Space.Type.WEBDAV)
+ mSpace = Space.get(args.spaceId) ?: Space(Space.Type.WEBDAV)
binding.header.visibility = View.GONE
binding.buttonBar.visibility = View.GONE
@@ -72,6 +103,7 @@ class WebDavFragment : BaseFragment() {
binding.password.setText(mSpace.password)
binding.name.setText(mSpace.name)
+ binding.layoutName.visibility = View.VISIBLE
// mBinding.swChunking.isChecked = mSpace.useChunking
// mBinding.swChunking.setOnCheckedChangeListener { _, useChunking ->
@@ -81,41 +113,66 @@ class WebDavFragment : BaseFragment() {
binding.btRemove.setOnClickListener {
- removeProject()
+ removeSpace()
}
// swap webDavFragment with Creative Commons License Fragment
- binding.btLicense.setOnClickListener {
- setFragmentResult(RESP_LICENSE, bundleOf())
- }
+// 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()
- }
+// 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
+// }
+// }
- 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
@@ -123,14 +180,18 @@ class WebDavFragment : BaseFragment() {
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.name.visibility = View.GONE
}
binding.btAuthenticate.setOnClickListener { attemptLogin() }
binding.btCancel.setOnClickListener {
- setFragmentResult(RESP_CANCEL, bundleOf())
+ findNavController().popBackStack()
}
binding.server.setOnFocusChangeListener { _, hasFocus ->
@@ -141,7 +202,7 @@ class WebDavFragment : BaseFragment() {
binding.password.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_NULL) {
- attemptLogin()
+ //attemptLogin()
}
false
@@ -152,13 +213,105 @@ class WebDavFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- mSnackbar = binding.root.makeSnackBar(getString(R.string.login_activity_logging_message))
+
+ 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 = Uri.parse(url.toString())
+ val uri = url.toString().toUri()
val builder = uri.buildUpon()
if (uri.scheme != "https") {
@@ -194,8 +347,6 @@ class WebDavFragment : BaseFragment() {
mSpace.username = binding.username.text?.toString() ?: ""
mSpace.password = binding.password.text?.toString() ?: ""
-// mSpace.useChunking = mBinding.swChunking.isChecked
-
if (mSpace.host.isEmpty()) {
binding.server.error = getString(R.string.error_field_required)
errorView = binding.server
@@ -221,11 +372,10 @@ class WebDavFragment : BaseFragment() {
return showError(getString(R.string.you_already_have_a_server_with_these_credentials))
}
- // Show a progress spinner, and kick off a background task to
- // perform the user login attempt.
- mSnackbar.show()
+ // Show loading overlay and make screen non-interactable
+ showLoadingOverlay(true)
- CoroutineScope(Dispatchers.IO).launch {
+ lifecycleScope.launch(Dispatchers.IO) {
try {
testConnection()
mSpace.save()
@@ -235,27 +385,90 @@ class WebDavFragment : BaseFragment() {
// 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) {
- if (exception.message?.startsWith("401") == true) {
- showError(getString(R.string.error_incorrect_username_or_password), true)
- } else {
- showError(exception.localizedMessage ?: getString(R.string.error))
+ 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 navigate(spaceId: Long) {
- Utility.showMaterialMessage(
- context = requireContext(),
- title = "Success",
- message = "You have successfully authenticated! Now let's continue setting up your media server."
- ) {
- setFragmentResult(RESP_SAVED, bundleOf(ARG_SPACE_ID to spaceId))
+ 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")
@@ -289,71 +502,103 @@ class WebDavFragment : BaseFragment() {
private fun showError(text: CharSequence, onForm: Boolean = false) {
requireActivity().runOnUiThread {
- mSnackbar.dismiss()
+ showLoadingOverlay(false)
if (onForm) {
- binding.password.error = text
+ binding.errorHint.text = text
+ binding.errorHint.show()
binding.password.requestFocus()
} else {
- mSnackbar = binding.root.makeSnackBar(text, Snackbar.LENGTH_LONG)
- mSnackbar.show()
-
- binding.server.requestFocus()
+ // 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()
+ }
- // make sure the snack-bar is gone when this fragment isn't on display anymore
- mSnackbar.dismiss()
+ // 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 removeProject() {
- AlertHelper.show(
- requireContext(),
- 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) { _, _ ->
+ 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()
- setFragmentResult(RESP_DELETED, bundleOf())
- }, AlertHelper.negativeButton()
+ findNavController().popBackStack()
+ }
+ ),
+ neutralButton = ButtonData(
+ text = UiText.StringResource(R.string.lbl_Cancel),
+ action = {}
)
)
+ dialogManager.showDialog(config)
}
- companion object {
- // events emitted by this fragment
- const val RESP_SAVED = "web_dav_fragment_resp_saved"
- const val RESP_DELETED = "web_dav_fragment_resp_deleted"
- const val RESP_CANCEL = "web_dav_fragment_resp_cancel"
- const val RESP_LICENSE = "web_dav_fragment_resp_license"
-
- // factory method parameters (bundle args)
- const val ARG_SPACE_ID = "space"
- const val ARG_VAL_NEW_SPACE = -1L
+ 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) {}
- // other internal constants
- const val REMOTE_PHP_ADDRESS = "/remote.php/webdav/"
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ updateAuthenticateButtonState()
+ }
- @JvmStatic
- fun newInstance(spaceId: Long) = WebDavFragment().apply {
- arguments = Bundle().apply {
- putLong(ARG_SPACE_ID, spaceId)
+ override fun afterTextChanged(s: Editable?) {
+ dismissCredentialsError()
}
}
- @JvmStatic
- fun newInstance() = newInstance(ARG_VAL_NEW_SPACE)
+ 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()
}
- override fun getToolbarTitle(): String = if (mSpaceId == ARG_VAL_NEW_SPACE) {
- "Add Private Server"
+ 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 {
- "Edit Private Server"
+ val space = Space.get(args.spaceId)
+ when {
+ space?.name?.isNotBlank() == true -> space.name
+ space?.friendlyName?.isNotBlank() == true -> space.friendlyName
+ else -> "Private Server"
+ }
}
-}
\ No newline at end of file
+}
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
new file mode 100644
index 000000000..ebbb3c8ef
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavRepository.kt
@@ -0,0 +1,48 @@
+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
new file mode 100644
index 000000000..119ca2a1a
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavScreen.kt
@@ -0,0 +1,656 @@
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.ColorFilter
+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.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()
+
+ 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,
+ 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
+ )
+
+ 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
+ )
+
+ // 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/WebDavSetupLicenseFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavSetupLicenseFragment.kt
deleted file mode 100644
index d1804df12..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavSetupLicenseFragment.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-package net.opendasharchive.openarchive.services.webdav
-
-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.os.bundleOf
-import androidx.fragment.app.setFragmentResult
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.FragmentWebdavSetupLicenseBinding
-import net.opendasharchive.openarchive.db.Space
-import net.opendasharchive.openarchive.features.onboarding.BaseFragment
-import net.opendasharchive.openarchive.features.settings.CcSelector
-import kotlin.properties.Delegates
-
-class WebDavSetupLicenseFragment: BaseFragment() {
-
- private lateinit var binding: FragmentWebdavSetupLicenseBinding
-
- private var mSpaceId by Delegates.notNull()
-
- private lateinit var mSpace: Space
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
-
- binding = FragmentWebdavSetupLicenseBinding.inflate(layoutInflater)
- mSpaceId = arguments?.getLong(ARG_SPACE_ID)!!
- mSpace = Space.get(mSpaceId) ?: Space(Space.Type.WEBDAV)
-
- val isEditing = arguments?.getBoolean(ARG_IS_EDITING) ?: false
-
- if(isEditing) {
- // Editing means hide subtitle, bottom bar buttons
- binding.buttonBar.visibility = View.GONE
- binding.descriptionText.visibility = View.GONE
- }
-
-
- binding.btNext.setOnClickListener {
- setFragmentResult(RESP_SAVED, bundleOf())
- }
-
- binding.btCancel.setOnClickListener {
- setFragmentResult(RESP_CANCEL, bundleOf())
- }
-
- binding.cc.tvCc.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)
-
- val isEditing = arguments?.getBoolean(ARG_IS_EDITING) ?: false
-
- if(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()
- }
- })
-
- CcSelector.init(binding.cc, Space.current?.license) {
- val space = Space.current ?: return@init
-
- space.license = it
- space.save()
- }
- }
-
- companion object {
-
- // events emitted by this fragment
- const val RESP_SAVED = "webdav_setup_license_fragment_resp_saved"
- const val RESP_CANCEL = "webdav_setup_license_fragment_resp_cancel"
-
- const val ARG_SPACE_ID = "space_id"
- const val ARG_IS_EDITING = "isEditing"
-
- @JvmStatic
- fun newInstance(spaceId: Long, isEditing: Boolean) = WebDavSetupLicenseFragment().apply {
- arguments = Bundle().apply {
- // add any arguments here
- putLong(ARG_SPACE_ID, spaceId)
- putBoolean(ARG_IS_EDITING, isEditing)
- }
- }
- }
-
- override fun getToolbarTitle() = "Select a License"
- 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/services/webdav/WebDavState.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavState.kt
new file mode 100644
index 000000000..c0018d1d9
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavState.kt
@@ -0,0 +1,83 @@
+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
new file mode 100644
index 000000000..1b340c239
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt
@@ -0,0 +1,438 @@
+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 java.io.IOException
+
+class WebDavViewModel(
+ private val repository: WebDavRepository,
+ savedStateHandle: SavedStateHandle
+) : 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)
+ space.save()
+ Space.current = space
+
+ _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/upload/SKBottomSheetDialogFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/SKBottomSheetDialogFragment.kt
new file mode 100644
index 000000000..a1c26a062
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/upload/SKBottomSheetDialogFragment.kt
@@ -0,0 +1,69 @@
+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
index 78d628a1b..c3cf500cb 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/upload/SwipeToDeleteCallback.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/upload/SwipeToDeleteCallback.kt
@@ -26,15 +26,10 @@ abstract class SwipeToDeleteCallback(context: Context?): ItemTouchHelper.Callbac
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
- if (isEditingAllowed()) {
- return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN,
- ItemTouchHelper.START)
- }
- return 0
- }
+ return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
- abstract fun isEditingAllowed(): Boolean
+ }
override fun onChildDraw(
c: Canvas,
diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt
deleted file mode 100644
index 493a1ce19..000000000
--- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt
+++ /dev/null
@@ -1,151 +0,0 @@
-package net.opendasharchive.openarchive.upload
-
-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.Menu
-import android.view.MenuItem
-import net.opendasharchive.openarchive.CleanInsightsManager
-import net.opendasharchive.openarchive.R
-import net.opendasharchive.openarchive.databinding.ActivityUploadManagerBinding
-import net.opendasharchive.openarchive.db.Media
-import net.opendasharchive.openarchive.features.core.BaseActivity
-
-class UploadManagerActivity : BaseActivity() {
-
- private lateinit var mBinding: ActivityUploadManagerBinding
- var mFrag: UploadManagerFragment? = null
- private var mMenuEdit: MenuItem? = null
-
- private val mMessageReceiver: BroadcastReceiver = object : BroadcastReceiver() {
- private val handler = Handler(Looper.getMainLooper())
-
- override fun onReceive(context: Context, intent: Intent) {
- val action = BroadcastManager.getAction(intent)
- val mediaId = action?.mediaId ?: return
-
- if (mediaId > -1) {
- val media = Media.get(mediaId)
-
- if (action == BroadcastManager.Action.Delete || media?.sStatus == Media.Status.Uploaded) {
- handler.post { mFrag?.removeItem(mediaId) }
- }
- else {
- handler.post { mFrag?.updateItem(mediaId) }
- }
-
-// if (media?.sStatus == Media.Status.Error) {
-// CleanInsightsManager.getConsent(this@UploadManagerActivity) {
-// // TODO: Record metadata. See iOS implementation.
-// CleanInsightsManager.measureEvent("upload", "upload_failed")
-// }
-// }
- }
-
- handler.post {
- updateTitle()
- }
- }
- }
-
- private var mEditMode = false
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- mBinding = ActivityUploadManagerBinding.inflate(layoutInflater)
- setContentView(mBinding.root)
-
- setupToolbar(
- title = getString(R.string.uploads),
- showBackButton = true
- )
-
- mFrag = supportFragmentManager.findFragmentById(R.id.fragUploadManager) as? UploadManagerFragment
- }
-
- override fun onResume() {
- super.onResume()
- mFrag?.refresh()
-
- BroadcastManager.register(this, mMessageReceiver)
-
- updateTitle()
- }
-
- override fun onPause() {
- super.onPause()
-
- BroadcastManager.unregister(this, mMessageReceiver)
- }
-
- private fun toggleEditMode() {
- mEditMode = !mEditMode
- mFrag?.setEditMode(mEditMode)
- mFrag?.refresh()
-
- if (mEditMode) {
- mMenuEdit?.setTitle(R.string.menu_done)
-
- UploadService.stopUploadService(this)
- }
- else {
- mMenuEdit?.setTitle(R.string.edit)
-
- UploadService.startUploadService(this)
- }
-
- updateTitle()
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.menu_upload, menu)
- mMenuEdit = menu.findItem(R.id.menu_edit)
-
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- finish()
- return true
- }
- R.id.menu_edit -> {
- toggleEditMode()
- return true
- }
- }
- return super.onOptionsItemSelected(item)
- }
-
- override fun finish() {
- // If we're still in edit mode, restart the upload service when the user leaves.
- if (mEditMode) {
- UploadService.startUploadService(this)
- }
-
- super.finish()
- }
-
- private fun updateTitle() {
- if (mEditMode) {
- supportActionBar?.title = getString(R.string.edit_media)
- supportActionBar?.subtitle = getString(R.string.uploading_is_paused)
- }
- else {
- val count = mFrag?.getUploadingCounter() ?: 0
-
- supportActionBar?.title = if (count < 1) {
- getString(R.string.uploads)
- } else {
- getString(R.string.uploading_left, count)
- }
-
- supportActionBar?.subtitle = null
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerFragment.kt
index cef0814b8..6771391d0 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerFragment.kt
@@ -5,7 +5,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
-import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
@@ -13,18 +12,23 @@ 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.MediaAdapter
-import net.opendasharchive.openarchive.db.MediaViewHolder
+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 : Fragment() {
+open class UploadManagerFragment : SKBottomSheetDialogFragment() {
companion object {
- private val STATUSES = listOf(Media.Status.Uploading, Media.Status.Queued, Media.Status.Error)
+ const val TAG = "ModalBottomSheet-UploadManagerFragment"
+ private val STATUSES =
+ listOf(Media.Status.Uploading, Media.Status.Queued, Media.Status.Error)
}
- open var mediaAdapter: MediaAdapter? = null
+ private lateinit var uploadMediaAdapter: UploadMediaAdapter
- private lateinit var mBinding: FragmentUploadManagerBinding
+ private lateinit var binding: FragmentUploadManagerBinding
private lateinit var mItemTouchHelper: ItemTouchHelper
@@ -33,40 +37,43 @@ open class UploadManagerFragment : Fragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
- mBinding = FragmentUploadManagerBinding.inflate(inflater, container, false)
+ binding = FragmentUploadManagerBinding.inflate(inflater, container, false)
- mBinding.uploadList.layoutManager = LinearLayoutManager(activity)
+ binding.uploadList.layoutManager = LinearLayoutManager(activity)
- val decorator = DividerItemDecoration(mBinding.uploadList.context, DividerItemDecoration.VERTICAL)
- val divider = ContextCompat.getDrawable(mBinding.uploadList.context, R.drawable.divider)
+ val decorator =
+ DividerItemDecoration(binding.uploadList.context, DividerItemDecoration.VERTICAL)
+ val divider = ContextCompat.getDrawable(binding.uploadList.context, R.drawable.divider)
if (divider != null) decorator.setDrawable(divider)
- mBinding.uploadList.addItemDecoration(decorator)
- mBinding.uploadList.setHasFixedSize(true)
-
- mediaAdapter =
- MediaAdapter(
- activity,
- { MediaViewHolder.SmallRow(it) },
- Media.getByStatus(STATUSES, Media.ORDER_PRIORITY),
- mBinding.uploadList,
- listOf(Media.Status.Error)
- )
+ 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)
+ }
+ )
+ }
+ )
- mediaAdapter?.doImageFade = false
- mBinding.uploadList.adapter = mediaAdapter
+ uploadMediaAdapter.doImageFade = false
+ binding.uploadList.adapter = uploadMediaAdapter
mItemTouchHelper = ItemTouchHelper(object : SwipeToDeleteCallback(context) {
- override fun isEditingAllowed(): Boolean {
- return mediaAdapter?.isEditMode ?: false
- }
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
- mediaAdapter?.onItemMove(
+ uploadMediaAdapter.onItemMove(
viewHolder.bindingAdapterPosition,
target.bindingAdapterPosition
)
@@ -75,38 +82,86 @@ open class UploadManagerFragment : Fragment() {
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
- mediaAdapter?.deleteItem(viewHolder.bindingAdapterPosition)
+ // Do nothing
}
})
- mItemTouchHelper.attachToRecyclerView(mBinding.uploadList)
+ mItemTouchHelper.attachToRecyclerView(binding.uploadList)
- return mBinding.root
+ binding.root.findViewById(R.id.done_button)?.setOnClickListener {
+ dismiss() // Close the bottom sheet when clicked
+ }
+
+ return binding.root
}
override fun onResume() {
super.onResume()
-
refresh()
}
- open fun updateItem(mediaId: Long) {
- mediaAdapter?.updateItem(mediaId, -1)
+ override fun onDestroy() {
+ super.onDestroy()
+
+ // Notify MainActivity that this fragment is dismissed
+ (activity as? MainActivity)?.uploadManagerFragment = null
}
- open fun removeItem(mediaId: Long) {
- mediaAdapter?.removeItem(mediaId)
+ open fun updateItem(mediaId: Long) {
+ uploadMediaAdapter.updateItem(mediaId, -1)
}
- fun setEditMode(isEditMode: Boolean) {
- mediaAdapter?.isEditMode = isEditMode
+ open fun removeItem(mediaId: Long) {
+ uploadMediaAdapter.removeItem(mediaId)
}
open fun refresh() {
- mediaAdapter?.updateData(Media.getByStatus(STATUSES, Media.ORDER_PRIORITY))
+ uploadMediaAdapter.updateData(Media.getByStatus(STATUSES, Media.ORDER_PRIORITY))
}
open fun getUploadingCounter(): Int {
- return mediaAdapter?.media?.size ?: 0
+ 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/util/AlertHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/util/AlertHelper.kt
index 9a60215de..da34903f3 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/util/AlertHelper.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/util/AlertHelper.kt
@@ -6,7 +6,7 @@ import android.view.ContextThemeWrapper
import androidx.appcompat.app.AlertDialog
import net.opendasharchive.openarchive.R
-@Suppress("unused")
+@Deprecated("Move to common BaseDialog implementation using Jetpack Compose")
class AlertHelper {
class Button(
@@ -20,29 +20,36 @@ class AlertHelper {
}
companion object {
- fun show(context: Context, message: Int?, title: Int? = R.string.error,
- icon: Int? = null, buttons: List
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_preview.xml b/app/src/main/res/layout/activity_preview.xml
index f33c94a89..38b0cc432 100644
--- a/app/src/main/res/layout/activity_preview.xml
+++ b/app/src/main/res/layout/activity_preview.xml
@@ -1,6 +1,7 @@
@@ -13,60 +14,150 @@
android:id="@+id/media_grid"
android:layout_width="match_parent"
android:layout_height="0dp"
- app:layout_constraintBottom_toTopOf="@id/bt_add_more"
+ app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/common_app_bar" />
+ app:layout_constraintTop_toBottomOf="@id/common_app_bar"
+ tools:background="@color/white" />
-
+ app:layout_constraintStart_toStartOf="parent">
+
+
+
+
+
-
+ android:gravity="start"
+ android:paddingStart="8dp">
-
+
+
+
+ android:layout_weight="2"
+ android:gravity="center">
-
+
+
+
+ android:gravity="end"
+ android:paddingEnd="8dp">
+
+
+
diff --git a/app/src/main/res/layout/activity_review.xml b/app/src/main/res/layout/activity_review.xml
index 7f2b0aa2b..d6bd1e1d1 100644
--- a/app/src/main/res/layout/activity_review.xml
+++ b/app/src/main/res/layout/activity_review.xml
@@ -12,92 +12,123 @@
android:id="@+id/common_app_bar"
layout="@layout/common_app_bar" />
+
+ app:layout_constraintVertical_weight="0.55"
+ tools:visibility="gone">
-
+ app:layout_constraintTop_toTopOf="parent"
+ app:srcCompat="@drawable/no_thumbnail" />
-
-
-
+
-
+
-
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintWidth_max="320dp"
+ app:srcCompat="@drawable/no_thumbnail">
-
-
+
+
+
+
+
+
+
+
-
+
+
+
-
+
@@ -131,49 +165,53 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
-
+
-
-
+
+
+
+
+
+
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintVertical_weight="0.45">
+ style="@style/OATextInputEditTextStyle"
+ app:layout_constraintTop_toTopOf="parent">
+ app:layout_constraintStart_toStartOf="parent">
+ android:inputType="textMultiLine"
+ android:minLines="3" />
diff --git a/app/src/main/res/layout/activity_settings_container.xml b/app/src/main/res/layout/activity_settings_container.xml
index bb09b3f2f..c494d1a67 100644
--- a/app/src/main/res/layout/activity_settings_container.xml
+++ b/app/src/main/res/layout/activity_settings_container.xml
@@ -1,12 +1,14 @@
+ tools:context=".features.settings.ProofModeSettingsActivity">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_space_setup.xml b/app/src/main/res/layout/activity_space_setup.xml
index 887bcb257..0a0834f81 100644
--- a/app/src/main/res/layout/activity_space_setup.xml
+++ b/app/src/main/res/layout/activity_space_setup.xml
@@ -1,17 +1,19 @@
-
-
+ android:layout_height="match_parent">
+ android:layout_height="match_parent"
+ android:fillViewport="true"
+ android:filterTouchesWhenObscured="true"
+ android:orientation="vertical"
+ tools:context=".features.onboarding.SpaceSetupActivity">
+
+
+ app:defaultNavHost="true"
+ app:navGraph="@navigation/app_nav_graph" />
-
+
+
diff --git a/app/src/main/res/layout/activity_spaces.xml b/app/src/main/res/layout/activity_spaces.xml
deleted file mode 100644
index 91e4bb889..000000000
--- a/app/src/main/res/layout/activity_spaces.xml
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_upload_manager.xml b/app/src/main/res/layout/activity_upload_manager.xml
deleted file mode 100644
index c079cba8a..000000000
--- a/app/src/main/res/layout/activity_upload_manager.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_webdav.xml b/app/src/main/res/layout/activity_webdav.xml
deleted file mode 100644
index 14efe68aa..000000000
--- a/app/src/main/res/layout/activity_webdav.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/block_onboarding23_title.xml b/app/src/main/res/layout/block_onboarding23_title.xml
index f4e39c0f9..52ad557c0 100644
--- a/app/src/main/res/layout/block_onboarding23_title.xml
+++ b/app/src/main/res/layout/block_onboarding23_title.xml
@@ -7,12 +7,13 @@
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical"
- android:weightSum="55">
+ android:weightSum="55"
+ tools:showIn="@layout/activity_onboarding23">
@@ -47,10 +48,10 @@
android:scaleX="1.15"
android:scaleY="1.15"
android:text="@string/intro_header_secure"
+ android:textColor="@color/colorOnBackground"
android:textFontWeight="900"
- android:textStyle="bold"
- app:autoSizeTextType="uniform"
- tools:targetApi="p" />
+ android:textSize="60sp"
+ app:autoSizeTextType="uniform" />
@@ -75,10 +76,10 @@
android:scaleX="1.15"
android:scaleY="1.15"
android:text="@string/intro_header_archive"
+ android:textColor="@color/colorOnBackground"
android:textFontWeight="900"
- android:textStyle="bold"
- app:autoSizeTextType="uniform"
- tools:targetApi="p" />
+ android:textSize="60sp"
+ app:autoSizeTextType="uniform" />
+ android:textSize="60sp"
+ app:autoSizeTextType="uniform" />
@@ -131,26 +132,27 @@
android:scaleX="1.15"
android:scaleY="1.15"
android:text="@string/intro_header_encrypt"
+ android:textColor="@color/colorOnBackground"
android:textFontWeight="900"
- android:textStyle="bold"
- app:autoSizeTextType="uniform"
- tools:targetApi="p" />
+ android:textSize="60sp"
+ app:autoSizeTextType="uniform" />
+ android:layout_weight="2" />
diff --git a/app/src/main/res/layout/common_app_bar.xml b/app/src/main/res/layout/common_app_bar.xml
index 2e31c5ec0..8dcc60659 100644
--- a/app/src/main/res/layout/common_app_bar.xml
+++ b/app/src/main/res/layout/common_app_bar.xml
@@ -2,15 +2,16 @@
diff --git a/app/src/main/res/layout/compose_dialog_host.xml b/app/src/main/res/layout/compose_dialog_host.xml
new file mode 100644
index 000000000..c09131da0
--- /dev/null
+++ b/app/src/main/res/layout/compose_dialog_host.xml
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/content_cc.xml b/app/src/main/res/layout/content_cc.xml
index a7c89adf2..2604ce8a9 100644
--- a/app/src/main/res/layout/content_cc.xml
+++ b/app/src/main/res/layout/content_cc.xml
@@ -12,14 +12,15 @@
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:context=".features.onboarding.SpaceSetupActivity"
tools:background="@color/colorBackground"
- tools:showIn="@layout/fragment_webdav_setup_license">
+ tools:showIn="@layout/fragment_setup_license">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/content_upload_manager.xml b/app/src/main/res/layout/content_upload_manager.xml
deleted file mode 100644
index 66c4f7468..000000000
--- a/app/src/main/res/layout/content_upload_manager.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/custom_bottom_nav.xml b/app/src/main/res/layout/custom_bottom_nav.xml
index aad880277..b2978a6e4 100644
--- a/app/src/main/res/layout/custom_bottom_nav.xml
+++ b/app/src/main/res/layout/custom_bottom_nav.xml
@@ -29,7 +29,7 @@
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
- app:iconTint="@color/colorOnBottomNavbar"
+ app:iconTint="@color/white"
app:rippleColor="@color/c23_light_grey" />
@@ -56,13 +57,13 @@
@@ -89,7 +90,7 @@
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
- app:iconTint="@color/colorOnBottomNavbar"
+ app:iconTint="@color/white"
app:rippleColor="@color/c23_light_grey" />
diff --git a/app/src/main/res/layout/custom_button.xml b/app/src/main/res/layout/custom_button.xml
index 9141a9868..d55faa39b 100644
--- a/app/src/main/res/layout/custom_button.xml
+++ b/app/src/main/res/layout/custom_button.xml
@@ -15,7 +15,7 @@
android:layout_marginBottom="5dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
- tools:src="@drawable/logo_drive_2020q4_color_2x_web_64dp" />
+ tools:src="@drawable/ic_dweb" />
-
diff --git a/app/src/main/res/layout/custom_preference_category.xml b/app/src/main/res/layout/custom_preference_category.xml
index 98e7e46b0..ce50d1cc8 100644
--- a/app/src/main/res/layout/custom_preference_category.xml
+++ b/app/src/main/res/layout/custom_preference_category.xml
@@ -6,7 +6,7 @@
android:background="@android:color/transparent"
android:orientation="vertical"
android:paddingHorizontal="16dp"
- android:paddingTop="16dp"
+ android:paddingTop="24dp"
android:paddingBottom="8dp">
@@ -15,9 +15,9 @@
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:fontFamily="sans-serif"
+ android:fontFamily="@font/montserrat_semi_bold"
android:lineSpacingExtra="3sp"
- android:textColor="@color/c23_teal"
+ android:textColor="@color/colorTertiary"
android:textFontWeight="600"
android:textSize="18sp"
android:textStyle="normal"
@@ -27,7 +27,7 @@
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:fontFamily="sans-serif"
+ android:fontFamily="@font/montserrat_medium"
android:lineSpacingExtra="3sp"
android:textColor="@color/colorOnSurface"
android:textSize="14sp"
diff --git a/app/src/main/res/layout/custom_preference_divider.xml b/app/src/main/res/layout/custom_preference_divider.xml
index 7fb2f3256..608af8933 100644
--- a/app/src/main/res/layout/custom_preference_divider.xml
+++ b/app/src/main/res/layout/custom_preference_divider.xml
@@ -2,5 +2,5 @@
\ No newline at end of file
diff --git a/app/src/main/res/layout/custom_preference_switch.xml b/app/src/main/res/layout/custom_preference_switch.xml
index 2013b4020..7e46a650c 100644
--- a/app/src/main/res/layout/custom_preference_switch.xml
+++ b/app/src/main/res/layout/custom_preference_switch.xml
@@ -20,6 +20,7 @@
@@ -29,33 +30,32 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="marquee"
+ android:fontFamily="@font/montserrat_medium"
android:singleLine="true"
+ android:textColor="@color/colorOnBackground"
android:textFontWeight="500"
- android:textAppearance="?android:attr/textAppearanceListItem"
android:textSize="14sp"
tools:text="Title" />
-
-
-
+ android:orientation="vertical" />
\ No newline at end of file
diff --git a/app/src/main/res/layout/folder_row.xml b/app/src/main/res/layout/folder_row.xml
index 53bf25f5d..03a29fa26 100644
--- a/app/src/main/res/layout/folder_row.xml
+++ b/app/src/main/res/layout/folder_row.xml
@@ -6,7 +6,7 @@
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:padding="8dp"
- android:background="@drawable/item_background_selector"
+ android:background="@drawable/list_item_background_selector"
android:filterTouchesWhenObscured="true">
@@ -30,7 +30,7 @@
android:layout_marginEnd="@dimen/activity_vertical_margin"
android:importantForAccessibility="no"
app:tint="@color/colorOnPrimaryContainer"
- android:src="@drawable/ic_folder" />
+ android:src="@drawable/ic_folder_new" />
+ app:tint="@color/colorOnBackground" />
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_add_media_dialog.xml b/app/src/main/res/layout/fragment_add_media_dialog.xml
index eedd20bb8..8f6b50d03 100644
--- a/app/src/main/res/layout/fragment_add_media_dialog.xml
+++ b/app/src/main/res/layout/fragment_add_media_dialog.xml
@@ -185,7 +185,7 @@
android:textFontWeight="700"
android:textSize="14sp"
app:layout_constraintWidth_percent="0.8"
- tools:targetApi="p" />
+ />
diff --git a/app/src/main/res/layout/activity_browse_folders.xml b/app/src/main/res/layout/fragment_browse_folders.xml
similarity index 75%
rename from app/src/main/res/layout/activity_browse_folders.xml
rename to app/src/main/res/layout/fragment_browse_folders.xml
index 512ede217..86a6d8baa 100644
--- a/app/src/main/res/layout/activity_browse_folders.xml
+++ b/app/src/main/res/layout/fragment_browse_folders.xml
@@ -6,25 +6,17 @@
android:layout_height="match_parent"
android:filterTouchesWhenObscured="true"
tools:background="@color/colorBackground"
- tools:context=".features.folders.BrowseFoldersActivity">
+ tools:context=".features.folders.BrowseFoldersFragment">
-
-
-
+ android:paddingHorizontal="8dp"
+ tools:listitem="@layout/folder_row" />
-
-
+ android:layout_marginHorizontal="24dp"
+ android:padding="16dp"
+ android:text="@string/content_picker_label"
+ android:textSize="16sp"
+ android:fontFamily="@font/montserrat_semi_bold"
+ android:textColor="@color/colorOnBackground" />
@@ -36,22 +40,27 @@
android:id="@+id/action_upload_camera"
android:layout_width="0dp"
android:layout_height="wrap_content"
+ android:layout_gravity="bottom"
android:layout_weight="1"
+ android:gravity="bottom"
android:orientation="vertical">
+ app:tint="@color/colorOnBackground" />
+ android:textAlignment="center"
+ android:textSize="14sp" />
@@ -59,160 +68,57 @@
android:id="@+id/action_upload_media"
android:layout_width="0dp"
android:layout_height="wrap_content"
+ android:layout_gravity="bottom"
android:layout_weight="1"
+ android:gravity="bottom"
android:orientation="vertical">
+ app:tint="@color/colorOnBackground" />
+ android:textAlignment="center"
+ android:textSize="14sp" />
+ app:tint="@color/colorOnBackground" />
+ android:textAlignment="center"
+ android:textSize="14sp" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_create_new_folder.xml b/app/src/main/res/layout/fragment_create_new_folder.xml
new file mode 100644
index 000000000..cdf9be94c
--- /dev/null
+++ b/app/src/main/res/layout/fragment_create_new_folder.xml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_edit_folder.xml b/app/src/main/res/layout/fragment_folder_detail.xml
similarity index 79%
rename from app/src/main/res/layout/activity_edit_folder.xml
rename to app/src/main/res/layout/fragment_folder_detail.xml
index 36ff7d2ab..2b04d8776 100644
--- a/app/src/main/res/layout/activity_edit_folder.xml
+++ b/app/src/main/res/layout/fragment_folder_detail.xml
@@ -7,32 +7,28 @@
android:filterTouchesWhenObscured="true"
android:orientation="vertical"
tools:background="@color/colorBackground"
- tools:context=".features.settings.EditFolderActivity">
-
-
-
+ tools:context=".features.settings.FolderDetailFragment">
+ app:layout_constraintTop_toTopOf="parent" />
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/folder_name_container">
-
+ android:padding="16dp"
+ android:text="@string/action_archive_project"
+ android:textColor="@color/colorOnPrimaryContainer"
+ android:textSize="18sp" />
+ android:gravity="center">
-
+ android:text="@string/remove_from_app"
+ android:textColor="@color/red_bg"
+ android:textSize="18sp"
+ android:textStyle="bold" />
diff --git a/app/src/main/res/layout/activity_folders.xml b/app/src/main/res/layout/fragment_folders.xml
similarity index 52%
rename from app/src/main/res/layout/activity_folders.xml
rename to app/src/main/res/layout/fragment_folders.xml
index a9e7ae594..738cc04c7 100644
--- a/app/src/main/res/layout/activity_folders.xml
+++ b/app/src/main/res/layout/fragment_folders.xml
@@ -7,38 +7,42 @@
android:filterTouchesWhenObscured="true"
android:orientation="vertical"
tools:background="@color/colorBackground"
- tools:context=".features.settings.FoldersActivity">
-
+ tools:context=".features.settings.FoldersFragment">
-
-
-
+ android:orientation="vertical">
+
+
-
-
+ android:textSize="18sp"
+ android:textColor="@color/colorOnSurface"
+ android:textFontWeight="600"
+ android:text="@string/lbl_no_archived_folders"/>
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_gdrive.xml b/app/src/main/res/layout/fragment_gdrive.xml
deleted file mode 100644
index c1f3f4950..000000000
--- a/app/src/main/res/layout/fragment_gdrive.xml
+++ /dev/null
@@ -1,126 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_main_media.xml b/app/src/main/res/layout/fragment_main_media.xml
index aa90f9fb1..7d125fe04 100644
--- a/app/src/main/res/layout/fragment_main_media.xml
+++ b/app/src/main/res/layout/fragment_main_media.xml
@@ -1,11 +1,10 @@
-
@@ -15,41 +14,33 @@
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="top"
- android:layout_marginTop="6dp"
android:gravity="top"
android:orientation="vertical">
-
-
-
-
+
+ android:layout_height="match_parent"
+ android:elevation="1dp">
+ app:layout_constraintTop_toTopOf="parent" />
+ app:layout_constraintTop_toBottomOf="@id/tv_welcome" />
-
+ app:tint="@color/c23_medium_grey">
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/fragment_onboarding23_slide.xml b/app/src/main/res/layout/fragment_onboarding23_slide.xml
index a36cd1ad4..4820ccf40 100644
--- a/app/src/main/res/layout/fragment_onboarding23_slide.xml
+++ b/app/src/main/res/layout/fragment_onboarding23_slide.xml
@@ -4,7 +4,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
- android:paddingStart="32dp"
+ android:paddingStart="24dp"
android:paddingEnd="128dp"
android:weightSum="125"
tools:context=".features.onboarding.Onboarding23SlideFragment">
@@ -12,44 +12,45 @@
+ android:layout_weight="8" />
-
+ android:orientation="vertical">
-
-
-
-
-
-
-
-
+ android:text="title"
+ android:textAllCaps="true"
+ android:lineSpacingExtra="0sp"
+ android:textFontWeight="800"
+ android:textSize="28sp"
+ android:textStyle="bold"
+ tools:ignore="HardcodedText" />
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
deleted file mode 100644
index 4c2b39c7a..000000000
--- a/app/src/main/res/layout/fragment_settings.xml
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_webdav_setup_license.xml b/app/src/main/res/layout/fragment_setup_license.xml
similarity index 81%
rename from app/src/main/res/layout/fragment_webdav_setup_license.xml
rename to app/src/main/res/layout/fragment_setup_license.xml
index 73521a9b5..40e31d7f2 100644
--- a/app/src/main/res/layout/fragment_webdav_setup_license.xml
+++ b/app/src/main/res/layout/fragment_setup_license.xml
@@ -6,16 +6,17 @@
android:layout_height="match_parent"
android:fillViewport="true"
tools:background="@color/colorBackground"
- tools:context=".services.webdav.WebDavSetupLicenseFragment">
+ tools:context=".features.settings.license.SetupLicenseFragment">
@@ -62,14 +64,17 @@
android:id="@+id/button_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:baselineAligned="false"
android:orientation="horizontal"
android:paddingTop="@dimen/activity_vertical_margin"
android:weightSum="2"
+ android:gravity="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
+ android:textColor="@color/colorText"
+ android:visibility="visible" />
@@ -97,14 +102,14 @@
android:gravity="center"
tools:ignore="ButtonStyle">
-
diff --git a/app/src/main/res/layout/fragment_snowbird.xml b/app/src/main/res/layout/fragment_snowbird.xml
index 0a471ef56..dba347af7 100644
--- a/app/src/main/res/layout/fragment_snowbird.xml
+++ b/app/src/main/res/layout/fragment_snowbird.xml
@@ -61,7 +61,7 @@
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginTop="40dp"
- android:text="join group"
+ android:text="Join Group"
app:layout_constraintTop_toBottomOf="@id/my_groups_button" />
diff --git a/app/src/main/res/layout/fragment_snowbird_list_groups.xml b/app/src/main/res/layout/fragment_snowbird_group_list.xml
similarity index 100%
rename from app/src/main/res/layout/fragment_snowbird_list_groups.xml
rename to app/src/main/res/layout/fragment_snowbird_group_list.xml
diff --git a/app/src/main/res/layout/fragment_space_list.xml b/app/src/main/res/layout/fragment_space_list.xml
new file mode 100644
index 000000000..10cae8fd7
--- /dev/null
+++ b/app/src/main/res/layout/fragment_space_list.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_space_setup.xml b/app/src/main/res/layout/fragment_space_setup.xml
deleted file mode 100644
index c3f28f0fd..000000000
--- a/app/src/main/res/layout/fragment_space_setup.xml
+++ /dev/null
@@ -1,326 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_space_setup_success.xml b/app/src/main/res/layout/fragment_space_setup_success.xml
index a8bb80a1e..5e0b7fa5c 100644
--- a/app/src/main/res/layout/fragment_space_setup_success.xml
+++ b/app/src/main/res/layout/fragment_space_setup_success.xml
@@ -11,28 +11,30 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/activity_horizontal_margin"
- android:paddingTop="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingBottom="@dimen/activity_vertical_margin">
+
+
-
+ app:layout_constraintRight_toRightOf="parent" />
-
+ app:layout_constraintStart_toStartOf="parent">
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_upload_manager.xml b/app/src/main/res/layout/fragment_upload_manager.xml
index af691fa4b..c436486db 100644
--- a/app/src/main/res/layout/fragment_upload_manager.xml
+++ b/app/src/main/res/layout/fragment_upload_manager.xml
@@ -1,23 +1,85 @@
-
+ android:fillViewport="true"
+ app:layout_behavior="@string/bottom_sheet_behavior">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_height="match_parent"/>
-
+
diff --git a/app/src/main/res/layout/fragment_web_dav.xml b/app/src/main/res/layout/fragment_web_dav.xml
index 8a1cac9ae..037d6c7ab 100644
--- a/app/src/main/res/layout/fragment_web_dav.xml
+++ b/app/src/main/res/layout/fragment_web_dav.xml
@@ -35,7 +35,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
- android:layout_marginVertical="24dp">
+ android:layout_marginTop="48dp"
+ android:layout_marginBottom="24dp">
@@ -52,7 +53,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
- android:orientation="vertical">
+ android:orientation="vertical"
+ android:paddingEnd="23dp">
+ android:visibility="gone" />
@@ -83,19 +85,22 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
- android:text="@string/server_info" />
+ android:text="@string/server_info"
+ android:textFontWeight="600"
+ android:textSize="18sp" />
+ app:hintEnabled="false">
@@ -103,9 +108,12 @@
+ android:visibility="visible"
+ tools:visibility="visible">
+ android:text="@string/account"
+ android:textFontWeight="600"
+ android:textSize="18sp" />
+ android:layout_marginBottom="8dp"
+ android:paddingVertical="4dp">
@@ -155,7 +166,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="4dp"
- app:endIconMode="password_toggle">
+ app:endIconMode="password_toggle"
+ app:endIconDrawable="@drawable/password_toggle_icon">
-
+
+
-
+
-
+
-
+
+
-
-
+ android:gravity="center"
+ android:orientation="horizontal"
+ android:paddingTop="@dimen/activity_vertical_margin"
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ tools:visibility="visible">
-
-
+
-
+
+
-
+
+
+ app:layout_constraintStart_toStartOf="parent"
+ tools:visibility="visible">
+ android:layout_marginHorizontal="24dp"
+ android:layout_weight="1"
+ android:gravity="center"
+ android:textColor="@color/colorText">
+ android:fontFamily="@font/montserrat_semi_bold"
+ android:text="@string/back"
+ android:textColor="@color/colorText"
+ android:textSize="18sp" />
-
+ android:enabled="false"
+ android:fontFamily="@font/montserrat_semi_bold"
+ android:paddingHorizontal="24dp"
+ android:paddingVertical="8dp"
+ android:text="@string/action_next"
+ android:textSize="18sp"
+ app:backgroundTint="@color/button_color" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/one_line_row.xml b/app/src/main/res/layout/one_line_row.xml
index 29843ea38..eec207fe8 100644
--- a/app/src/main/res/layout/one_line_row.xml
+++ b/app/src/main/res/layout/one_line_row.xml
@@ -4,7 +4,7 @@
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:drawableStart="@drawable/logo_drive_2020q4_color_2x_web_64dp"
+ android:drawableStart="@drawable/ic_dweb"
android:drawableEnd="@drawable/button_with_right_icon"
android:background="@drawable/item_background_selector"
android:padding="16dp"
diff --git a/app/src/main/res/layout/popup_folder_options.xml b/app/src/main/res/layout/popup_folder_options.xml
new file mode 100644
index 000000000..47922d7f2
--- /dev/null
+++ b/app/src/main/res/layout/popup_folder_options.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/rv_drawer_row.xml b/app/src/main/res/layout/rv_drawer_row.xml
new file mode 100644
index 000000000..9c4ef2387
--- /dev/null
+++ b/app/src/main/res/layout/rv_drawer_row.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/rv_folders_row.xml b/app/src/main/res/layout/rv_folders_row.xml
index d87921706..ca225dcde 100644
--- a/app/src/main/res/layout/rv_folders_row.xml
+++ b/app/src/main/res/layout/rv_folders_row.xml
@@ -1,70 +1,33 @@
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_gravity="center_vertical"
+ android:layout_marginHorizontal="10dp"
+ android:fontFamily="@font/montserrat_semi_bold"
+ android:gravity="center_vertical"
+ android:textSize="18sp" />
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/rv_media_box.xml b/app/src/main/res/layout/rv_media_box.xml
index cd7332e24..a0f08b60d 100644
--- a/app/src/main/res/layout/rv_media_box.xml
+++ b/app/src/main/res/layout/rv_media_box.xml
@@ -20,6 +20,13 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
+
+
-
-
+ android:background="@color/transparent_loading_overlay">
+ app:trackThickness="4dp"
+ tools:visibility="visible" />
+ app:tint="@color/colorDanger"
+ tools:visibility="invisible" />
+
+
+
+
+ android:background="@drawable/bg_selected_overlay" />
@@ -84,15 +89,16 @@
android:id="@+id/progress_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="100%"
- android:textSize="12sp"
- android:visibility="gone"
android:textColor="@color/colorPrimary"
+ android:textSize="9sp"
+ android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
- tools:ignore="HardcodedText" />
+ tools:ignore="HardcodedText"
+ tools:text="65"
+ tools:visibility="visible" />
+
+
diff --git a/app/src/main/res/layout/rv_media_row_big.xml b/app/src/main/res/layout/rv_media_row_big.xml
index 0ada2a2a8..1c7831bc7 100644
--- a/app/src/main/res/layout/rv_media_row_big.xml
+++ b/app/src/main/res/layout/rv_media_row_big.xml
@@ -37,15 +37,6 @@
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+ tools:text="This is the title" />
-
+
-
+
-
+ app:srcCompat="@drawable/ic_reorder_black_24dp" />
diff --git a/app/src/main/res/layout/view_section.xml b/app/src/main/res/layout/view_section.xml
index c85cc961a..013594423 100644
--- a/app/src/main/res/layout/view_section.xml
+++ b/app/src/main/res/layout/view_section.xml
@@ -25,7 +25,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
- android:textSize="12sp"
+ android:textColor="@color/colorOnBackground"
+ style="@style/TextAppearance_Save_BodySmall"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
@@ -38,6 +39,8 @@
android:layout_marginStart="8dp"
android:background="@drawable/pill"
android:gravity="center"
+ style="@style/TextAppearance_Save_BodySmall"
+ android:textColor="@color/colorOnBackground"
android:paddingStart="8dp"
android:paddingEnd="8dp"
app:layout_constraintBottom_toBottomOf="parent"
@@ -53,6 +56,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/activity_horizontal_margin"
+ android:nestedScrollingEnabled="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/app/src/main/res/menu/menu_confirm.xml b/app/src/main/res/menu/menu_confirm.xml
new file mode 100644
index 000000000..0ce6df0a7
--- /dev/null
+++ b/app/src/main/res/menu/menu_confirm.xml
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
index 69d2baf82..8f6929ee5 100644
--- a/app/src/main/res/menu/menu_main.xml
+++ b/app/src/main/res/menu/menu_main.xml
@@ -6,7 +6,7 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/menu/menu_main_edit_folder_bar.xml b/app/src/main/res/menu/menu_main_edit_folder_bar.xml
new file mode 100644
index 000000000..a08f46667
--- /dev/null
+++ b/app/src/main/res/menu/menu_main_edit_folder_bar.xml
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_upload.xml b/app/src/main/res/menu/menu_upload.xml
index 5ef3f9cf1..b13726dd9 100644
--- a/app/src/main/res/menu/menu_upload.xml
+++ b/app/src/main/res/menu/menu_upload.xml
@@ -5,9 +5,9 @@
tools:context=".features.main.MainActivity">
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calendar.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calendar.xml
new file mode 100644
index 000000000..97a347ebb
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calendar.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_mask_launcher_calculator.xml b/app/src/main/res/mipmap-anydpi-v26/ic_mask_launcher_calculator.xml
new file mode 100644
index 000000000..5b5dc9230
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_mask_launcher_calculator.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_mask_launcher_dictionary.xml b/app/src/main/res/mipmap-anydpi-v26/ic_mask_launcher_dictionary.xml
new file mode 100644
index 000000000..876495cb3
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_mask_launcher_dictionary.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_calendar.png b/app/src/main/res/mipmap-hdpi/ic_launcher_calendar.png
new file mode 100644
index 000000000..e43b51e31
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_calendar.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_calendar_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_calendar_background.png
new file mode 100644
index 000000000..a49a7367b
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_calendar_background.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_calendar_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_calendar_foreground.png
new file mode 100644
index 000000000..fd6a8683f
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_calendar_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_calendar_monochrome.png b/app/src/main/res/mipmap-hdpi/ic_launcher_calendar_monochrome.png
new file mode 100644
index 000000000..fd6a8683f
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_calendar_monochrome.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator.png b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator.png
new file mode 100644
index 000000000..0fcaa261f
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator_background.png b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator_background.png
new file mode 100644
index 000000000..889119a6f
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator_background.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator_foreground.png b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator_foreground.png
new file mode 100644
index 000000000..f12db5964
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator_monochrome.png b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator_monochrome.png
new file mode 100644
index 000000000..f12db5964
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_calculator_monochrome.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary.png b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary.png
new file mode 100644
index 000000000..0e6af2187
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary_background.png b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary_background.png
new file mode 100644
index 000000000..e77fb9e26
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary_background.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary_foreground.png b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary_foreground.png
new file mode 100644
index 000000000..021a1ae85
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary_monochrome.png b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary_monochrome.png
new file mode 100644
index 000000000..021a1ae85
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_mask_launcher_dictionary_monochrome.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_calendar.png b/app/src/main/res/mipmap-mdpi/ic_launcher_calendar.png
new file mode 100644
index 000000000..33fd5ab66
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_calendar.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_calendar_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_calendar_background.png
new file mode 100644
index 000000000..f156d4004
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_calendar_background.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_calendar_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_calendar_foreground.png
new file mode 100644
index 000000000..2a4df6184
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_calendar_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_calendar_monochrome.png b/app/src/main/res/mipmap-mdpi/ic_launcher_calendar_monochrome.png
new file mode 100644
index 000000000..2a4df6184
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_calendar_monochrome.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator.png b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator.png
new file mode 100644
index 000000000..fe22df819
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator_background.png b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator_background.png
new file mode 100644
index 000000000..1eb99128e
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator_background.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator_foreground.png b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator_foreground.png
new file mode 100644
index 000000000..cedd5d26c
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator_monochrome.png b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator_monochrome.png
new file mode 100644
index 000000000..cedd5d26c
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_calculator_monochrome.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary.png b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary.png
new file mode 100644
index 000000000..2525c69c1
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary_background.png b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary_background.png
new file mode 100644
index 000000000..c48c17fc7
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary_background.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary_foreground.png b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary_foreground.png
new file mode 100644
index 000000000..57b8e4563
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary_monochrome.png b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary_monochrome.png
new file mode 100644
index 000000000..57b8e4563
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_mask_launcher_dictionary_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar.png
new file mode 100644
index 000000000..a2ed5c282
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar_background.png
new file mode 100644
index 000000000..c9239bb31
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar_background.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar_foreground.png
new file mode 100644
index 000000000..725b8d950
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar_monochrome.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar_monochrome.png
new file mode 100644
index 000000000..725b8d950
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_calendar_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator.png b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator.png
new file mode 100644
index 000000000..414249c76
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator_background.png b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator_background.png
new file mode 100644
index 000000000..0725745fe
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator_background.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator_foreground.png
new file mode 100644
index 000000000..965c8cb05
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator_monochrome.png b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator_monochrome.png
new file mode 100644
index 000000000..965c8cb05
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_calculator_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary.png b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary.png
new file mode 100644
index 000000000..6fdc0e641
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary_background.png b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary_background.png
new file mode 100644
index 000000000..a8789668d
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary_background.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary_foreground.png
new file mode 100644
index 000000000..3f280c1ad
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary_monochrome.png b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary_monochrome.png
new file mode 100644
index 000000000..8c061d5b6
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_mask_launcher_dictionary_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar.png
new file mode 100644
index 000000000..a26b7fb66
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar_background.png
new file mode 100644
index 000000000..03f12c7b9
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar_background.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar_foreground.png
new file mode 100644
index 000000000..bc8457bb1
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar_monochrome.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar_monochrome.png
new file mode 100644
index 000000000..bc8457bb1
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_calendar_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator.png b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator.png
new file mode 100644
index 000000000..a6c345774
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator_background.png b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator_background.png
new file mode 100644
index 000000000..cee262709
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator_background.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator_foreground.png
new file mode 100644
index 000000000..8bfbaa5a3
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator_monochrome.png b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator_monochrome.png
new file mode 100644
index 000000000..8bfbaa5a3
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_calculator_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary.png b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary.png
new file mode 100644
index 000000000..be01b9102
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary_background.png b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary_background.png
new file mode 100644
index 000000000..cbf606746
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary_background.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary_foreground.png
new file mode 100644
index 000000000..1a9a75598
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary_monochrome.png b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary_monochrome.png
new file mode 100644
index 000000000..1a9a75598
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_mask_launcher_dictionary_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar.png
new file mode 100644
index 000000000..176f7efcb
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar_background.png
new file mode 100644
index 000000000..e99b74cff
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar_background.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar_foreground.png
new file mode 100644
index 000000000..b9e38f99f
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar_monochrome.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar_monochrome.png
new file mode 100644
index 000000000..b9e38f99f
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_calendar_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator.png b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator.png
new file mode 100644
index 000000000..8f5c22ac3
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator_background.png
new file mode 100644
index 000000000..511fefbbd
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator_background.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator_foreground.png
new file mode 100644
index 000000000..e10ddef89
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator_monochrome.png b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator_monochrome.png
new file mode 100644
index 000000000..e10ddef89
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_calculator_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary.png b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary.png
new file mode 100644
index 000000000..837cbf3b8
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary_background.png
new file mode 100644
index 000000000..0bfcc0843
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary_background.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary_foreground.png
new file mode 100644
index 000000000..a720ae356
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary_monochrome.png b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary_monochrome.png
new file mode 100644
index 000000000..a720ae356
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_mask_launcher_dictionary_monochrome.png differ
diff --git a/app/src/main/res/navigation/app_nav_graph.xml b/app/src/main/res/navigation/app_nav_graph.xml
new file mode 100644
index 000000000..2f884f340
--- /dev/null
+++ b/app/src/main/res/navigation/app_nav_graph.xml
@@ -0,0 +1,335 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index f5ed9a2ba..033d2fe68 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -1,49 +1,51 @@
-
- أرشِف وسائطك تعرف على المزيد
-
- تسجيل الدخول
البدء الأول
- التحميلات
-
-
-
الإعدادات
-
-
موقع Internet Archive
+ تحميل وسائطك إلى حساب عمومي مجاني على موقع Internet Archive.
+ خدمة DWeb
+ اتصل بـ\nالانترنت اللامركزي
+ الخوادم
+ إضافة حساب آخر
+ إعادة تسمية المجلد
+ تحديد الوسائط
+ إزالة المجلد
+ أرشفة المجلد
+ إزالة المجلد من التطبيق
+ لا يمكن ترك اسم المجلد فارغا
+ تمت إعادة تسمية المجلد
+ لم يتم العثور على المجلد
+ تمت إزالة المجلد
+ إضافة الوسائط من
+ تعطيل رمز المرور
+ هل أنت متيقِّن من تعطيل رمز المرور؟
+ التوجيه البصلي قيد التطوير
+ هذه الميزة قيد التطوير حاليا. في الوقت الحالي، يمكنك استخدام Orbot أو أي شبكة افتراضية خاصة من اختيارك لتعزيز خصوصيتك وأمنك.
+ تنزيل Orbot
-
-
-
-
-
- صورة رئيسية
إلغاء
موافق
- تأكيد البريد الإلكتروني
- يرجى تأكيد عنوان بريدك الإلكتروني والعودة للدخول إلى حسابك في Internet Archive.
-
-
+ تأكيد
+ إزالة
+ حاول مجددا
+ الترخيص
+ التنازل عن جميع القيود والمتطلبات ونَسْب الحقوق (CC0).
السماح لأي شخص بالتعديل والمشاركة ؟
- اطلب من الأفراد المشاركة مثلما سبق لك أيضا المشاركة ؟
+ الطلب من الأفراد المشاركة مثلما سبق لك أيضا المشاركة ؟
السماح بالاستخدام التجاري ؟
أزِل الوسائط
+ سوف يتم حذف هذا الوسيط من Save.\nسيظل في الخادم وفي تطبيق صورك.
اضغط لإضافة علامة محتوى مميز
- نعم
- لا
+ نعم
+ لا
إلغاء
@@ -51,210 +53,229 @@
تشغيل التوجيه البصلي
النقل عبر شبكة « تور » فقط
- الرجاء تثبيت « أوربوت » لتفعيله
- حظر لقطات الشاشة
- منع أخذ لقطات الشاشة وتسجيل الشاشة لهذا التطبيق.
-
- التقديم
- السمة
- تغيير سِمة Save إلى نهارية أو ليلية
- يتحكم فيها النظام
- نهاري
- ليلي
+
تفعيل ProofMode
- التقاط البيانات الوصفية الإضافية وتوثيق جميع الوسائط.
+ تعرَّف على المزيد هنا]]>
+
+
+*لا Save ولا OpenArchive يمكنهما الوصول إلى بيانات موقعك ولا حتى تخزينها، سيكون ذلك متاحا فقط لمن له حق الوصول إلى ملفات الخادم.
+ ]]>\n
هوية ProofMode
مشاركة مفتاح ProofMode العمومي
+
+ إذن معرفة الموقع مطلوبة
+
- اسم المستخدم
- كلمة السر
+ اسم المستخدم
+ عنوان البريد الإلكتروني
+ كلمة السر
+
+ \@string/prompt_email
+ أدخل عنوان البريد الإلكتروني أو اسم المستخدم
+ \@string/prompt_password
+ أدخل كلمة السر
+
الولوج
اسم المستخدم أو كلمة السر غير صحيحة
- هذا الحقل مطلوب
+ عنوان البريد الإلكتروني أو كلمة السر غير صحيحة
+ هذا الحقل ضروري
- يجري استيراد الوسائط…
+ يجري استيراد الوسائط…
مرحباً
سياسة الخصوصية
- عن %s
+ عن %s
إضافة مجلد
تصفح المجلدات الموجودة
تصفح الموجودة
مدير التحميل
- الملفات المُحمَّلة
- يجري التحميل…
- يجري التحميل… (%d متبقية)
+ التحميلات
+ تعديل قائمة الانتظار
+ التحميل متوقف مؤقتا
+ يجري التحميل…
+ يجري التحميل… (%d متبقية)
تظهر هذه الإشعارات أثناء قيام هذا التطبيق بتحميل الوسائط.
- تحميل
+ رفع
+
+
+ إن التطبيق مُقفَل بسبب عدة محاولات فاشلة. يرجى المحاولة مرة أخرى لاحقا.
+ لم يتم تعيين رمز للمرور
+ رمز المرور غير صحيح، بقيت لك %1$d محاولات.
+ عدد المحاولات الفاشلة كثير. التطبيق مُقفَل.
+
+ قفل التطبيق برمز مرور
+ أدخل رمز مرورك
+ رمزا المرور غير متطابقَين، يرجى المحاولة مرة أخرى.
+
+ أكِّد رمز المرور
+ حدد رمز المرور
+ تيقَّن من تذكُّر هذا الرمز. إذا نسيته، فسيحتاج الأمر إلى إعادة تعيين التطبيق، وسيتم مسح جميع البيانات داخل التطبيق.
+
- المس الزر أدناه لإضافة الوسائط.
+ المس الزر أدناه لإضافة الوسائط
+ المس الزر أدناه لإضافة مجلد
+ المس الزر أدناه لإضافة خادم
لبدء الاستخدام، اتصل بخادم لتخزين وسائطك.
- في القائمة الجانبية، يمكنك إضافة خادم آخر والاتصال بخوادم متعددة
- إرسال إلى خادم webdav
- تحميل إلى IA
- يتصل Save فقط بالخوادم المتوافقة مع WebDAV، مثل Nextcloud و ownCloud.
+ يمكنك إضافة خوادم خصوصية متعددة\nوحساب خادم IA أو حساب DWEB في أي وقت.
+ يمكنك إضافة خوادم خصوصية متعددة\nوحساب خادم IA واحد في أي وقت.
+ اتصل بخادم\nWebDAV آمن
+ اتصل بخادم\nعمومي مجاني
+ اتصل بالخوادم المتوافقة مع WebDAV، على سبيل المثال Nextcloud و ownCloud.
التالي
تم
محتوى مميز
- حدِّد مكان تخزين وسائطك.
+ اختر مجلدًا جديدا أو مجلدا موجودا لحفظ وسائطك فيه.
لقد اتصلت بخادم خاص بنجاح !
- لقد اتصلت بـ Dropbox بنجاح !
- لقد اتصلت بـ Internet Archive بنجاح !
+ لقد اتصلت بـ Internet Archive بنجاح !
إنشاء مجلد جديد
- تحرير
+ يرجى إطلاق اسم على مجلدك
+ سيتم إنشاء هذا المجلد على خادمك ثم سيُضاف إلى Save.
+ أدخل اسم المجلد
+ تعديل
تم
- أزِل من التطبيق
- هل أنت متأكد من أنك تريد إزالة مشروعك ؟
- إزالة
- حوِّل المشروع إلى الأرشيف
- أخرِج المشروع من الأرشيف
- يرجى تسجيل الدخول باستخدام اعتمادات صالحة لـ Internet Archive.
- مفتاح الوصول
- المفتاح السري
- يحتاج Save إلى مفاتيح واجهة برمجة التطبيقات الخاصة بحسابك في Internet Archive حتى يتمكن من تحميل الوسائط.
- اكتسب المفاتيح
+ إزالة من التطبيق
+ هل أنت متيقِّن من أنك تريد إزالة مشروعك ؟
+ أرشفة المشروع
+ إلغاء أرشفة المشروع
مجلد جديد
يرجى عدم كتابة أحرف خاصة في الاسم.
- هل أنت متأكد من أنك تريد إزالة هذا الخادم من التطبيق ؟
+ هل أنت متيقِّن من أنك تريد إزالة هذا الخادم من التطبيق ؟
اسم المجلد موجود بالفعل.
- Dropbox
البيانات الوصفية
- هل أنت متأكد من أنك تريد إزالة هذه الوسائط ؟
+ تعذّر العثور على خادم باسم المضيف المحدد.
+ أزِل الوسائط
+ أشِّر على المحتوى المميز
+ عندما تضع العلامة على عنصر ما، يتم توجيهها نحو مجلد فرعي داخل مجلد المشروع المختار على الخادم الخاص.
+ تعيين ترخيص المشاع الإبداعي للمجلدات الموجودة على هذا الخادم.
+ تعيين نفس ترخيص المشاع الإبداعي لـ** كافة ** المجلدات الموجودة على هذا الخادم.
+ لديك بالفعل خادم بهذه الاعتمادات
+
- علامة على محتوى مميز
- عندما تضع العلامة على عنصر ما، يتم وَسْمها على مجلد فرعي داخل مجلد المشروع المختار على الخادم الخاص.
+
+ تحديد خادم
+
+ الخادم الخاص
- تعيين نفس ترخيص المشاع الإبداعي لجميع المجلدات الموجودة على هذا الخادم.
- تعيين تراخيص المشاع الإبداعي فريدة ** لكل مجلد فردي ** على هذا الخادم.
+ هناك تغييرات غير محفوظة !
+ أ تريد الحفظ
+ حفظ
+ تجاهل
- تثبيت
- تسجيل الدخول…
- لديك بالفعل خادم بهذه الاعتمادات
- غير قادر على الاتصال بـ « أوربوت » أو « تور ».
- غير قادر على الاتصال بـ « أوربوت » أو « تور » : انتهت المهلة
- غير قادر على الاتصال بـ « أوربوت » أو « تور » : غير صالح
- تم اكتشاف اتصال انترنت غير آمن
- الإصدار %s
+
+ أطلق اسما على خادمك واختر الترخيص
+ اختر ترخيصا
+
+ اكتمل الإعداد
+
+
+ تعديل قائمة الانتظار
+ التحميل متوقف مؤقتا
- اسم الخادم (اختياري)
- مُعرِّف Dropbox :
- إضافة حساب آخر
- أَمِّن مفتاح ProofMode بالمقاييس الحيوية أو رمز مرور الجهاز
- أَمِّن مفتاح ProofMode بالمقاييس الحيوية
- أَمِّن مفتاح ProofMode برمز مرور الجهاز
- سيؤدي تغيير هذا إلى إنشاء مفتاح جديد ! إذا قمت بتصدير هذا المفتاح وتوقيعه من قبل، فستحتاج إلى القيام بذلك مرة أخرى باستخدام المفتاح الجديد.
- فك قفل مفتاح ProofMode
+ اسم الخادم (اختياري)
+ إضافة خادم جديد
+ فك قفل مفتاح ProofMode
المجلدات
إنشاء اسم المجلد
اسم المجلد
إنشاء
إضافة
لا (مزيد من) المجلدات
- الخادم الخاص
- تحميل إلى Dropbox
- قم بتسجيل الدخول أو إنشاء حساب مع Dropbox
- استوثِق
+
الرجوع
وسائطي
- إذا لم تكن لديك مفاتيح موجودة، تعرف كيفية الحصول على المفاتيح.
معلومات الخادم
- إدخال عنوان الصفحة
+ إدخال عنوان الصفحة
الحساب
عام
-
- حمِّل الوسائط عندما تكون متصلا بالشبكة اللاسلكية فقط
+ حمِّل الوسائط عندما تكون متصلا بالشبكة اللاسلكية (Wi-Fi) فقط
الأمن
ProofMode
لا يوجد تطبيق يمكنه التعامل مع هذا الطلب. الرجاء تثبيت متصفح الانترنت.
-
- قم بتحميل الوسائط المتحقق منها إلى الخادم الذي اخترته. أضف ترخيص المشاع الإبداعي للتعبير
- عن رغباتك للاستخدام المستقبلي.
-
-
- حافظ على أمان وسائطك وأبْقِها منظمة على المدى الطويل وقم بإنشاء مجلدات المشروع في التطبيق والتي
- تتوافق مع أرشفة وسائطك الشخصية أو وسائط منظمتك.
-
-
- استوثِق من وسائطك باستخدام تلبيد التحقق من التعمية SHA-256 وProofMode
- الاختياري. أضف البيانات الوصفية المهمة مثل الملاحظات والأشخاص والموقع مع كل عملية تحميل.
-
-
-
- قم بتثبيت « أوربوت »لتفعيل أمن الشبكة المتقدم.]]>
-
+ أرسل وسائطك بشكل آمن إلى خوادم خاصة، ثم اقفل التطبيق باستخدام رمز.
+
اخترترخيص المشاع الإبداعي]]>
+
+ ProofMode.]]>
+ Saveدائما عبر TLS (بروتوكول طبقة النقل الآمن) لحماية وسائطك أثناء عبورها.
لتعزيز الأمن بشكل أفضل، قم بتفعيل تور لمنع اعتراض وسائطك من هاتفك إلى الخادم.]]>
التالي
تم
الحفظ الآمن لوسائط المحمول
بدء الاستخدام
- يقوم ProofMode بجمع البيانات الوصفية من الأبراج الخلوية المحلية للمساعدة في التحقق من الوسائط. يتطلب أندرويد إذنًا لتفعيل هذا الإعداد. سيستخدم Save هذا الإعداد فقط لالتقاط البيانات و ** لن ** يتم الوصول إلى هاتفك لإجراء أو إدارة المكالمات.
المجلدات المؤرشفة
عرض المجلدات المؤرشفة
تعرف على المزيد حول المشاع الإبداعي.
إنعاش
- لبدء الاستخدام، يُرجى إنشاء مجلد
- قبل إضافة الوسائط، قم بإنشاء مجلد جديد أولا.
هو فيديو
- استخدام التحميل بالتقطيع (Nextcloud فقط)
- يقوم « التقطيع » بتحميل الوسائط عبر أجزاء حتى لا تضطر إلى إعادة تشغيل التحميل إذا انقطع اتصالك.
- معاينة الوسائط
+ معاينة التحميل
إضافة المزيد
حدد الكل
- فهمت
+ إزالة الاختيار عن الكل
+ مفهوم
لا تُظهِر لي هذا مجددا
التعديل المتعدد
- اضغط مع الإبقاء لتحديد وتعديل وسائط متعددة
- بمجرد التحميل، لن تتمكن من تعديل الوسائط
+ إضافة وسائط
+ المس علامة + للاختيار من مَعْرِض الصور أو اضغط مع الاستمرار في ذلك لإضافة الوسائط من تطبيقات أخرى.
+ اضغط مع الإبقاء لتحديد وتعديل عناصر وسائط متعددة.
+ بمجرد التحميل، لن تتمكن من تعديل الوسائط.
تعديل معلومات الوسائط
- %1$d من %2$d
- إضافة موقع (اختياري)
- إضافة ملاحظات (اختياري)
+ إضافة موقع (اختياري)
+ إضافة ملاحظات (اختياري)
تخطي
- إضافة صور أو فيديو
- إضافة ملفات
- كيفية الحصول على مفاتيح في 3 مراحل سهلة
- المرحلة 1 : قم بتسجيل الدخول إلى Internet Archive. إذا لم يكن لديك حساب، قم بإنشاءه.
- المرحلة 2 : إذا كنت ستقوم بإنشاء حساب جديد، فقم بتأكيد بريدك الإلكتروني.
- المرحلة 3 : قم بتوليد مفاتيح واجهة برمجة التطبيقات الخاصة بك عن طريق تحديد المربع وسيتم تحميلها تلقائيا في التطبيق !
التحميل غير ناجح
- إعادة المحاولة
+ لقد تعذر التحميل بسبب خطأ في الجلسة، يرجى المحاولة مرة أخرى أو الاتصال بالدعم.
+
تحرير الوسائط
- التحميل متوقف مؤقتا
- إضافة الوسائط باستخدام :
+
+ مُصوِّرة
معرض الصور
الملفات
- التحقق من سلامة
- ساعد في تحسين التطبيق من خلال إجراء التحقق من السلامة عند فشل عمليات التحميل.
- يساعدنا التحقق من السلامة في فهم أسباب فشل عمليات التحميل.
- ساهم ببياناتك لتكون جزءًا من الحل.
- بعد الحصول على إذنك، سنجري تحقُّقا في كل مرة تواجه فيها خطأ أثناء التحميل.
- يلتقط التحقق من السلامة
- – وصف الخطأ
- – حجم ونوع الوسائط
- – عدد مرات إعادة المحاولة
- – نوع الشبكة
- – التوطين
- السماح بالتحقق من السلامة؟
- من خلال السماح بالتحقق من السلامة، فأنت تعطي للتطبيق الإذن بإرسال بيانات التحقق من السلامة بشكل آمن إلى فريق عمل %s.
- لا شكراً
- تراجَع
-
-
+
+
+ عنوان البريد الإلكتروني
+ اسم الشاشة
+ إنشاء واحد
+ لا تملك حسابا ؟
+
+ آمن
+ الأرشفة
+ البنود والخصوصية
+ خوادم الوسائط
+ المجلدات المؤرشفة
+ إدارة خوادمك
+ إدارة مجلداتك المؤرشفة
+ اطلع على شروط الاستخدام وسياسة الخصوصية
+
+
+ نجاح !
+ تحذير !
+ معلومة !
+ مفهوم
+
+
+ لقد أضفت مجلدا بنجاح.
+ لقد غيرت إعدادات خادمك بنجاح.
+ تمديد
+ لم يُضَف أي خادم بعد.
+ لم يتم العثور على أي مجلد مُؤرشَف.
+ الانترنت غير متاح
+ Save من إصدار OpenArchive
+ اعرف أكثر
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 0b03bf646..00cb2153e 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -1,9 +1,6 @@
- Anmelden
- Uploads
-
Einstellungen
@@ -36,12 +33,7 @@
Fehler
- Bildschirmfotos blockieren
- Design
- Hell
- Dunkel
Aktiviere ProofMode
- Erfasse zusätzliche Metadaten und beglaubige alle Medien.
Teile öffentlichen ProofMode Schlüssel
@@ -67,7 +59,6 @@
Upload
Um zu beginnen, verknüpfe einen Server um Deine Medien zu speichern.
- Im Seitenmenü kannst Du weitere Server hinzufügen und Dich mit mehreren Servern verbinden.
Zum Internet Archive hochladen
Weiter
Fertig
@@ -77,35 +68,25 @@
Bearbeiten
Fertig
Aus App entfernen
- Entfernen
+ Entfernen
Projekt archivieren
Projekt entarchivieren
- Access Key
- Secret Key
- Schlüssel holen
Neues Verzeichnis
- Dropbox
Metadaten
Besonderen Inhalt markieren
Wenn Du ein Element markierst, wird es in einem Unterordner innerhalb des ausgewählten Projektordners auf dem privaten Server abgelegt.
Die gleiche Creative–Commons–Lizenz für ALLE Ordner auf diesem Server verwenden.
- Eigene Creative–Commons–Lizenz für JEDEN EINZELNEN Ordner auf diesem Server verwenden.
- Installieren
Servername (optional)
Server hinzufügen
- Sichere den ProofMode-Schlüssel mit dem Gerätepasswort ab
- Wenn Du das änderst, wird ein neuer Schlüssel generiert! Wenn Du den Vorherigen exportiert und signiert hast, wirst Du das mit dem Neuen wiederholen müssen.
Ordner
Ordnername
Erstellen
Hinzufügen
Privater Server
- Auf Dropbox hochladen
- Authentifizieren
Zurück
Meine Medien
Server Info
@@ -121,35 +102,20 @@
Archivierte Ordner
Archivierte Ordner ansehen
Aktualisieren
- Ordner erstellen, um anzufangen
- Bevor Du Medien hinzufügen kannst, erstelle erst einen neuen Ordner.
- Upload stückeln (nur Nextcloud)
- \"Stückeln\" lädt Medien in Stücken hoch, damit nicht der komplette Upload von vorne begonnen werden muß, wenn die Verbindung unterbrochen wurde.
Alles auswählen
- Verstanden
+ Verstanden
Mehrere editieren
Gedrückt halten, um mehrere Elemente auszuwählen.
Editieren
Ort hinzufügen (optional)
Notizen hinzufügen (optional)
Überspringen
- Dateien hinzufügen
- Schritt 2: Wenn Du einen neuen Zugang erstellst, verifiziere Deine E-Mail-Adresse.
- Erneut versuchen
+ Erneut versuchen
Medien bearbeiten
Hochladen pausiert
Fotogallerie
Dateien
- Gesundheitscheck
- Hilf mit, die App durch Gesundheitschecks besser zu machen, wenn das Hochladen fehlschlägt.
- Gesundheitschecks helfen uns zu verstehen, warum das Hochladen manchmal fehlschlägt.
- Spende Deine Daten um Teil der Lösung zu werden.
- Mit Deiner Erlaubnis lassen wir jedes Mal einen Check laufen, wenn ein Fehler beim Hochladen auftritt.
- Gesundheitschecks erfassen
- Erlaube Gesundheitschecks?
- Nein danke
- Zurück
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 9ce15151e..0bb44d2cc 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1,48 +1,50 @@
-
- Archiva tus medios aprende más
-
- Iniciar sesión
- Primer inicio
- Subir
-
-
-
- Ajustes
-
-
+ Primer Inicio
+ Configuración
Internet Archive
+ Sube tus medios a una cuenta gratuita pública en Internet Archive.
+ Servicio DWeb
+ Conectar a la \nweb descentralizada
+ Servidores
+ Agregar Otra Cuenta
+ Renombrar Carpeta
+ Seleccionar Multimedia
+ Eliminar Carpeta
+ Archivar carpeta
+ Eliminar carpeta de la app
+ Nombre de carpeta no puede estar vacío
+ Carpeta renombrada
+ Carpeta no encontrada
+ Carpeta eliminada
+ Añadir medios desde
+ Deshabilitar Código de Acceso
+ ¿Tienes seguridad de querer deshabilitar el código de acceso\?
+ Enrutamiento Onion en desarrollo
+ Esta funcionalidad actualmente está en desarrollo. Por ahora, puedes utilizar Orbot o cualquier VPN de tu elección para mejorar tu privacidad y seguridad.
+ Descargar Orbot
-
-
-
-
-
- Imagen principal
Cancelar
- OK
- Confirmación de correo electrónico
- Confirma su dirección de correo electrónico y vuelva a conectar su cuenta de Internet Archive.
-
-
+ Aceptar
+ Confirmar
+ Eliminar
+ Reintentar
- ¿Permitir que cualquiera cambie y comparta?
- ¿Solicitarles que compartan del mismo modo que tú?
- ¿Permitir uso comercial?
+ Licencia
+ Renunciar a todas las restricciones, requisitos y atribución (CC0).
+ ¿Permitir que cualquiera cambie y comparta\?
+ ¿Solicitarles que compartan como hiciste tú\?
+ ¿Permitir uso comercial\?
Eliminar Medios
+ Este medio se eliminará de Save.\nPermanecerá en el servidor y en tu app de Fotos.
Pulsa para marcar como contenido significativo
- Sí
+ Si
No
Cancelar
@@ -51,209 +53,229 @@
Habilita el Enrutamiento Onion
Transferencia solo a través de la Red Tor
- Instala Orbot para habilitar
- Bloquear capturas de pantalla
- Evitar capturas de pantalla y grabaciones de pantalla de esta app.
-
- Presentación
- Tema
- Cambia el tema de \"Guardar\" a claro/oscuro
- Sistema Controlado
- Claro
- Oscuro
+
Habilitar ProofMode
- Capture metadatos adicionales y notarizar todos los medios
+ Aprende más aquí.]]>
+
+
+ *NiSaveni OperArchive podrán acceder o almacenar estos datos de ubicación, solo serán accesibles para quienes tengan acceso a los archivos del servidor.
+ ]]>\n
- Identidad de ProofMode
- Compartir Cave Pública de ProofMode
+ Identidad ProofMode
+ Compartir Clave Pública ProofMode
+
+ Permisos de ubicación requeridos
+
- Nombre de usuaria(o)
- Contraseña
+ Nombre de Usuaria(o)
+ Email
+ Contraseña
+
+ \@string/prompt_email
+ Introduce un correo electrónico o nombre de usuaria(o)
+ \@string/prompt_password
+ Introduce una contraseña
+
Iniciar sesión
Nombre de usuaria(o) o contraseña incorrectos
+ Correo electrónico o contraseña inválidos
Este campo es obligatorio
- Importar medios…
+ Importando medios…
- Bienvenida(o)
+ ¡Bienvenida(o)!
Política de Privacidad
Sobre %s
- Agregar una Carpeta
- Explorar Carpetas Existentes
- Navegar por los Existentes
+ Añadir una carpeta
+ Examinar Carpetas Existentes
+ Examinar Existentes
Gestor de Cargas
Cargas
- Cargando…
- Cargando… (%d restantes)
- Estas notificaciones se muestran cuando la app está realizando una carga de medios.
+ Editar Cola
+ Subida está pausada
+ Subiendo…
+ Subiendo… (%d restantes)
+ Estas notificaciones aparecen mientras la app está realizando una carga de medios.
+
+ Subida
+
+
+ La app está bloqueada debido a múltiples intentos fallidos. Por favor intenta nuevamente más tarde.
+ Código de acceso no establecido
+ Código de acceso incorrecto.%1$d intentos restantes.
+ Demasiados intentos fallidos. La app está bloqueada.
+
+ Bloquear la app con código de acceso.
+ Introduce Tu Código de Acceso
+ Los códigos de acceso no coinciden. Vuelve a intentar.
+
+ Confirmar Código de Acceso
+ Establecer Código de Acceso
+ Asegúrate de recordar este pin. Si lo olvidas, tendrás que restablecer la app, y toda la información dentro de la app se eliminará.
- Cargar
- Presiona el botón de abajo para agregar medios.
- Para comenzar, conéctate a un servidor para almacenar tus medios.
- En el menú del costado, puedes agregar otro espacio de almacenamiento y conectar a múltiples servidores
- Envía a un servidor WebDAV
- Cargar a la IA
- Guardar solo se conecta con servidores compatibles con WebDAV, por ejemplo, Nextcloud y ownCloud.
+ Toca el botón de abajo para agregar medios
+ Toca el botón de abajo para agregar una carpeta
+ Toca el botón de abajo para agregar un servidor
+ Para empezar, conéctate a un servidor para almacenar tus archivos multimedia.
+ Puedes agregar varios servidores privados y\nuna cuenta de servidor IA o DWeb en cualquier momento.
+ Puedes agregar varios servidores privados y\nuna cuenta de IA en cualquier momento.
+ Conectar a un \nservidor WebDAV seguro
+ Conéctate a un \nservidor público gratuito
+ Conectar con servidores compatibles con WebDAV, p. ej. Nextcloud y ownCloud.
Seguir
Listo
Contenido Significativo
- Selecciona dónde almacenar tus medios.
+ Elige una carpeta nueva o existente para guardar tus medios.
¡Te has conectado exitosamente a un servidor privado!
- ¡Te has conectado exitosamente a Dropbox!
¡Te has conectado exitosamente a Internet Archive!
Crear una Nueva Carpeta
+ Por favor nombra tu carpeta
+ Esta carpeta será creada en tu servidor y se añadirá a Save.
+ Introduce el nombre de la carpeta
Editar
Listo
- Eliminar de la App
- ¿Estás segura de que quieres eliminar tu proyecto?
- Eliminar
+ Eliminar de la app
+ ¿Estás segura de que quieres eliminar tu proyecto\?
Archivar Proyecto
Desarchivar Proyecto
- Por favor inicia sesión con credenciales válidas de Internet Archive.
- Clave de Acceso
- Clave Secreta
- Guardar necesita las claves API de tu cuenta de Internet Archive para subir medios.
- Adquirir Claves
- Nuevo Directorio
- Por favor no incluya caracteres especiales en este nombre
- ¿Estás segura(o) de querer eliminar este servidor de la app?
- El nombre de la carpeta ya existe.
- Dropbox
+ Carpeta Nueva
+ Por favor no incluyas caracteres especiales en este nombre.
+ ¿Estás segura(o) de querer eliminar este servidor de la app\?
+ Nombre de carpeta ya existe.
Metadatos
- ¿Estás segura(o) de que deseas eliminar este Medio?
-
+ No se pudo encontrar un servidor con el nombre de host especificado.
+ Eliminar Medios
Marcar Contenido Significativo
Cuando marcas un elemento, se envía a una subcarpeta dentro de la carpeta del proyecto elegido en el servidor privado.
+ Establecer licencias de creative commons para carpetas en este servidor.
+ Permitir el uso de licencia Creative Commons para TODAS las carpetas de este servidor.
+ Ya tienes un servidor con estas credenciales
- Permitir el uso de licencia Creative Commons para todas las carpetas de este servidor
- Establece licencias Creative Commons únicas para CADA CARPETA INDIVIDUAL en este servidor.
- Instalar
- Iniciando sesión…
- Ya tienes un servidor con estas credenciales
- No se puede conectar a Orbot/Tor.
- No se puede conectar a Orbot/Tor: TIEMPO DE ESPERA
- No se puede conectar a Orbot/Tor: INVÁLIDA
- Se detectó una conexión a Internet no segura
- Versión %s
+
+ Selecciona un Servidor
+
+ Servidor Privado
+
+ ¡Cambios sin guardar!
+ ¿Quieres guardar\?
+ Guardar
+ Descartar
+
+
+ Nombra tu servidor y elige una licencia
+ Elige una licencia
+
+ Configuración Completada
+
+
+ Editar Cola
+ Subida está pausada
- Nombre del Servidor (Opcional)
- ID de Dropbox:
- Agregar Otra Cuenta
- Clave de ProofMode segura con datos Biométricos o Código de Acceso del dispositivo
- Clave de ProofMode segura con datos Biométricos
- Clave de ProofMode segura con Código de Acceso del Dispositivo
- ¡Cambiar esto creará una nueva clave! Si exportaste y firmaste esta antes, vas a necesitar hacerlo nuevamente con la nueva.
+ Nombre del Servidor (Opcional)
+ Añadir nuevo servidor
Desbloquear Clave de ProofMode
Carpetas
- Crear Nombre para Carpeta
- Nombre de la Carpeta
+ Crear Nombre de Carpeta
+ Nombre de Carpeta
Crear
Añadir
No (más) carpetas
- Servidor Privado
- Subir a Dropbox
- Inicia sesión o crear una cuenta con Dropbox
- Autenticar
+
Volver
Mis Medios
- Si no tienes claves existentes, aprende cómo adquirir claves.
- Info del servidor
- Ingresar URL
+ Info de servidor
+ Introducir URL
Cuenta
General
- Conectividad y Datos
- Solo subir medios cuando estés conectada a Wi-Fi
+ Solo subir medios cuando estés conectada(o) a Wi-Fi
Seguridad
ProofMode
Ninguna aplicación puede manejar esta solicitud. Por favor instala un navegador web.
-
-Suba medios verificados a su servidor elegido. Añada una licencia de Creative Commons para comunicarse
-sus intenciones para uso futuro.
-
- Mantén tus medios seguros y organizados a largo plazo y crea carpetas de proyectos en la aplicación que
- mapean tu archivo de medios personal u organizacional.
-
-
- Autentica tus medios con una verificación criptográfica de hash sha256 y opcionalmente
- ProofMode. Agrega metadatos críticos como notas, personas y ubicación con cada carga.
-
-
-
- Instala Orbot habilitar una seguridad de red avanzada.]]>
-
+ Envía tus medios de manera segura a servidores privados y bloquea la app con un pin.
+
EligeLicencias Creative Commons.]]>
+
+ ProofMode.]]>
+ Save siempre sube sobre TLS (Seguridad de la Capa de Transporte) para proteger tus medios en tránsito.
Para mejorar aún más la seguridad, habilitaTor para prevenir la intercepción de tus medios desde tu teléfono hacia el servidor.]]>
Seguir
Listo
Preservación Segura de Medios Móviles
Comenzar
- ProofMode recolecta metadatos de las torres de celular locales para ayudar a verificar medios. Android requiere permiso para habilitar esta configuración. Guardar solo usará esta configuración para capturar datos y NO accederá a tu teléfono para hacer/gestionar llamadas.
Carpetas Archivadas
Ver Carpetas Archivadas
Más información sobre Creative Commons.
- Actualizar
- Para comenzar, crea una carpeta.
- Antes de agregar medios, primero crea una nueva carpeta.
- es vídeo
- Usar Fragmentación de Subida (solo Nextcloud)
- La “fragmentación” sube los medios en partes, así no tendrás que reiniciar tu subida si tu conexión se interrumpe.
- Previsualizar Medios
+ Refrescar
+ es video
+ Previsualizar Subida
Agregar Más
Seleccionar Todos
- Entendido
- No mostrar esto de nuevo
+ Deseleccionar Todo
+ Entendido
+ No mostrarme esto de nuevo
Editar Múltiples
- Presionar y sostener para seleccionar medios y editar múltiples medios.
- Una vez subidos, no podrás editar los medios
+ Añadir Medios
+ Tocar + para elegir de la galería de imágenes o presiona y mantén para añadir medios desde otras apps.
+ Mantén presionado para seleccionar y editar múltiples ítems de medios.
+ Una vez subidos, no podrás editar los medios.
Editar Info de Medios
- %1$d/%2$d
- Agregar una ubicación (opcional)
- Agregar notas (opcional)
+ Añade una ubicación (opcional)
+ Añade notas (opcional)
Omitir
- Agregar medios o videos
- Agregar Archivos
- Cómo Adquirir Llaves en 3 pasos fáciles
- Paso 1: inicia sesión en Internet Archive. Si no tienes una cuenta, crea una.
- Paso 2: Si estás creando una cuenta nueva, verifica tu correo electrónico.
- Paso 3: Obten tus claves API seleccionando la casilla y se cargará automáticamente en la app.
Carga Fallida
- Reintentar
+ No se pudo subir debido a error de sesión, por favor vuelve a intentar o contacta a soporte.
+
Editar Medios
- La carga está pausada
- Agrega medios usando:
+
+ Cámara
Galería de Fotos
Archivos
- Controles de Salud
- Ayuda a mejorar la app ejecutando controles de salud cuando fallan las cargas.
- Los controles de salud nos ayudan a comprender por qué fallan las cargas.
- Aporta tus datos para ser parte de la solución.
- Con tu permiso, realizaremos una verificación cada vez que encuentres un error durante la carga.
- Los controles de salud capturan
- -- Descripción del error
- -- Tipo y tamaño de medios
- -- Número de reintentos
- -- Tipo de red
- -- Ubicación
- ¿Permitir controles de salud?
- Al permitir los controles de salud, otorgas permiso a la app para enviar de forma segura datos de control de salud al %s del equipo.
- No gracias
- Deshacer
-
-
+
+
+ Email
+ Nombre en Pantalla
+ Crear uno
+ ¿Sin cuenta\?
+
+ Seguro
+ Archivo
+ Términos y Privacidad
+ Servidores Multimedia
+ Carpetas Archivadas
+ Administrar tus servidores
+ Administra tus carpetas archivadas
+ Lee nuestros Términos y Política de Privacidad
+
+
+ ¡Éxito!
+ ¡Advertencia!
+ ¡Info!
+ Entendido
+
+
+ Has añadido una carpeta exitosamente.
+ Has cambiado la configuración de tu servidor exitosamente.
+ Expandir
+ Aún no se han añadido servidores.
+ No se han encontrado carpetas.
+ No hay internet disponible
+ Save por OpenArchive
+ Más información
+
+
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 034caf379..f3adea042 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -1,42 +1,68 @@
- ورود
- اولین آغاز
- آپلود
+ اولین شروع
تنظیمات
- بایگانی اینترنت
+ Internet Archive
+ رسانهٔ خود را روی حساب عمومی رایگان در Internet Archive بارگذاری کنید.
+ سرویس DWeb
+ به وب غیرمتمرکز متصل شوید
+
+ سرورها
+ افزودن سرور
+
+ تغییر نام پوشه
+ انتخاب رسانه
+ حذف پوشه
+
+ آرشیوکردن پوشه
+ حذف پوشه از برنامه
+
+ نام پوشه نمیتواند خالی باشد
+ نام پوشه تغییر کرد
+ پوشه پیدا نشد
+ پوشه حذف شد
+ پوشه آرشیو شد
+
+
+ غیرفعالکردن گذرواژه
+ آیا مطمئنید که میخواهید گذرواژه را غیرفعال کنید؟
+
- عکس اصلی
لغو
- قابل قبول
+ قبول
+ تأیید
+
- به هر کسی اجازه بازنشر و استفاده داده شود؟
- بخواه که مانند خودت به اشتراک بگذارند؟
- اجازه استفاده تجاری بده؟
- حذف فایل رسانهای
+ پروانه
+ به هر کسی اجازهٔ تغییردادن و اشتراکگذاری رسانهٔ شما داده شود؟
+ از آنها خواسته شود تا محتوای شما را بدون جرحوتعدیل در آن بهاشتراک بگذارند؟
+ اجازهٔ استفادهٔ تجاری داده شود؟
- برای نشانهگذاری به عنوان محتوای مهم، روی پرچم بزنید.
+ حذف رسانه
+ این رسانه از Save حذف خواهد شد.\nاما روی سرور و گالری شما باقی خواهد ماند.
+
+ برای علامتگذاری بهعنوان محتوای مهم ضربه بزنید
بله
خیر
@@ -44,78 +70,234 @@
خطا
- جلوگیری از اسکرینشات گرفتن
- ارائه
- قالب
- روشن
- تاریک
+ مسیریابی Onion را فعال کنید
+ انتقال فقط از طریق شبکهٔ Tor
+
+
+ فعالسازی ProofMode
+
+
+
+
+
+
+
+ برای کمک به اینکه محل ضبط رسانههای خود را تصدیق کنید، ProofMode دادههایی را از دکلهای سلولی نزدیک جمعآوری میکند تا موقعیت مکانی شما را تأیید کند. سپس برای افزودن اعتبار و اطلاعات زمینهای، یک فایل فرادادهٔ جداگانه در کنار رسانهٔ شما قرار داده میشود. نه Save و نه OpenArchive قادر به ذخیرهسازی یا دستیابی به این دادههای مکانی نخواهند بود و تنها کسانی که به فایلهای سرور دسترسی دارند، قادر به مشاهدهٔ آن خواهند بود. Android برای جمعآوری این اطلاعات به دسترسی به موقعیت مکانی نیاز دارد.
+
+ هویت ProofMode
+ کلید عمومی ProofMode را اشتراکگذاری کنید
+
+
+
+ اجازهٔ دسترسی به موقعیت مکانی لازم است
+
- نام کاربری
- گذرواژه
+ نام کاربری
+ ایمیل
+ رمز عبور
+
+ \@string/prompt_email
+ ایمیل یا نام کاربری خود را وارد کنید
+ \@string/prompt_password
+ رمز عبور را وارد کنید
+
ورود
- نام کاربری یا گذرواژه نادرست است
- این فیلد ضروری می باشد
+ نام کاربری یا رمز عبور نادرست است
+ ایمیل یا رمز عبور نادرست است
+ این فیلد ضروری است
+
+ در حال واردکردن رسانهها…
+
+ خوش آمدید!
+
+ سیاست حریم خصوصی
+ دربارهٔ %s
+ افزودن پوشه
+ مرور پوشههای موجود
+ مرور موارد موجود
+ مدیر بارگذاری
+
+ بارگذاریها
+ ویرایش صف
+ بارگذاری متوقف شده است
+ در حال بارگذاری…
+ در حال بارگذاری… (%d باقیمانده)
+ این اعلانها زمانی نمایش داده میشوند که این برنامه در حال بارگذاری رسانهها باشد.
+
+ بارگذاری
+
+
+ برنامه به دلیل تلاشهای ناموفق متعدد قفل شده است. لطفا بعدا دوباره امتحان کنید.
+ گذرواژه تنظیم نشده است
+ گذرواژه اشتباه است. %1$d تلاش دیگر باقی مانده است.
+ تلاشهای ناموفق بسیاری رخ داد. برنامه قفلشده است.
- خوش آمدید
+ برنامه را با گذرواژه قفل کنید
+ گذرواژهٔ خود را وارد کنید
+ گذرواژهها با هم تطابق ندارند. دوباره امتحان کنید.
- حریم خصوصی
- آپلودها
- بارگذاری.......
- آپلود
+ تأیید گذرواژه
+ تنظیم گذرواژه
+ حتماً این پین را به خاطر بسپارید. اگر آن را فراموش کنید، باید برنامه را بازنشانی کنید و تمام دادهها پاک خواهند شد.
+
+
+
+ برای افزودن رسانه روی دکمهٔ زیر ضربه بزنید
+ برای افزودن پوشه روی دکمهٔ زیر ضربه بزنید
+ برای افزودن سرور روی دکمهٔ زیر ضربه بزنید
+ برای شروع، به یک سرور وصل شوید تا رسانههای خود را ذخیره کنید
+ شما میتوانید در هر زمانی چندین سرور خصوصی و\nیک حساب سرور IA یا DWeb اضافه کنید.
+ شما میتوانید در هر زمانی چندید سرور و\nیک حساب IA اضافه کنید.
+ اتصال به یک سرور امن WebDAV
+ اتصال به یک سرور عمومی رایگان یا یک سرور خصوصی پولی
+ به یک سرور سازگار با WebDAV مانند Nextcloud و ownCloud متصل شوید.
بعدی
انجام شد
محتوای مهم
+ انتخاب کنید رسانههای شما کجا ذخیره شوند
+ شما با موفقیت به یک سرور خصوصی وصل شدید!
+ شما با موفقیت به Internet Archive وصل شدید!
+ ایجاد پوشهٔ جدید
+ لطفاً ابتدا، پوشهٔ خود را نامگذاری کنید
+ این پوشه در سرور شما ایجاد و بهصورت خودکار به Save افزوده خواهد شد.
+ نام پوشه را وارد کنید
ویرایش
انجام شد
حذف از برنامه
- حذف
- بایگانی کردن پروژه
- خروج پروژه از بایگانی
- کلید دسترسی
- کلید خصوصی
- به دست آوردن کلیدها
- New Folder
- Dropbox
- فرا داده
-
- نشانهگذاری محتوای مهم
- هنگامی که یک مورد را نشانهگذاری میکنید، به یک زیرپوشه در پروژهی انتخابی در سرور خصوصی منتقل میشود.
-
- نصب
- در حال ورود…
- پوشه ها
- ایجاد کردن
+ آیا مطمئنید میخواهید پروژهیتان را حذف کنید؟
+ حذف
+ آرشیوکردن پروژه
+ خارجکردن پروژه از آرشیو
+ پوشهٔ جدید
+ لطفاً در نام از نویسههای خاص استفاده نکنید.
+ آیا مطمئنید که میخواهید این سرور را از برنامه پاک کنید؟
+ نام پوشه تکراری است.
+ فراداده
+
+ حذف رسانه
+
+ علامتگذاری محتوای مهم
+ هنگامی که یک مورد را علامتگذاری میکنید، به یک زیرپوشه در پروژهٔ انتخابی در سرور خصوصی منتقل میشود.
+
+ برای پوشههای این سرور پروانههای Creative Commons تنظیم کنید.
+ همان مجوز Creative Commons را برای تمام پوشههای این سرور تنظیم کنید.
+
+ شما از قبل یک سرور با این اطلاعات کاربری دارید
+
+
+ یک سرور انتخاب کنید
+
+ سرور خصوصی
+
+ تغییرات ذخیره نشدهاند!
+ می خواهید ذخیر کنید
+ ذخیرهسازی
+ دور انداختن
+
+
+ سرور خود را نامگذاری کرده و یک پروانه انتخاب کنید
+
+
+ نصب کامل شد
+
+
+ ویرایش صف
+ بارگذاری متوقف شده است
+
+
+ نام سرور (اختیاری)
+ افزودن سرور جدید
+ بازگشایی کلید ProofMode
+ پوشهها
+ ایجاد نام پوشه
+ نام پوشه
+ ایجاد
افزودن
- بارگذاری در Dropbox
- تصدیق کردن
- برگشت
+ پوشه (بیشتری) وجود ندارد
+
+ بازگشت
+ رسانههای من
+ اطلاعات سرور
+ نشانی URL را وارد کنید
حساب کاربری
- کلی
+ عمومی
فایلهای رسانهای را تنها زمانی که به Wi-Fi متصل هستید، بارگذاری کنید.
امنیت
+ ProofMode
+ هیچ برنامه ای قادر به انجام این درخواست نیست. لطفا یک مرورگر وب نصب کنید.
+
+ رسانههای خود را بهصورت امن به سرورهای خصوصی ارسال کنید و برنامه را با یک پین قفل کنید.
+
+ \n\n
بعدی
انجام شد
- حفظ ایمن رسانههای موبایل
+ نگهداری امن رسانههای تلفن همراه
شروع کنید
+ پوشههای آرشیوشده
+ مشاهدهٔ پوشههای آرشیوشده
+ در مورد Creative Commons بیشتر یاد بگیرید.
تازهسازی
+ ویدئو است
+ پیشنمایش بارگذاری
+ افزودن بیشتر
انتخاب همه
- متوجه شدم
- از قلم بینداز
- تلاش دوباره
+ متوجه شدم
+ دیگر این را نمایش نده
+ ویرایش گروهی
+ افزودن رسانه
+ برای انتخاب تصویر از گالری روی + ضربه بزنید و یا برای افزودن تصویر از برنامههای دیگر روی + ضربه بزنید و نگه دارید.
+ برای انتخاب و ویرایش چندین رسانه، ضربه بزنید و نگه دارید.
+ وقتی بارگذاری انجام شد، دیگر قادر به ویرایش رسانه نخواهید بود.
+ ویرایش اطلاعات رسانه
+ افزودن موقعیت مکانی (اختیاری)
+ افزودن یادداشت (اختیاری)
+ رد کردن
+
+ بارگذاری ناموفق بود
+ به دلیل خطا در نشست ارتباطی، بارگذاری امکانپذیر نیست. لطفاً دوباره امتحان کنید یا با پشتیبانی تماس بگیرید.
+ امتحان مجدد
+
+ ویرایش رسانه
- تدوین رسانه
+ دوربین
+ گالری عکس
فايلها
- بررسیهای سلامت
- با انجام بررسیهای سلامت در صورت عدم موفقیت در بارگذاریها، به بهبود برنامه کمک کنید.
- بررسیهای سلامت به ما کمک میکند تا بفهمیم چرا بارگذاریها ناموفق هستند.
- کمک کنید مشارکت دادههای شما بخشی از راه حل باشند.
- با اجازه شما، هر بار که هنگام بارگذاری با خطایی مواجه شدید، آن را بررسی خواهیم کرد.
- یافتههای بررسیهای سلامت
- بررسی سلامت مجاز است؟
- نه، ممنون
- اصلاح
+
+ ایمیل
+ نام نمایشی
+ ایجادکردن
+ حساب کاربری ندارید؟
+
+
+
+
+
+
+
+ امن
+ آرشیو
+ شرایط و سیاست حریم خصوصی ما را بخوانید
+ سرورهای رسانه
+ پوشههای آرشیوشده
+ مدیریت سرورها
+ مدیریت پوشههای آرشیوشده
+ شرایط و سیاست حریم خصوصی ما را بخوانید
+
+
+ موفقیت!
+ هشدار!
+ اطلاعات!
+ متوجه شدم
+
+
+ پوشه را با موفقیت اضافه کردید
+ تنظیمات سرور را با موفقیت تغییر دادید.
+ گسترش
+ هنوز هیچ سروری افزوده نشده است.
+ هیچ پوشهٔ آرشیوشدهای یافت نشد.
+ اینترنت در دسترس نیست
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index d88928750..a5ca90d0d 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -1,22 +1,47 @@
-
- Connexion
- Téléversements
-
-
-
+ Lancement initial
Paramètres
+ « Internet Archive »
+ Téléversez vos médias vers un compte public gratuit sur internet Archive.
+ Service DWeb
+ Connectez-vous\nau web décentralisé
+ Serveurs
+ Ajouter un serveur
+ Renommer le dossier
+ Choisir un média
+ Supprimer le dossier
+ Archiver le dossier
+ Supprimer le dossier de l’appli
+ Le nom du dossier ne peut pas être vide
+ Le dossier a été renommé
+ Le dossier est introuvable
+ Le dossier a été supprimé
+ Ajouter des médias depuis
+ Désactiver le code d’accès
+ Voulez-vous vraiment désactiver le code d’accès \?
+ Routage en oignon en cours de développement
+ Cette fonctionnalité est actuellement en phase de développement. En attendant, pour améliorer le niveau de confidentialité et de sécurité de vos communications, vous pouvez utiliser Orbot ou n\'importe quel autre VPN de votre choix.
+ Télécharger Orbot
-
- Archive Internet
Annuler
Valider
- Supprimer le contenu multimédia
+ Confirmer
+ Supprimer
+ Réessayer
+
+ Licence
+ Lever toutes les restrictions, exigences et l\'attribution (CC0).
+ Permettre à quiconque de remixer et de partager mes médias
+ Exiger un partage identique au mien
+ Permettre l’utilisation commerciale
+
+ Supprimer le média
+ Ce média sera supprimé de Save\nIl restera sur le serveur et dans votre appli Photos.
Touchez pour marquer comme contenu important
Oui
@@ -26,75 +51,231 @@
Erreur
- Bloquer les captures d’écran
- Thème
- Clair
- Sombre
+ Activer le routage en oinon
+ Ne transférer que par le Réseau Tor
+
+ Activer ProofMode
+ Pour en savoir plus cliquez ici.]]>
+
+
+*Ni Save ni OpenArchive ne pourront accéder à ces données de localisation ni les stocker. Seules les personnes ayant accès aux fichiers du serveur pourront le faire.
+]]>\n
+
+ Identité ProofMode
+ Partager la clé publique ProofMode
+
+
+
+ L’autorisation de localisation est nécessaire
+
- Nom d’utilisateur
- Mot de passe
- Connexion
+ Nom d’utilisateur
+ Courriel
+ Mot de passe
+
+ \@string/prompt_email
+ Saisissez une adresse courriel ou un nom d’utilisateur
+ \@string/prompt_password
+ Saisissez un mot de passe
+
+ Créer un compte
Le nom d’utilisateur ou le mot de passe est erroné
+ L’adresse courriel ou le mot de passe est erroné
Ce champ est obligatoire
+ Importation du média…
+
Bienvenue
Politique de confidentialité
+ À propos de %s
+ Ajouter un dossier
+ Parcourir les dossiers existants
+ Parcourir les dossiers
+ Gestionnaire de téléversement
+
Téléversements
+ Modifier la file d’attente
+ Le téléversement est suspendu
Téléversement…
- Téléversement
+ Téléversement… (il reste %d)
+ Ces notifications s’affichent lors du téléversement de média par l’appli.
+
+ Téléverser
+
+
+ Plusieurs tentatives infructueuses ont verrouillé l’appli. Réessayez plus tard.
+ Le code d’accès n’est pas défini
+ Le code d’accès est erroné. Il reste %1$dessais.
+ Trop de tentatives infructueuses. L’appli est verrouillée.
+
+ Verrouiller l’appli avec un code d’accès
+ Saisissez votre code d’accès
+ Les codes d’accès ne correspondent pas. Réessayez.
+
+ Confirmer le code d’accès
+ Définir le code d’accès
+ Mémorisez ce code. Si vous l’oubliez, vous devrez réinitialiser l’appli et toutes les données seront effacées.
+
+
+
+ Toucher le bouton ci-dessous pour ajouter un média
+ Toucher le bouton ci-dessous pour ajouter un dossier
+ Toucher le bouton ci-dessous pour ajouter un serveur
+ Pour commencer, connectez-vous à un serveur afin d\'y stocker vos médias.
+ Vous pouvez à tout moment ajouter plusieurs\nserveurs privés et un compte de serveur IA ou DWeb.
+ Vous pouvez à tout moment ajouter plusieurs\nserveurs privés et un compte de serveur IA.
+ Connectez-vous à un serveur\nWebDAV sécurisé
+ Connectez-vous à un serveur\npublic gratuit
+ Connectez-vous à des serveurs compatibles avec WebDAV, p. ex. Nextcloud ou ownCloud.
Suivant
Terminé
Contenu important
+ Choisissez un dossier nouveau ou existant pour y enregistrer votre média.
+ La connexion au serveur privé est établie
+ La connexion à « Internet Archive » est établie
+ Créer un nouveau dossier
+ Nommez votre dossier
+ Ce dossier sera créé sur votre serveur et ajouté à Save.
+ Saisissez le nom du dossier
Modifier
Terminé
Supprimer de l’appli
- Supprimer
+ Voulez-vous vraiment supprimer votre projet \?
Archiver le projet
Désarchiver le projet
- Clé d’accès
- Clé secrète
- Obtenir les clés
Nouveau dossier
- Dropbox
+ Ne pas utiliser de caractères spéciaux dans le nom.
+ Voulez-vous vraiment supprimer ce serveur de l’appli \?
+ Un dossier portant ce nom existe déjà.
Métadonnées
- Quand vous marquez un élément, il est acheminé vers un sous-dossier dans le dossier désigné du projet sur le serveur privé.
+ Aucun serveur avec le nom d\'hôte spécifié n\'a pu être trouvé.
+ Supprimer le média
+ Marquer un contenu important
+ Quand vous marquez un élément, il est acheminé dans un sous-dossier du dossier du projet choisi sur le serveur privé.
+ Attribuer des licences « Creative Commons » aux dossiers de ce serveur.
+ Attribuer la même licence « Creative Commons » à TOUS les dossiers de ce serveur.
+ Un serveur est déjà défini avec ces identifiants
+
+
+
+ Choisir un serveur
+
+ Serveur privé
+
+ Des changements ne sont pas enregistrés !
+ Voulez-vous enregistrer \?
+ Save
+ Abandonner
+
+
+ Nommez votre serveur et choisissez une licence
+ Choisissez une licence
- Installer
- Connexion…
+
+ La configuration est terminée
+
+
+ Modifier la file d’attente
+ Le téléversement est suspendu
+
+
+ Nom du serveur (facultatif)
+ Ajouter un nouveau serveur
+ Déverrouiller la clé ProofMode
Dossiers
+ Créer un nom de dossier
+ Nom du dossier
Créer
Ajouter
- Téléverser vers Dropbox
- S’authentifier
+ Plus de dossiers
+
Retour
+ Mes médias
+ Renseignements sur le serveur
+ Saisissez l’URL
Compte
Général
- Ne téléverser des médias que connecté au Wi-Fi
- Sécurité
+ Téléverser les médias par Wi-Fi seulement
+ Securité
+ ProofMode
+ Aucune appli ne peut traiter cette demande. Installez un navigateur Web.
+
+ Envoyez vos médias vers des serveurs privés en toute sécurité et verrouillez l’appli avec un code.
+
Choisissez une licence Creative Commons.]]>
+
+ ProofMode.]]>
+ Save télécharge toujours via TLS (Transport Layer Security) pour protéger vos médias pendant leur transit.
Pour renforcer davantage la sécurité, activez Tor afin d\'empêcher l\'interception de vos médias entre votre téléphone et le serveur.]]>
Suivant
Terminé
- Préservation sécurisée de médias sur appareil mobile
- Commençons
+ Préservation sécurisée des médias sur appareil mobile
+ Commencer
+ Dossiers archivés
+ Afficher les dossiers archivés
+ Découvrir « Creative Commons ».
Actualiser
+ est une vidéo
+ Aperçu du téléversement
+ En ajouter d’autres
Tout sélectionner
- D’accord
+ Tout dessélectionner
+ J’ai compris
+ Ne plus afficher
+ Modifier plusieurs
+ Ajouter des médias
+ Touchez + pour choisir dans la photothèque ou touchez et maintenez pour ajouter des médias d’autres applis.
+ Maintenez la touche enfoncée pour sélectionner et modifier plusieurs contenus multimédias.
+ Une fois téléversé, vous ne pourrez plus le modifier.
+ Modifier les renseignements du média
+ Ajouter un emplacement (facultatif)
+ Ajouter des notes (facultatif)
Ignorer
- Ressayer
+
+ Échec de téléchargement
+ Échec de téléversement causé par une erreur de session. Réessayez ou contactez l’assistance.
+
+
+ Modifier le média
+
+ Appareil photo
+ Photos
Fichiers
- Bilans de santé
- Aidez à améliorer l’appli en effectuant des bilans de santé quand les téléversements ne réussissent pas.
- Les bilans de santé nous permettent de comprendre pourquoi les téléversements ne réussissent pas.
- Partagez vos données pour faire partie de la solution.
- Si vous l’autorisez, nous effectuerons un bilan de santé chaque fois que vous rencontrerez un problème de téléchargement.
- Capture de bilans de santé
- Permettre les bilans de santé ?
- Non merci
- Annuler
-
-
+
+
+ Addresse courriel
+ Nom affiché
+ Créer un compte
+ Pas de compte \?
+
+ Sécuriser
+ Archiver
+ Conditions et Confidentialité
+ Serveurs de contenu multimédia
+ Dossiers archivés
+ Gérez vos serveurs
+ Gérez vos dossiers archivés
+ Lisez nos conditions générales et notre politique de confidentialité
+
+
+ L’opération a été effectuée avec succès
+ Avertissement !
+ Renseignements
+ J’ai compris
+
+
+ Vous avez ajouté un dossier avec succès.
+ Les paramètres de votre serveur sont changés.
+ Développer
+ Aucun serveur n’a été ajouté
+ Aucun dossier archivé.
+ Aucune connexion à Internet
+ Save par OpenArchive
+ En savoir plus
+
+
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index fcda5c619..8f2a8a2e9 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -1,9 +1,6 @@
- Accedi
- Upload
-
Impostazioni
@@ -26,11 +23,6 @@
Errore
- Blocca acquisizione schermate
- Presentazione
- Tema
- Chiaro
- Scuro
@@ -53,25 +45,18 @@
Modifica
Fatto
Rimuovi dall\' App
- Rimuovi
+ Rimuovi
Archivia Progetto
Progetto inerente
- Chiave d\'accesso
- Chiave Segreta
- Acquisisci Chiavi
Nuova cartella
- Dropbox
Metadati
Segnala Contenuto Significativo
Quando segnali un elemento, è instradato in una sottocartella all\'interno della cartella di progetto scelto nel server privato.
- Installa
cartelle
Crea
Aggiungi
- Carica su Dropbox
- Autenticato
Indietro
Account
Generale
@@ -83,14 +68,11 @@
Per incominciare
Aggiorna
Seleziona tutto
- Ho capito
+ Ho capito
Salta
- Aggiungi file
- Riprova
+ Riprova
File
- No grazie
- Annulla
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 160d3f040..c6265ea7c 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -3,7 +3,7 @@
- #16161D
+ #000000
@color/white
#000A0A
@@ -19,15 +19,19 @@
@color/c23_teal_30
@color/c23_teal_60
- #16161D
+ #24242E
@color/c23_light_grey
- @color/c23_teal_60
+ @color/c23_grey_30
+ @color/c23_teal
@color/c23_light_grey
@color/red
+ @color/c23_medium_grey
+
#212021
@color/c23_teal_50
+ @color/c23_dark_grey
@color/c23_darker_grey
@color/white
@color/colorOnPrimary
@@ -37,13 +41,14 @@
@color/c23_teal
@color/c23_medium_grey
@color/c23_darker_grey
- @color/black
+ @color/black
@color/c23_grey
@color/c23_medium_grey
@color/white
#00B4A6
@color/c24_cream_95
@color/c23_dark_grey
+ #80333333
@color/c23_grey
#151515
@color/black
@@ -53,5 +58,8 @@
#33e3e3e4
@color/eigengrau
@color/c23_teal
+ #292828
+ #CCFFFFFF
+ #B3FFFFFF
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index eb41f3a62..568c4d7bc 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -1,9 +1,6 @@
- Inloggen
- Uploads
-
Instellingen
@@ -20,11 +17,6 @@
Fout
- Blokkeer screenshots
- Presentatie
- Thema
- Licht
- Donker
@@ -45,17 +37,13 @@
Gereed
Bewerken
Gereed
- Verwijderen
- Geheime Sleutel
+ Verwijderen
Nieuwe map
Metadata
- Installeren
- Inloggen…
Mappen
Maken
Toevoegen
- Authenticeren
Terug
Account
Algemeen
@@ -65,12 +53,10 @@
Beginnen
Vernieuwen
Alles selecteren
- Begrepen
+ Begrepen
Overslaan
- Opnieuw proberen
+ Opnieuw proberen
Bestanden
- Nee bedankt
- Ongedaan maken
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index e470e7abf..ff19900ff 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -1,9 +1,7 @@
- Entrar
Iniciar pela primeira vez
- Transferências
@@ -14,7 +12,6 @@
- Imagem Principal
@@ -38,11 +35,6 @@
Erro
- Bloquear capturas de tela
- Apresentação
- Tema
- Claro
- Escuro
@@ -63,15 +55,13 @@
Pronto
Editar
Pronto
- Remover
+ Remover
Nova Pasta
Metadados
- Instalar
Pastas
Criar
Adicionar
- Autenticar
Voltar
Conta
Geral
@@ -81,13 +71,11 @@
Começar
Atualizar
Selecionar todos
- Entendi
+ Entendi
Pular
- Tentar novamente
+ Tentar novamente
Editar Média
Arquivos
- Não, obrigado!
- Desfazer
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index b0cc71b67..dbdaa8929 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -1,9 +1,6 @@
- Авторизоваться
- Загруженная информация
-
Настройки
@@ -26,10 +23,6 @@
Ошибка
- Не допускать скриншоты
- Тема
- Светлая
- Темная
@@ -51,25 +44,18 @@
Редактировать
Готово
Удалить из приложения
- Удалить
+ Удалить
Архивировать проект
Разархивировать проект
- Ключ доступа
- Секретный ключ
- Получить ключи
Новый каталог
- Внесите свои данные, чтобы стать частью решения.
Метаданные
Отметить важное содержание
Когда вы отмечаете элемент, он направляется в подпапку выбранного проекта на частном сервере.
- Установить приложение
Папки
Создать
Добавить
- Загрузить в Dropbox
- Аутентифицировать
вернуться и отредактировать
Учетная запись
Общие
@@ -81,19 +67,10 @@
Начало работы
Обновить
Выбрать все
- ОК
+ ОК
Пропустить
- Повторить
+ Повторить
Файлы
- Проверка работоспособности
- Помогите улучшить приложение, запустив проверки работоспособности при сбое загрузки.
- Проверки работоспособности помогают нам понять, почему загрузка не удалась.
- Внесите свои данные, чтобы стать частью решения.
- С вашего разрешения мы будем проводить проверку каждый раз, когда вы сталкиваетесь с ошибкой при загрузке.
- Проверка работоспособности покрывает
- Разрешить проверку работоспособности?
- Нет, спасибо
- Отменить
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 7098c5b7d..02d204a4b 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -1,9 +1,6 @@
- Logga in
- Uppladdningar
-
Inställningar
@@ -20,10 +17,6 @@
Fel
- Presentation
- Tema
- Ljust
- Mörkt
@@ -42,14 +35,12 @@
Färdig
Ändra
Färdig
- Ta bort
+ Ta bort
Ny mapp
Metadata
- Installera
Skapa
Lägg till
- Autentisera
Tillbaka
Konto
Generell
@@ -59,11 +50,10 @@
Kom igång
Ladda om sidan
Markera allt
- Förstått
+ Förstått
Hoppa över
- Försök igen
+ Försök igen
Filer
- Ångra
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 2dc9102f6..8e9f4cde1 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -1,45 +1,47 @@
-
- Ortamlarınızı arşivleyin\nbilgi alın
-
- Oturum açın
Önce başla
- Yüklemeler
-
-
-
Ayarlar
-
-
Internet Archive
+ Ortamınızı ücretsiz ve herkese açık Internet Archive hesabına yükleyin.
+ DWeb hizmeti
+ Merkezi olmayan\ninternet bağlantısı kurun
+ Sunucular
+ Başka bir hesap ekle
+ Klasörü yeniden adlandır
+ Ortam seçin
+ Klasörü Kaldır
+ Klasörü arşivle
+ Klasörü uygulamadan kaldır
+ Klasör adı boş olamaz
+ Klasör yeniden adlandırıldı
+ Klasör bulunamadı
+ Klasör silindi
+ Şuradan ortam ekle
+ Parola kodunu devre dışı bırak
+ Parola kodunu devre dışı bırakmak istediğinize emin misiniz\?
+ Onion yöneltme geliştiriliyor
+ Bu özellik şu anda geliştiriliyor. Şimdilik, gizliliğinizi ve güvenliğinizi artırmak için Orbot uygulamasını veya istediğiniz herhangi bir VPN hizmetini kullanabilirsiniz.
+ Orbot indir
-
-
-
-
-
- Ana görsel
İptal
Tamam
- E-posta onayı
- Lütfen e-posta adresinizi doğrulayın ve Internet Archive hesabınızı bağlamaya geri dönün.
-
-
+ Onayla
+ Kaldır
+ Yeniden dene
+ Lisans
+ Tüm kısıtlamalardan, gereksinimlerden ve ilişkilendirmelerden kurtulun (CC0).
İsteyen herkes yeniden derleyip paylaşabilsin
Onlardan da senin gibi paylaşmasını iste
Ticari olarak kullanılabilsin
Ortamı sil
+ Ortam Save uygulamasından silinecek.\nSunucu ve fotoğraflar içinde durmayı sürdürecek.
Önemli içerik olarak işaretlemek için dokunun
Evet
@@ -51,30 +53,37 @@
Onion yöneltme kullanılsın
Yalnızca Tor ağı ile aktarılsın
- Sağlamak için Orbot kurun
- Ekran görüntüsü alınmasını engelle
- Bu uygulamadan ekran görüntüsü ve ekran kaydı alınmasını engelleyin.
-
- Sunum
- Tema
- Save temasını koyu ya da açık yapın
- Sistem tarafından denetleniyor
- Açık
- Koyu
+
ProofMode kullanılsın
- Ek üst verileri alın ve tüm ortamları onaylayın.
+ Ayrıntılı bilgi alın]]>
+
+
+ Ne Save ne de OpenArchive bu konum verilerine erişemez veya depolayamaz. Bu verilere yalnızca sunucu dosyalarına erişimi olanlar erişilebilir.
+ ]]>
ProofMode kimliği
ProofMode herkese açık anahtarını paylaş
+
+ Konum izni gereklidir
+
- Kullanıcı adı
- Parola
+ Kullanıcı adı
+ E-posta
+ Parola
+
+ \@string/prompt_email
+ Bir e-posta adresi ya da kullanıcı adı yazın
+ \@string/prompt_password
+ Bir parola yazın
+
Oturum aç
Kullanıcı adı ya da parola hatalı
+ E-posta ya da parola hatalı
Bu alanın doldurulması zorunludur
Ortam içe aktarılıyor…
@@ -89,73 +98,95 @@
Yükleme yöneticisi
Yüklemeler
+ Sorguyu düzenle
+ Yükleme duraklatıldı
Yükleniyor…
Yükleniyor… (%d kaldı)
Bu bildirimler, bu uygulama ortam yüklemelerini yaparken görüntülenir.
Yükle
+
+ Birden fazla başarısız deneme nedeniyle uygulama kilitlendi. Lütfen bir süre sonra yeniden deneyin.
+ Parola kodu ayarlanmamış
+ Parola kodu hatalı. %1$d deneme hakkınız kaldı.
+ Çok fazla başarısız deneme yapıldı. Uygulama kilitlendi.
+
+ Uygulamayı parola kodu ile kilitleyin
+ Parola kodunuzu yazın
+ Parola kodu ile onayı aynı değil. Yeniden deneyin.
+
+ Parola kodu onayı
+ Parola kodunu ayarla
+ Bu PIN kodunu unutmayacağınızdan emin olun. Unutursanız, uygulamayı sıfırlamanız gerekir ve uygulama içindeki tüm veriler silinir.
+
+
- Ortam eklemek için aşağıdaki düğmeye dokunun
+ Ortam eklemek için aşağıdaki düğmeye dokunun.
+ Bir klasör eklemek için aşağıdaki düğmeye dokunun
+ Bir sunucu eklemek için aşağıdaki düğmeye dokunun
Başlamak için, ortamınızı saklayacağınız bir sunucuya bağlanın.
- Yan menüden başka bir sunucu ekleyebilir ve birkaç sunucuya bağlanabilirsiniz
- Bir WebDAV sunucusuna gönder
- Internet Archive üzerine yükle
- Save yalnızca Nextcloud ve ownCloud gibi WebDAV uyumlu sunucularla bağlantı kurabilir.
- Sonraki
- Tamam
+ İstediğiniz zaman birden fazla özel sunucu ve\nbir IA veya DWeb sunucu hesabı ekleyebilirsiniz.
+ İstediğiniz zaman birden fazla özel sunucu ve\nbir IA hesabı ekleyebilirsiniz.
+ Bir güvenli WebDAV\nsunucusuna bağlanın
+ Bir güvenli herkese\naçık sunucuya bağlanın
+ WebDAV uyumlu bir sunucuya bağlan, Nexcloud veya ownCloud gibi.
+ İleri
+ Bitti
Önemli içerik
- Ortamınızı saklamak istediğiniz yeri seçin.
+ Ortamınızı kaydetmek için yeni ya da var olan bir klasör seçin.
Özel bir sunucu ile bağlantı kurdunuz!
- Dropbox ile bağlantı kurdunuz!
İnternet Archive ile bağlantı kurdunuz!
Yeni klasör oluştur
+ Klasörünüze bir ad verin
+ Bu klasör sunucunuzda oluşturulacak ve Save üzerine eklenecek.
+ Klasör adını yazın
Düzenle
- Tamam
+ Bitti
Uygulamadan kaldır
- Projenizi kaldırmak istediğinize emin misiniz?
- Kaldır
+ Projenizi kaldırmak istediğinize emin misiniz\?
Projeyi arşive kaldır
Projeyi arşivden çıkar
- Lütfen geçerli Internet Archive kimlik doğrulama bilgileri ile oturum açın.
- Erişim anahtarı
- Gizli anahtar
- Save ortam yükleyebilmek için Internet Archive hesabınızın API anahtarlarını bilmelidir
- Anahtarları al
Yeni klasör
Lütfen ad içinde özel karakterler kullanmayın.
- Bu sunucuyu uygulamadan kaldırmak istediğinize emin misiniz?
+ Bu sunucuyu uygulamadan kaldırmak istediğinize emin misiniz\?
Klasör adı zaten var.
- Dropbox
Üst veriler
- Bu ortamı silmek istediğinize emin misiniz?
-
+ A server with the specified hostname could not be found.
+ Ortamı sil
Önemli içerik olarak işaretle
Bir ögeyi işaretlediğinizde, özel sunucu üzerinde seçilmiş proje klasöründeki bir alt klasöre yönlendirilir.
-
+ Bu sunucudaki klasörler için Creative Commons lisansını ayarlayın.
Bu sunucudaki TÜM klasörler için aynı Creative Commons lisansı ayarlayın.
- Bu sunucudaki HER BİR klasör için ayrı Creative Commons lisansı ayarlayın.
-
- Kur
- Oturum açılıyor…
Bu kimlik doğrulama bilgileriyle zaten bir sunucunuz var
- Orbot/Tor bağlantısı kurulamadı.
- Orbot/Tor bağlantısı kurulamadı: ZAMAN AŞIMI
- Orbot/Tor bağlantısı kurulamadı: GEÇERSİZ
- Güvenli olmayan bir internet bağlantısı algılandı
- %s sürümü
+
+ Bir sunucu seçin
+
+ Özel sunucu
+
+ Değişiklikler kaydedilmemiş!
+ Kaydetmek ister misiniz
+ Kaydet
+ Yok say
+
+
+ Sunucunuza bir ad verin ve bir lisans seçin
+ Bir lisans seçin
+
+
+ Kurulum tamamlandı
+
+
+ Sorguyu düzenle
+ Yükleme duraklatıldı
+
- Sunucu adı (isteğe bağlı)
- Dropbox kimliği:
- Başka bir hesap ekle
- ProofMode anahtarını biyometri veya aygıt parolası ile koruyun
- ProofMode anahtarını biyometri ile koruyun
- ProofMode anahtarını aygıt parolası ile koruyun
- Bunu değiştirmek yeni bir anahtar oluşturur! Bunu daha önce dışa aktarıp imzaladıysanız, yenisiyle yeniden yapmanız gerekir.
+ Sunucu adı (isteğe bağlı)
+ Yeni sunucu ekle
ProofMode anahtarının kilidini aç
Klasörler
Klasör adı oluştur
@@ -163,98 +194,88 @@
Oluştur
Ekle
(Başka) klasör yok
- Özel sunucu
- Dropbox üzerine yükle
- Dropbox oturumu ya da hesabı aç
- Doğrula
+
Geri
Ortamlarım
- Var olan bir anahtarınız yoksa, anahtarları nasıl alacağınızı öğrenin.
Sunucu bilgileri
- Adresi yazın
+ Adresi yazın
Hesap
Genel
-
Ortamlar yalnızca Wi-Fi bağlantısı varken yüklensin
Güvenlik
ProofMode
Bu isteği yerine getirecek bir uygulama yok. Lütfen bir tarayıcı kurun.
-
- Doğrulanmış ortamı seçtiğiniz sunucuya yükleyin. Gelecekte kullanması niyetinizi göstermesi için
- bir Creative Commons lisansı ekleyin.
-
-
- Ortamınızın uzun süre güvende ve düzenli olmasını sağlayın ve uygulama içinde
- proje klasörleri oluşturarak kişisel veya kurumsal ortam arşivinizin haritasını çıkarın.
-
-
- Ortamınızın kimliğini şifreli doğrulama sha256 karması ve isteğe bağlı ProofMode ile doğrulayın.
- Her yüklemeye notlar, kişiler ve konum gibi önemli olabilecek üst veriler ekleyin.
-
-
-
- Gelişmiş ağ güvenliği sağlamak için Orbot kurun.]]>
-
- Sonraki
- Tamam
+ Ortamınızı güvenli bir şekilde özel sunuculara gönderin ve uygulamayı bir PIN kodu ile kilitleyin.
+
Creative Commons Lisansı. kullanın ]]>
+
+ ProofMode ile kimlik ve gerçekliği doğrulayın.]]>
+ Save aktarım sırasında ortamınızı korumak için her zaman TLS (Transport Layer Security) ile yükleme yapar.
Ek güvenlik sağlamak için, ortamınızın telefonunuzdan sunucuya gönderilirken ele geçirilmesini önleyen Tor özelliğini açın.]]>
+ İleri
+ Bitti
Güvenli mobil ortam koruma
Kullanmaya başlayın
- ProofMode, ortamın doğrulanmasına yardımcı olmak için yerel baz istasyonlarından üst verileri toplar. Bu ayarın açılabilmesi için Android izninin verilmesi gereklidir. Save, bu ayarı yalnızca veri yakalamak için kullanır ve arama yapmak/yönetmek için telefonunuza ERİŞMEZ.
Arşivlenmiş klasörler
Arşivlenmiş klasörleri görüntüle
Creative Commons hakkında ayrıntılı bilgi alın.
Yenile
- Başlamak için bir klasör oluşturun
- Ortam eklemeden önce yeni bir klasör oluşturun.
video mu
- Yükleme paketleri kullanılsın (yalnızca Nextcloud)
- \"Paketleme\", ortamı parçalar halinde yükler. Böylece bağlantınız kesilirse yüklemeyi en baştan değil kaldığınız yerden sürdürebilirsiniz.
- Ortam ön izlemesi
+ Yükleme ön izleme
Başka ekle
Tümünü seç
- Anladım
+ Tümünü bırak
+ Anladım
Bunu bir daha gösterme
Birkaç ögeyi düzenle
- Birkaç ortamı seçmek ve düzenlemek için basılı tutun
- Yüklendikten sonra ortamı düzenleyemeyeceksiniz
+ Ortam ekle
+ Resim galerisinden seçim yapmak için + üzerine dokunun ya da diğer uygulamalardan ortam eklemek için basılı tutun.
+ Birkaç ortamı seçmek ve düzenlemek için dokunup basılı tutun.
+ Yüklendikten sonra ortamı düzenleyemeyeceksiniz.
Ortam bilgilerini düzenle
- %1$d/%2$d
- Bir konum ekle (isteğe bağlı)
- Notlar ekle (isteğe bağlı)
+ Bir konum ekle (isteğe bağlı)
+ Notlar ekle (isteğe bağlı)
Atla
- Görsel ya da video ekle
- Dosya ekle
- Anahtarları 3 kolay adımda nasıl alırsınız
- 1. adım: Internet Archive oturumu açın. Hesabınız yoksa bir hesap açın.
- 2. adım: Yeni bir hesap açıyorsanız e-posta adresinizi doğrulayın.
- 3. adım: Kutuyu işaretleyerek uygulamaya otomatik olarak yüklenecek API anahtarlarınızı oluşturun.
Yükleme tamamlanamadı
- Yeniden dene
+ Oturum sorunu nedeniyle yükleme yapılamadı. Lütfen yeniden deneyin ya da destek ekibi ile görüşün.
+
Ortamı düzenle
- Yükleme duraklatıldı
- Şununla ortam ekleyin:
+
+ Kamera
Fotoğraf galerisi
Dosyalar
- Sağlık denetimleri
- Yüklemeler başarısız olduğunda sağlık denetimlerinin yapılmasına izin vererek uygulamanın geliştirilmesine yardımcı olun.
- Sağlık denetimleri yüklemelerin neden başarısız olduğunu anlamamızı sağlar.
- Çözümün bir parçası olmak için verilerinizle katkıda bulunun.
- İzninizi alarak, yükleme sırasında bir sorun çıktığında sağlık denetimi yapacağız.
- Sağlık denetimleri yapılıyor
- – Hata açıklaması
- – Ortam boyutu ve türü
- – Yinelenme sayısı
- – Ağ türü
- – Yerel
- Sağlık denetimleri yapılabilsin
- Bu seçenek açıldığında, uygulamanın sağlık denetimi verileri güvenli bir şekilde %s ekibine gönderilir.
- Hayır, teşekkürler
- Geri al
-
-
+
+
+ E-posta
+ Ekran adı
+ Bir tane oluştur
+ Hesabınız yok mu\?
+
+ Güvenli Tutma
+ Arşivleyin
+ Koşullar ve gizlilik
+ Ortam sunucuları
+ Arşivlenmiş klasörler
+ Sunucularınızı yönetin
+ Arşivlenmiş klasörlerinizi yönetin
+ Koşullarımızı ve gizlilik ilkemizi okuyun
+
+
+ Başarılı!
+ Uyarı!
+ Bilgiler!
+ Anladım
+
+
+ Bir klasör eklediniz.
+ Sunucu ayarlarınızı değiştirdiniz.
+ Genişlet
+ Henüz bir sunucu eklenmemiş.
+ Herhangi bir arşivlenmiş klasör bulunamadı.
+ İnternet erişimi yok
+ Save geliştiricisi OpenArchive
+ Ayrıntılı Bilgi Alın
+
+
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 26dfadfc8..ed94a0695 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -1,45 +1,47 @@
-
- Архівуйте Ваші медіа\nдізнайтеся більше
-
- Увійти
Перший початок
- Вивантаження
-
-
-
Налаштування
-
-
Сервіс Internet Archive
+ Вивантажуйте ваші медіафайли на безплатний публічний обліковий запис Internet Archive.
+ Сервіс DWeb
+ Під\'єднатись до\nдецентралізованого інтернету
+ Сервери
+ Додати сервер
+ Перейменувати теку
+ Вибрати медіафайли
+ Видалити теку
+ Архівувати теку
+ Вилучити теку з застосунку
+ Ім\'я теки не може бути порожнім
+ Теку перейменовано
+ Теку не знайдено
+ Теку видалено
+ Додати медіафайли з
+ Вимкнути код доступу
+ Ви впевнені, що хочете вимкнути застосування коду доступу\?
+ Маршрутизація через Onion у процесі розробки
+ Ця можливість знаходиться на стадії розробки. Зараз ви можете використати Orbot або будь-який VPN на ваш розсуд для підвищення приватності та безпеки.
+ Завантажити Orbot
-
-
-
-
-
- Зображення
Скасувати
Гаразд
- Підтвердження листом на електронну пошту
- Підтвердьте адресу електронної пошти та поверніться, щоб під\'єднати обліковий запис Internet Archive.
-
-
+ Підтвердити
+ Вилучити
+ Повторити
- Дозволити будь-кому видозмінювати та поширювати?
- Вимагати від них поширення файлів навзаєм?
- Дозволити комерційне використання?
+ Ліцензія
+ Відмовитись від усіх обмежень, вимог та зазначення авторства (CC0).
+ Дозволити будь-кому перемішувати та поширювати\?
+ Вимагати від них поширення файлів навзаєм\?
+ Дозволити комерційне використання\?
Вилучити медіа
+ Цей медіафайл буде вилучено з Save.\nВін залишиться на сервері та у Галереї.
Торкніться, щоб позначити як важливий вміст
Так
@@ -49,208 +51,231 @@
Помилка
- Увімкніть маршрутизацію через Onion
+ Увімкнути маршрутизацію через Onion
Передавати лише через мережу Tor
- Будь ласка, встановіть Orbot для можливості використання
- Блокувати знімок з екрана
- Заборонити знімки екрана та записи екрана цього застосунку.
-
- Вигляд
- Тема
- Зміна теми Save на світлу/темну
- Контролюється системою
- Світла
- Темна
+
Увімкнути ProofMode
- Збирає додаткові метадані та завіряє всі медіа.
+ Більше дізнайтесь тут.]]>
+
+
+*Ані Save, ані OpenArchive не матимуть доступу до цих даних про місцеперебування та не зможуть їх зберігати. Вони будуть доступні лише особам, які мають доступ до серверних файлів.
+]]>\n
Ідентифікатор ProofMode
Поширити публічний ключ ProofMode
+
+ Потрібен дозвіл на отримання місцеперебування
+
- Ім\'я користувача
- Пароль
+ Ім\'я користувача
+ Ел. пошта
+ Пароль
+
+ \@string/prompt_email
+ Введіть адресу електронної пошти або ім\'я користувача
+ \@string/prompt_password
+ Введіть пароль
+
Увійти
Неправильне ім\'я користувача або пароль
+ Хибна електронна пошта або пароль
Треба заповнити це поле
Імпортуються медіа…
- Вітаємо
+ Вітаємо!
Політика конфіденційності
Про %s
- Додати папку
- Переглянути створені папки
- Переглянути створені
+ Додати теку
+ Переглянути наявні теки
+ Переглянути наявні
Менеджер вивантаження
Вивантаження
+ Редагувати чергу
+ Вивантаження призупинено
Вивантаження…
Вивантажується… (%d залишилося)
Ці сповіщення показуються під час вивантаження медіа застосунком.
Вивантажити
+
+ Застосунок заблоковано внаслідок багатьох невдалих спроб входу. Будь ласка, спробуйте пізніше.
+ Код доступу не встановлено
+ Хибний код доступу. Залишилось спроб: %1$d.
+ Забагато хибних спроб. Застосунок заблоковано.
+
+ Заблокувати застосунок кодом доступу
+ Введіть ваш код доступу
+ Коди доступу не збігаються. Спробуйте ще раз.
+
+ Підтвердити код доступу
+ Встановити код доступу
+ Запам’ятайте цей код доступу. У разі його втрати потрібно буде виконати скидання застосунку, після чого всі внутрішні дані буде безповоротно видалено.
+
+
- Натисніть на кнопку нижче для додавання медіа.
+ Торкніться кнопки нижче для додавання медіафайлів
+ Торкніться кнопки нижче для додавання теки
+ Торкніться кнопки нижче для додавання сервера
Щоб розпочати, під’єднайтесь до сервера для збереження медіа.
- У бічному меню ви можете додати інший сервер та під\'єднатися до кількох серверів
- Відправити на сервер WebDAV
- Вивантаження до IA
- Save може під’єднуватися лише до WebDAV-підтримуваних серверів, як-от Nextcloud і Owncloud.
+ Ви в будь-який момент можете додавати різні приватні сервери та \nодин обліковий запис Internet Archive або DWeb.
+ Ви в будь-який момент можете додавати різні приватні сервери та \nодин обліковий запис Internet Archive.
+ Під\'єднатись до захищеного\nWebDAV серверу
+ Під\'єднатись до безплатного\nпублічного сервера
+ Під\'єднуйтесь до сумісних з WebDAV серверів, як-от Nextcloud або ownCloud.
Вперед
Готово
Важливий вміст
- Обрати, де зберігати медіа.
+ Оберіть нову або вже наявну теку для збереження у неї ваших файлів.
Ви успішно під’єднались до приватного сервера!
- Ви успішно під’єднались до Dropbox!
Ви успішно під’єднались до Internet Archive!
- Створити нову папку
+ Створити нову теку
+ Будь ласка, введіть назву теки
+ Цю теку буде створено на вашому сервері та додано до Save.
+ Введіть назву теки
Змінити
Готово
Вилучити із застосунку
- Ви справді хочете вилучити ваш проєкт?
- Вилучити
+ Ви справді хочете вилучити ваш проєкт\?
Архівувати проєкт
Розархівувати проєкт
- Будь ласка, вкажіть чинні облікові дані Internet Archive.
- Ключ доступу
- Приватний ключ
- Save потребує ключів API акаунта Internet Archive для вивантаження файлів.
- Отримати ключі
- Нова папка
+ Нова тека
Будь ласка, не використовуйте спеціальні символи в назві.
- Ви впевнені, що хочете видалити цей сервер із застосунку?
- Така папка вже існує.
- Dropbox
+ Ви впевнені, що хочете видалити цей сервер із застосунку\?
+ Тека з такою назвою вже існує.
Метадані
- Ви впевнені, що хочете видалити цей медіафайл?
-
+ Не вдалось знайти сервер з визначеною назвою.
+ Вилучити медіа
Позначте важливий вміст
- Коли ви позначаєте елемент, він поміщається до підпапки у вибраній папці проєкту на приватному сервері.
+ Коли ви позначаєте елемент, він поміщається до підтеки у вибраній теці проєкту на приватному сервері.
+ Встановити ліцензування Creative Commons для тек на цьому сервері.
+ Задайте однакову ліцензію Creative Commons для ВСІХ тек на цьому сервері.
+ У вас уже є сервер із цими обліковими даними
- Задайте однакову ліцензію Creative Commons для ВСІХ папок на цьому сервері.
- Задайте унікальні ліцензії Creative Commons для КОЖНОЇ папки на цьому сервері.
- Встановити
- Вхід…
- У вас уже є сервер із цими обліковими даними
- Неможливо під\'єднатися до Orbot/Tor.
- Неможливо під\'єднатися до Orbot/Tor: Сплив час очікування
- Неможливо під\'єднатися до Orbot/Tor: Недійсні дані
- Виявлено незахищене підключення до Інтернету
- Версія %s
+
+ Обрати сервер
+
+ Приватний сервер
+
+ Є незбережені зміни!
+ Бажаєте зберегти\?
+ Зберегти
+ Відхилити
+
+
+ Надайте назву сервера та оберіть тип ліцензії
+ Оберіть тип ліцензії
+
+
+ Встановлення завершене
+
+ Редагувати чергу
+ Вивантаження призупинено
- Ім\'я сервера (необов\'язково)
- Dropbox ID:
- Додати інший обліковий запис
- Захистіть ключ ProofMode біометрикою або паролем пристрою
- Захистіть ключ ProofMode біометрикою
- Захистіть ключ ProofMode паролем пристрою
- Після зміни цього буде створено новий ключ! Якщо ви експортували та підписали ключ раніше, вам потрібно буде зробити те ж саме з новим ключем.
+ Назва сервера (необов\'язково)
+ Додати новий сервер
Розблокувати ключ ProofMode
- Папки
- Назвати папку
- Назва папки
+ Теки
+ Створити назву теки
+ Назва теки
Створити
Додати
- Немає (більше) папок
- Приватний сервер
- Вивантажити до Dropbox
- Увійдіть або створіть обліковий запис у Dropbox
- Автентифікація
+ Немає (більше) тек
+
Назад
Мої медіафайли
- Якщо у вас немає чинних ключів — дізнайтесь , як їх отримати.
Інформація про сервер
- Ввести URL
+ Введіть URL-адресу
Обліковий запис
Загальні
-
- Вивантажувати медіа тільки коли ви під\'єднані до Wi-Fi
+ Вивантажувати медіа тільки, коли ви під\'єднані до Wi-Fi
Безпека
ProofMode
Жоден застосунок не може обробити цей запит. Будь ласка, встановіть веббраузер.
-
-Завантажуйте перевірені медіафайли на обраний сервер. Додавайте ліцензію Creative Commons
-для визначення політики їхнього використання у майбутньому.
-
-Тримайте свої медіафайли у безпеці та порядку для довгострокового використання, створюючи у
-застосунку папки проєктів, які показуватимуть або особистий медіаархів, або медіаархів організації.
-
-Засвідчуйте свої медіафайли криптографічною верифікацією sha256 та ProofMode за потреби.
-Додавайте важливі метадані як-от примітки, людей, локації для кожного вивантаження.
-
-
-Встановіть Orbot для увімкнення додаткової безпеки мережі.]]>
+ Безпечно надсилайте медіафайли до приватних серверів та блокуйте застосунок кодом доступу.
+
Обирайте Ліцензування Creative Commons.]]>
+
+ ProofMode.]]>
+ Save завжди вивантажує файли за допомогою TLS (Протокол захисту транспортного рівня) для захисту ваших медіафайлів під час передавання.
Для додаткового підвищення безпеки, увімкніть Tor для запобігання перехопленню ваших медіафайлів під час передавання з телефону на сервер.]]>
Вперед
Готово
Схоронність медіафайлів на мобільних пристроях
Розпочати
- ProofMode збирає метадані з веж стільникового зв’язку на місцевості для підтвердження оригінальності медіафайлів. Android вимагає дозволу для увімкнення цієї функції. Застосунок Save використовуватиме це налаштування лише для отримання даних та НЕ отримуватиме доступу до здійснення дзвінків або їхньої обробки.
Архівовані папки
Переглянути архівовані папки
Дізнатися більше про Creative Commons.
Оновити
- Щоб розпочати, будь ласка, створіть папку
- Перш ніж додавати файли, створіть нову папку.
– відео
- Використовувати Дроблення для вивантаження (лише NextCloud)
- «Дроблення» завантажує файли частинами, отже у випадку переривання з’єднання не доведеться починати вивантаження спочатку.
- Попередній перегляд медіа
+ Попередній перегляд вивантаження
Додати більше
Вибрати всі
- Зрозуміло
+ Зняти вибір з усіх
+ Зрозуміло
Не показувати знову
Редагувати декілька
- Натисніть й утримуйте для обрання та редагування декількох медіафайлів.
+ Додати медіафайли
+ Торкніться + для вибору файлів з Галереї або натисніть та утримуйте для додавання медіафайлів з інших застосунків.
+ Натисніть та тримайте для обрання та редагування декількох медіафайлів.
Після вивантаження медіафайлів втрачається можливість їхнього редагування
Редагувати дані медіафайлу
- %1$d/%2$d
- Додати розташування (необов\'язково)
- Додати примітки (необов\'язково)
+ Додати місцеперебування (необов\'язково)
+ Додати примітки (необов\'язково)
Пропустити
- Додати зображення або відео
- Додати файли
- Як отримати ключі за 3 прості кроки
- Крок 1. Увійдіть до Internet Archive. Якщо у вас немає облікового запису, створіть його.
- Крок 2. Якщо ви створюєте новий обліковий запис, підтвердіть свою електронну пошту.
- Крок 3. Згенеруйте свої API-ключі, поставивши галочку, і вони автоматично завантажаться в застосунок!
Не вдалось вивантажити
- Повторити
+ Не вдалось завантажити через помилку сесії; будь ласка, спробуйте знову або зв\'яжіться з підтримкою.
+
Редагувати медіафайл
- Вивантаження призупинено
- Додати медіафайл з:
+
+ Камера
Галерея
Файли
- Перевірки стану
- Допоможіть покращити застосунок, запустивши перевірку його стану, якщо вивантаження зазнає збою.
- Перевірка стану застосунку допомагає нам зрозуміти, чому вивантаження не вдається.
- Надайте дані застосунку, щоб допомогти розібратись.
- З вашого дозволу ми запускатимемо перевірку щоразу, коли ви зіткнетеся з помилкою під час вивантаження.
- Перевірка стану включатиме
- – Опис помилки
- – Розмір і тип медіафайла
- – Кількість спроб
- – Тип мережі
- – Локаль
- Дозволити перевірки стану?
- Дозволяючи перевірку стану, ви надаєте застосунку право безпечно надсилати дані про перевірку стану команді %s.
- Ні, дякую
- Скасувати
-
-
+
+
+ Ел. пошта
+ Назва екрану
+ Створити
+ Немає облікового запису\?
+
+ Захищайте
+ Архівуйте
+ Умови користування та політика конфіденційності
+ Сервери медіафайлів
+ Архівовані теки
+ Керувати власними серверами
+ Керувати вашими архівованими теками
+ Читати наші Умови користування та Політику конфіденційності
+
+
+ Успіх!
+ Увага!
+ Інформація!
+ Зрозуміло
+
+
+ Ви успішно додали теку.
+ Ви успішно змінили налаштування сервера.
+ Відкрити
+ Сервери ще не додано.
+ Не знайдено архівованих тек.
+ З\'єднання з інтернетом відсутнє
+ Save від OpenArchive
+ Дізнатися більше
+
+
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index d04ee4032..1c6be7283 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -1,9 +1,7 @@
- 登录
首次启动
- 上传
@@ -14,7 +12,6 @@
- 主要图像
@@ -38,10 +35,6 @@
错误
- 禁止截屏
- 主题
- 亮色主题
- 深色主题
@@ -60,16 +53,13 @@
完成
编辑
完成
- 移除
+ 移除
新文件夹
元数据
- 安装
- 正在登录…
文件夹
创建
添加
- 认证
返回
账号
常规
@@ -79,11 +69,10 @@
开始
刷新
全选
- 知道了
+ 知道了
跳过
- 重试
+ 重试
编辑媒体
解密文件
- 下次再说
-
+
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 647068fe0..c5a38c792 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -1,9 +1,6 @@
- 登入
- 上傳項目
-
設定
@@ -20,11 +17,6 @@
錯誤
- 關閉螢幕截圖
- 呈現
- 主題
- 亮色
- 深色
@@ -44,16 +36,12 @@
完成
編輯
完成
- 移除
+ 移除
新資料夾
中繼資料
- 安裝
- 正在登录…
建立
新增
- 上傳到 Dropbox
- 驗證
返回
帳號
一般
@@ -63,12 +51,10 @@
開始
更新
選擇全部
- 收到
+ 收到
跳過
- 重試
+ 重試
檔案
- 不,謝謝
- 復原
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 000000000..b233c4b7e
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f1930b304..fd128c4f5 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -4,10 +4,13 @@
#ff0000
+ #DC341E
+ #FFBB33
#00ff00
#0000ff
#000000
#33333333
+ #AB2B2B2B
#CCFFFFFF
#27aae1
#b0e7ff
@@ -16,7 +19,11 @@
#ffbebebe
#9e9e9e
#757575
+ #777979
+ #cccccc
+ #AEAEAE
#424242
+ #434343
#212121
#A92E33
@@ -51,7 +58,8 @@
#ff00ffeb
#ff00e7d5
#ff00cebe
- #ff00b4a6
+
+ #ff00B4A6
#ff009b8f
#ff008177
#ff00685f
@@ -74,7 +82,7 @@
- #F5F1E7
+ @color/white
@color/black
@color/c23_grey
@color/c23_grey
@@ -96,16 +104,19 @@
#F5F1E7
@color/c23_dark_grey
+ @color/black
- @color/c23_teal_50
+ @color/c23_teal
@color/c23_dark_grey
@color/red
+ @color/c23_medium_grey
+
#FFFBF0
@color/c23_teal_60
-
+ @color/c23_light_grey
@color/colorPrimary
@color/colorOnBackground
@color/colorOnPrimary
@@ -115,7 +126,7 @@
@color/black
@color/c23_grey
@color/c23_light_grey
- @color/white
+ @color/white
@color/colorPrimary
#00B4A6
@color/white
@@ -133,6 +144,7 @@
-->
@color/white
#E3E3E4
+ #80E3E3E4
@color/c24_cream_70
@color/white
@color/red
@@ -148,5 +160,8 @@
@color/c24_cream_80
#bbe3e3e4
@color/c24_cream_100
+ #FFFFFF
+ #66292828
+ #99FFFFFF
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index ee9449807..8201ed556 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -1,6 +1,6 @@
- 16dp
+ 8dp
16dp
8dp
@@ -32,4 +32,13 @@
20sp
32dp
+
+ 280dp
+
+ 30dp
+ 1dp
+ 14dp
+ 22dp
+ 10dp
+
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
new file mode 100644
index 000000000..ab8c60419
--- /dev/null
+++ b/app/src/main/res/values/ids.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/save_text_styles.xml b/app/src/main/res/values/save_text_styles.xml
new file mode 100644
index 000000000..58e67e5aa
--- /dev/null
+++ b/app/src/main/res/values/save_text_styles.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e7b5ce57a..92ec79371 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,64 +1,53 @@
-
- Drawer Open
- Drawer Closed
-
-
Save
- Archive Your Media\nlearn more
- 741316235729-ohg8quvto8ajl8qpil4r9rm1o3gfml3t.apps.googleusercontent.com
+ Calculator
- Log in
+ 741316235729-ohg8quvto8ajl8qpil4r9rm1o3gfml3t.apps.googleusercontent.com
First Start
- Uploads
-
-
-
-
OpenArchive
-
-
-
Settings
-
-
Internet Archive
- Upload your media to a free public or paid private account on the Internet Archive.
- Google Drive
- Google Drive™
- Upload to Google Drive
- Sign in or create an account with Google Drive
-
-
-
-
+ Upload your media to a free public account on the Internet Archive.
+ DWeb Service
+ Connect to the\ndecentralized web
Servers
+ Add Server
+ Rename Folder
+ Select Media
+ Remove Folder
+ Archive folder
+ Remove folder from app
+ Folder name cannot be empty
+ Folder renamed
+ Folder not found
+ Folder removed
+ Folder archived successfully…
+ Add media from
+ Disable Passcode
+ Are you sure you want to disable the passcode?
+ Onion routing under development
+ This feature is currently under development. For now, you can use Orbot or any VPN of your choice to enhance your privacy and security.
+ Download Orbot
-
-
-
- Main Image
Cancel
OK
- E-mail Confirmation
- Please confirm your e-mail address and return to connect your Internet Archive account.
-
-
+ Confirm
+ Remove
+ Retry
+ License
+ Waive all restrictions, requirements, and attribution (CC0).
Allow anyone to remix and share?
Require them to share like you have?
Allow commercial use?
Remove Media
+ This media will be removed from Save.\nIt will remain on the server and in your Photos app.
Tap to flag as significant content
Yes
@@ -70,32 +59,37 @@
Turn on Onion Routing
Transfer via the Tor Network only
- Please install Orbot to enable
- Block screenshots
- Prevent screenshots and screen-recordings of this app.
-
- Presentation
- Theme
- Change Save\'s theme to light/dark
- System Controlled
- Light
- Dark
+
+ System Controlled
+ Light
+ Dark
system
light
dark
Enable ProofMode
- Capture extra metadata and notarize all media.
+ Share ProofMode public key
+ Learn more here.]]>
+
+
+ *Neither Save nor OpenArchive will be able to access or store this location data, it will only be accessible to those with access to the server files.
+ ]]>
+
ProofMode Identity
Share ProofMode Public Key
+
+ Location permission required
+
- Username
- Password
+ Username
+ Email
+ Password
@string/prompt_email
Enter an email or username
@@ -104,11 +98,12 @@
Sign in
Incorrect username or password
+ Incorrect email or password
This field is required
Importing media…
- Welcome
+ Welcome!
Privacy Policy
About %s
@@ -118,75 +113,103 @@
Upload Manager
Uploads
+ Edit Queue
+ Uploading is paused
Uploading…
Uploading… (%d left)
These notifications show up while this app is performing media uploads.
Upload
+
+ A new version is ready to install.
+ Restart
+
+
+ App is locked due to multiple failed attempts. Please try again later.
+ Passcode not set
+ Incorrect passcode. %1$d attempts remaining.
+ Too many failed attempts. App is locked.
+
+ Lock app with passcode
+ Enter Your Passcode
+ Passcodes do not match. Try again.
+
+ Confirm Passcode
+ Set Passcode
+ Make sure you remember this pin. If you forget it, you will need to reset the app, and all in-app data will be erased.
+
- Tap the button below to add media.
+ Tap the button below to add media
+ Tap the button below to add a folder
+ Tap the button below to add a server
To get started, connect to a server to store your media.
- In the side menu, you can add another server and connect to multiple servers
- Send to a webdav server
- Upload to the IA
- Save only connects to WebDAV-compatible servers, e.g Nextcloud and ownCloud.
+ You can add multiple private servers and\none IA or DWeb server account at any time.
+ You can add multiple private servers and\none IA account at any time.
+ Connect to a secure\nWebDAV server
+ Connect to a free\npublic server
+ Connect to a WebDAV-compatible servers, e.g. Nexcloud and ownCloud.
Next
Done
Significant Content
- Select where to store your media.
+ Choose a new or existing folder to save your media in.
You have successfully connected to a private server!
- You have successfully connected to Dropbox!
You have successfully connected to the Internet Archive!
- You have successfully connected to Google Drive!
+ You have successfully connected to Google Drive!
Create a New Folder
+ Please name your folder
+ This folder will be created on your server and added to Save.
+ Enter folder name
Edit
Done
- Remove from App
+ Remove from app
Are you sure you want to remove your project?
- Remove
Archive Project
Unarchive Project
- Please login with valid Internet Archive credentials.
- Access Key
- Secret Key
- Save needs your Internet Archive account\'s API keys to be able to upload media.
- Acquire Keys
- Add Folder
+ New Folder
Please do not include special characters in the name.
Are you sure you want to remove this server from the app?
Folder name already exists.
- Dropbox
+ Dropbox
Metadata
+ A server with the specified hostname could not be found.
- Are you sure you want to remove this Media?
-
+ Remove Media
Flag Significant Content
When you flag an item, it is routed into a subfolder within the chosen project folder on the private server.
-
Set creative commons licenses for folders on this server.
Set the same Creative Commons license for ALL folders on this server.
- Set unique Creative Commons licenses for EACH INDIVIDUAL folder on this server.
+ You already have a server with these credentials.
- Install
- Logging in…
- You already have a server with these credentials
- Unable to connect to Orbot/Tor.
- Unable to connect to Orbot/Tor: TIMEOUT
- Unable to connect to Orbot/Tor: INVALID
- Detected an unsecured internet connection
- Version %s
+
+ Select a Server
+
+ Private Server
+
+ Unsaved changes!
+ Do you want to save
+ Save
+ Discard
+
+
+ Name your server and choose a license
+ Choose a license
+
+
+ Setup Complete
+
+
+ Edit Queue
+ Uploading is paused
- Server Name (Optional)
- Dropbox ID:
- Add Server
- Secure ProofMode Key with Biometrics or Device Passcode
- Secure ProofMode Key with Biometrics
- Secure ProofMode Key with Device Passcode
- Changing this will create a new key! If you exported and signed that one before, you will need to do it again with the new one.
+ Server Name (Optional)
+ Add new server
+ Secure ProofMode Key with Biometrics or Device Passcode
+ Secure ProofMode Key with Biometrics
+ Secure ProofMode Key with Device Passcode
Unlock ProofMode Key
Folders
Create Folder Name
@@ -194,154 +217,207 @@
Create
Add
No (more) folders
- Private Server
- Upload to Dropbox
- Sign in or create an account with Dropbox
- Authenticate
+
+ Sign in or create an account with Dropbox
+ Authenticate
Back
My Media
- If you do not have existing keys, learn how to acquire keys.
- Server Info
- Enter URL
+ Server info
+ Enter URL
Account
General
-
Only upload media when you are connected to Wi-Fi
Security
ProofMode
No application can handle this request. Please install a webbrowser.
- SECURE
- ARCHIVE
- VERIFY
- ENCRYPT
-
- Send your media securely to private servers and lock the app with a pin.
-
-
-
- Communicate your intentions for future use by adding a Creative Commons License.
- ]]>
-
+ Secure
+ Archive
+ Verify
+ Encrypt
+ Send your media securely to private servers and lock the app with a pin.
+
Choose Creative Commons Licensing.]]>
+
https://creativecommons.org
-
- ProofMode.
- ]]>
-
+ ProofMode.]]>
https://proofmode.org
-
- Automatically upload over TLS (Transport Layer Security) and use Orbot to protect your media in transit over the Tor network.
-
-
- Save always uploads over TLS (Transport Layer Security) to protect your media in transit.
- To further enhance security, enable Tor to prevent interception of your media from your phone to the server.
- ]]>
-
+ Save always uploads over TLS (Transport Layer Security) to protect your media in transit.
To further enhance security, enable Tor to prevent interception of your media from your phone to the server.]]>
https://www.torproject.org
Next
Done
Secure Mobile Media Preservation
Get Started
- ProofMode gathers metadata from local cell towers to help verify media. Android requires permission to enable this setting. Save will only use this setting to capture data and will NOT access your phone to make/manage calls.
Archived Folders
View Archived Folders
Learn more about Creative Commons.
Refresh
- To get started, please create a folder
- Before adding media, create a new folder first.
is video
- Use Upload Chunking (Nextcloud Only)
- \"Chunking\" uploads media in pieces so you don\'t have to restart your upload if your connection is interrupted.
- Preview Media
+ Preview Upload
Add More
Select All
- Got it
+ Deselect All
+ Got it
Do not show me this again
Edit Multiple
- Add Media Options
- Tap + to upload an image. Press and hold for more options.
- Press and hold to select and edit multiple media.
- Once uploaded, you will not be able to edit media
+ Add Media
+ Tap + to pick from image gallery or press and hold to add media from other apps.
+ Press and hold to select and edit multiple media items.
+ Once uploaded, you will not be able to edit media.
Edit Media Info
- %1$d/%2$d
- Add a location (optional)
- Add notes (optional)
+ %1$d/%2$d
+ Add a location (optional)
+ Add notes (optional)
Skip
- Add Images from Camera
- Add Images or Video
- Add Files
- How to Acquire Keys in 3 easy steps
- Step 1: Log into the Internet Archive. If you don\'t have an account, create one.
- Step 2: If you are creating a new account, verify your email.
- Step 3: Generate your API keys by selecting the box and it will auto load in the app!
Upload Unsuccessful
Unable to upload due to session error, please try again or contact support.
- Retry
+
Edit Media
- Uploading is paused
- Add Media using:
+
+ Add Media using:
Camera
Photo Gallery
Files
- Health Checks
- Setup Passcode
- Enter Passcode
- Help improve the app by running health checks when uploads fail.
- Health checks help us understand why uploads fail.
- Contribute your data to be part of the solution.
- With your permission, we\'ll run a check each time you encounter an error while uploading.
- Health checks capture
- – Error description
- – Size and type of media
- – Number of retries
- – Network type
- – Locale
- Allow health checks?
- By allowing health checks, you give permission for the app to securely send health check data to the %s team.
- No thanks
- Undo
- Google Drive ID
- %1$s\'s use and transfer of information received from %2$s APIs to any other app adheres to %3$s, including the Limited Use requirements.
- Google
- Google API Services User Data Policy]]>
- %1$s\'s APIs allow you to send media to your %2$s via %3$s. %3$s, however, cannot see or access anything on your %2$s, which you didn\'t create with %3$s in the first place.
- %1$s cannot work properly if you don\'t allow it to write to your %2$s. Please try the authorization again and make sure to grant all the access permissions listed.
- new user
Email
Screen Name
- Login
Create one
No account?
- Continue
-
- Hello blank fragment
-
- Messages
- Sync
-
-
- Your signature
- Default reply action
-
-
- Sync email periodically
- Download incoming attachments
- Automatically download attachments for incoming emails
-
- Only download attachments when manually requested
- pref_media_servers
- pref_media_folders
- passcode_enabled
Secure
Archive
+ Terms and Privacy
+ Media Servers
+ Archived Folders
+ Manage your servers
+ Manage your archived folders
+ Read our Terms and Privacy Policy
+
+
+ Success!
+ Warning!
+ Info!
+ Got it
+
+
+ You have added a folder successfully.
+ You have changed your server settings successfully.
+ Expand
+ No servers added yet.
+ No archived folders found.
+ Internet not available
+ Save by OpenArchive
+ Learn More
+
+ pref_media_servers
+ pref_media_folders
+ pref_about_app
+ pref_privacy_policy
+ pref_app_version
+ passcode_enabled
+ pref_proof_mode
+ use_proofmode
+ pref_use_tor
+ pref_is_dark_mode
+ app_masking_enabled
+
+ Main Image
+ Google Drive ID
+ %1$s\'s use and transfer of information received from %2$s APIs to any other app adheres to %3$s, including the Limited Use requirements.
+ Google
+ Google API Services User Data Policy]]>
+ %1$s\'s APIs allow you to send media to your %2$s via %3$s. %3$s, however, cannot see or access anything on your %2$s, which you didn\'t create with %3$s in the first place.
+ %1$s cannot work properly if you don\'t allow it to write to your %2$s. Please try the authorization again and make sure to grant all the access permissions listed.
+ Google Drive
+ Google Drive™
+ Sign in or create an account with Google Drive
+
+ Health Checks
+ Setup Passcode
+ Enter Passcode
+ Health checks help us understand why uploads fail.
+ Contribute your data to be part of the solution.
+ With your permission, we\'ll run a check each time you encounter an error while uploading.
+ Health checks capture
+ – Error description
+ – Size and type of media
+ – Number of retries
+ – Network type
+ – Locale
+ Allow health checks?
+ By allowing health checks, you give permission for the app to securely send health check data to the %s team.
+ No thanks
+
+ Add Images from Camera
+ Add Images or Video
+ Add Files
+
+ Unable to connect to Orbot/Tor.
+ Unable to connect to Orbot/Tor: TIMEOUT
+ Unable to connect to Orbot/Tor: INVALID
+
+
+
+ separator
+
+
+
+
+ Choose how the app appears
+ Change the launcher name and icon. You can always switch back to the original.
+ Note
+ Android may still list the original app name in system settings. These options only change the launcher icon and label.
+ Save
+ Standard Save name and icon.
+ Calc
+ Simple calculator for everyday math.
+ Notes
+ Private notes and word lists.
+ Calendar
+ Basic calendar for events and reminders.
+ Use this look
+ Current look
+ Active
+ Switch appearance?
+ The app will restart and appear as %1$s.
+ Apply
+ Applying appearance\u2026
+ Appearance changed successfully
+ Restarting app\u2026
+ Failed to apply appearance. Please try again.
+
+
+
+ Camera Access Blocked
+ Camera Permission Required
+ and microphone
+ Camera%1$s access has been permanently denied. Please enable it in Settings to use this feature.
+ This app needs access to your camera and microphone to capture photos and videos.
+ This app needs access to your camera to capture photos.
+ Open Settings
+ Grant Permission
+ Open App Settings
+
+
+ Captured Photo
+ PHOTO
+ VIDEO
+ Retake
+ Use
+ Pause
+ Play
+ Video
+
+
+ Switch Camera
+ Capture Photo
+ Start Recording
+ Done (%d)
+ Close
+ Grid
+ Flash
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index da5d26c8f..f21cc74ff 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -2,17 +2,9 @@
@@ -22,15 +14,33 @@
- @color/colorOnSurface
- @color/colorPrimaryDark
- @color/colorOnPrimary
+ - @color/colorOutline
+
+ - @color/colorOnSurfaceVariant
+
- @color/colorBackground
- @style/OATextViewStyle
+ - @style/TextAppearance.SaveApp.18pt
+
+ - @style/TextAppearance.SaveApp.16pt
+ - @style/TextAppearance.SaveApp.14pt
+ - @style/TextAppearance.SaveApp.11pt
+ - @style/TextAppearance.Material3.BodySmall
+
+ - @style/TextAppearance.App.TitleLarge
+ - @style/TextAppearance.App.TitleMedium
- - @color/colorPrimary
- - @bool/dayMode
+
+ - true
+ - @color/colorTertiary
+
- @bool/dayMode
- - @style/NavigationViewStyle
+
+
+
+
- @style/AppDialogTheme
- @style/SaveAppBottomSheetTheme
- @style/AlertDialogTheme
@@ -49,7 +59,7 @@
- @style/Widget.App.Button.TextButton
- @style/Widget.App.Button.OutlinedButton
-
+
- @style/TextAppearance.App.Button
@@ -57,14 +67,17 @@
- @color/white
- - @style/Widget.SaveApp.TextInputLayout
+ - @style/OATextInputLayoutStyle
- - @style/TextAppearance.App.TitleLarge
- - @style/TextAppearance.App.TitleMedium
- - @style/TextAppearance.App.BodySmall
- @style/ShapeAppearance.SaveApp.SmallComponent
- @style/Widget.SaveApp.Switch
+
+
+
+
+
@@ -73,42 +86,38 @@
- @drawable/save_list_item_spacing_small
- 2dp
- @style/AlertDialogTheme
-
-
+
+ - @style/SaveApp.Preference.SwitchPreference
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
-
+
+
diff --git a/app/src/main/res/values/styles_selection.xml b/app/src/main/res/values/styles_selection.xml
new file mode 100644
index 000000000..79a07cd46
--- /dev/null
+++ b/app/src/main/res/values/styles_selection.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/prefs_general.xml b/app/src/main/res/xml/prefs_general.xml
index f4d93a7ac..f2a6b61b7 100644
--- a/app/src/main/res/xml/prefs_general.xml
+++ b/app/src/main/res/xml/prefs_general.xml
@@ -13,11 +13,24 @@
android:icon="@null"
android:iconSpaceReserved="false"
android:key="@string/pref_app_passcode"
+ android:layout="@layout/custom_preference_switch"
android:singleLineTitle="false"
android:title="Lock app with passcode"
+ android:widgetLayout="@layout/custom_preference_widget_switch"
+ app:icon="@null"
+ app:iconSpaceReserved="false" />
+
+
+ app:summary="Hide save app" />
@@ -26,22 +39,35 @@
android:title="@string/pref_title_archive"
app:allowDividerAbove="true">
+
+
@@ -68,7 +94,8 @@
@@ -82,10 +109,12 @@
@@ -96,40 +125,38 @@
android:layout="@layout/custom_preference_category"
android:title="General">
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+ android:key="@string/pref_key_use_dark_mode"
+ android:layout="@layout/custom_preference_switch"
+ android:singleLineTitle="true"
+ android:title="Switch to dark mode"
+ android:widgetLayout="@layout/custom_preference_widget_switch"
+ app:icon="@null"
+ app:iconSpaceReserved="false" />
-
\ No newline at end of file
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/prefs_proof_mode.xml b/app/src/main/res/xml/prefs_proof_mode.xml
index 1f756172d..0c54cb201 100644
--- a/app/src/main/res/xml/prefs_proof_mode.xml
+++ b/app/src/main/res/xml/prefs_proof_mode.xml
@@ -1,37 +1,15 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ app:key="@string/pref_key_use_proof_mode"
+ app:singleLineTitle="false"
+ app:title="@string/prefs_use_proofmode_title" />
\ No newline at end of file
diff --git a/app/src/test/java/net/opendasharchive/openarchive/MainMediaAdapterTest.kt b/app/src/test/java/net/opendasharchive/openarchive/MainMediaAdapterTest.kt
new file mode 100644
index 000000000..f0f1b5244
--- /dev/null
+++ b/app/src/test/java/net/opendasharchive/openarchive/MainMediaAdapterTest.kt
@@ -0,0 +1,188 @@
+package net.opendasharchive.openarchive
+
+import android.app.Activity
+import androidx.recyclerview.widget.RecyclerView
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertFalse
+import junit.framework.TestCase.assertTrue
+import net.opendasharchive.openarchive.db.Media
+import net.opendasharchive.openarchive.features.main.adapters.MainMediaAdapter
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import java.util.Date
+
+
+// Since Media is a SugarRecord and calls save()/delete() internally,
+// for testing we want to stub these methods.
+// One option is to use a fake subclass. For simplicity, we’ll create a helper
+// function that creates Media instances with test values. (In a real test suite,
+// you may want to use a mocking library like Mockito or create a fake subclass.)
+fun createTestMedia(
+ id: Long,
+ uri: String,
+ status: Media.Status,
+ progress: Int? = 0,
+ selected: Boolean = false,
+ title: String = "Test Media"
+): Media {
+ return Media(
+ originalFilePath = uri,
+ mimeType = "image/jpeg",
+ createDate = Date(),
+ title = title,
+ description = "",
+ author = "",
+ location = "",
+ tags = "",
+ licenseUrl = null,
+ mediaHash = byteArrayOf(),
+ mediaHashString = "",
+ status = status.id,
+ statusMessage = "",
+ projectId = 100,
+ collectionId = 200,
+ contentLength = 1024L,
+ progress = 0,
+ flag = false,
+ priority = 0,
+ selected = selected
+ ).apply {
+ // Stub out save() and delete() if needed (SugarRecord normally writes to DB).
+ // For tests, you can override these to no-ops.
+ // For example, if using Mockito, you could spy() and stub save()/delete().
+ }
+}
+
+@RunWith(RobolectricTestRunner::class)
+class MainMediaAdapterTest {
+
+
+ private lateinit var activity: Activity
+ private lateinit var recyclerView: RecyclerView
+ private lateinit var adapter: MainMediaAdapter
+ private lateinit var mediaList: MutableList
+
+ @Before
+ fun setup() {
+ // Use Robolectric to create an Activity
+ activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ recyclerView = RecyclerView(activity)
+ // Create a dummy list of Media items
+ mediaList = mutableListOf(
+ createTestMedia(id = 1, uri = "file://uri1", status = Media.Status.Local, progress = 0),
+ createTestMedia(
+ id = 2,
+ uri = "file://uri2",
+ status = Media.Status.Queued,
+ progress = 0
+ ),
+ createTestMedia(
+ id = 3,
+ uri = "file://uri3",
+ status = Media.Status.Uploading,
+ progress = 50
+ )
+ )
+ adapter = MainMediaAdapter(
+ activity,
+ mediaList,
+ recyclerView,
+ checkSelecting = { },
+ allowMultiProjectSelection = TODO(),
+ onDeleteClick = TODO(),
+ )
+ }
+
+ @Test
+ fun testItemCountAndGetItemId() {
+ // Verify that the item count matches the list size.
+ assertEquals(mediaList.size, adapter.itemCount)
+ // Verify stable IDs are returned correctly.
+ assertEquals(1L, adapter.getItemId(0))
+ assertEquals(2L, adapter.getItemId(1))
+ assertEquals(3L, adapter.getItemId(2))
+ }
+
+ @Test
+ fun testUpdateItemProgress() {
+ // Update item with id 3 to a new progress value.
+ val result = adapter.updateItem(3, progress = 80)
+ assertTrue(result)
+ val updatedMedia = adapter.media.first { it.id == 3L }
+ assertEquals(80, updatedMedia.uploadPercentage)
+ // Since we update the status when progress is updated, check that status is set to Uploading.
+ assertEquals(Media.Status.Uploading.id, updatedMedia.status)
+ }
+
+ @Test
+ fun testRemoveItem() {
+ // Remove media with id 2.
+ val result = adapter.removeItem(2)
+ assertTrue(result)
+ // After removal, item count should decrease.
+ assertEquals(2, adapter.itemCount)
+ // And no item with id 2 should exist.
+ assertFalse(adapter.media.any { it.id == 2L })
+ }
+
+ @Test
+ fun testUpdateData() {
+ // Simulate a data refresh with a new media list.
+ val newMediaList = listOf(
+ createTestMedia(
+ id = 1,
+ uri = "file://uri1",
+ status = Media.Status.Uploaded,
+ progress = 100
+ ),
+ createTestMedia(id = 2, uri = "file://uri2", status = Media.Status.Local, progress = 0),
+ createTestMedia(id = 4, uri = "file://uri4", status = Media.Status.Local, progress = 0)
+ )
+ adapter.updateData(newMediaList)
+ // Verify that the adapter now has three items.
+ assertEquals(3, adapter.itemCount)
+ // New item (id = 4) should be present.
+ assertTrue(adapter.media.any { it.id == 4L })
+ // Item with id 3 should have been removed.
+ assertFalse(adapter.media.any { it.id == 3L })
+ }
+
+ @Test
+ fun testClearSelections() {
+ // Mark some items as selected.
+ adapter.media[0].selected = true
+ adapter.media[1].selected = true
+ // Call clearSelections() and verify all items are unselected.
+ adapter.clearSelections()
+ adapter.media.forEach { assertFalse(it.selected) }
+ }
+
+ @Test
+ fun testOnItemMove() {
+ // Enable edit mode so onItemMove works.
+ adapter.isEditMode = true
+ val firstItemId = adapter.media[0].id
+ // Move item at position 0 to position 2.
+ adapter.onItemMove(0, 2)
+ // Check that the item now appears at position 2.
+ assertEquals(firstItemId, adapter.media[2].id)
+ }
+
+ @Test
+ fun testDeleteSelected() {
+ // Mark two items as selected.
+ adapter.media[0].selected = true
+ adapter.media[2].selected = true
+ val originalCount = adapter.itemCount
+ // Call deleteSelected() and verify it returns true.
+ val result = adapter.deleteSelected()
+ assertTrue(result)
+ // The new count should be originalCount - 2.
+ assertEquals(originalCount - 2, adapter.itemCount)
+ // Verify that no selected items remain.
+ assertEquals(0, adapter.getSelectedCount())
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 3607adedd..253e9da58 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,17 +1,24 @@
plugins {
- id("com.android.application") version "8.6.1" apply false
- id("org.jetbrains.kotlin.android") version "2.1.10" apply false
- id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
- id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10" apply false
- id("com.google.devtools.ksp") version "2.1.10-1.0.29" apply false
-}
+ // Android
+ alias(libs.plugins.android.application) apply false
+
+ // Kotlin
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.compose.compiler) apply false
+
+ // Build Tools
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.navigation.safeargs) apply false
+
+ // Code Quality
+ alias(libs.plugins.detekt.plugin) apply false
-configurations.configureEach {
- resolutionStrategy {
- force("com.android.support:support-v4:1.0.0")
- }
+ // Google Services
+ alias(libs.plugins.google.gms.google.services) apply false
+ alias(libs.plugins.google.firebase.crashlytics) apply false
}
tasks.register("clean") {
- delete(rootProject.buildDir)
+ delete(layout.buildDirectory)
}
diff --git a/config/detekt-config.yml b/config/detekt-config.yml
new file mode 100644
index 000000000..e26d76684
--- /dev/null
+++ b/config/detekt-config.yml
@@ -0,0 +1,821 @@
+build:
+ maxIssues: 0
+ excludeCorrectable: false
+ weights:
+ # complexity: 2
+ # LongParameterList: 1
+ # style: 1
+ # comments: 1
+
+config:
+ validation: true
+ warningsAsErrors: false
+ checkExhaustiveness: false
+ # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
+ excludes: ''
+
+compose:
+ ReusedModifierInstance:
+ active: true
+ UnnecessaryEventHandlerParameter:
+ active: true
+ ComposableEventParameterNaming:
+ active: true
+ ComposableParametersOrdering:
+ active: true
+ ModifierHeightWithText:
+ active: true
+ MissingModifierDefaultValue:
+ active: true
+ PublicComposablePreview:
+ active: true
+ TopLevelComposableFunctions:
+ active: true
+ allowInObjects: false
+ ComposableFunctionName:
+ active: true
+ ConditionCouldBeLifted:
+ active: true
+ ignoreCallsWithArgumentNames: [ 'modifier', 'contentAlignment' ]
+
+processors:
+ active: true
+ exclude:
+ - 'DetektProgressListener'
+ # - 'KtFileCountProcessor'
+ # - 'PackageCountProcessor'
+ # - 'ClassCountProcessor'
+ # - 'FunctionCountProcessor'
+ # - 'PropertyCountProcessor'
+ # - 'ProjectComplexityProcessor'
+ # - 'ProjectCognitiveComplexityProcessor'
+ # - 'ProjectLLOCProcessor'
+ # - 'ProjectCLOCProcessor'
+ # - 'ProjectLOCProcessor'
+ # - 'ProjectSLOCProcessor'
+ # - 'LicenseHeaderLoaderExtension'
+
+console-reports:
+ active: true
+ exclude:
+ - 'ProjectStatisticsReport'
+ - 'ComplexityReport'
+ - 'NotificationReport'
+ - 'FindingsReport'
+ - 'FileBasedFindingsReport'
+ # - 'LiteFindingsReport'
+
+output-reports:
+ active: true
+ exclude:
+ # - 'TxtOutputReport'
+ # - 'XmlOutputReport'
+ # - 'HtmlOutputReport'
+ # - 'MdOutputReport'
+ # - 'SarifOutputReport'
+
+comments:
+ active: true
+ AbsentOrWrongFileLicense:
+ active: false
+ licenseTemplateFile: 'license.template'
+ licenseTemplateIsRegex: false
+ CommentOverPrivateFunction:
+ active: false
+ CommentOverPrivateProperty:
+ active: false
+ DeprecatedBlockTag:
+ active: false
+ EndOfSentenceFormat:
+ active: false
+ endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
+ KDocReferencesNonPublicProperty:
+ active: false
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ OutdatedDocumentation:
+ active: false
+ matchTypeParameters: true
+ matchDeclarationsOrder: true
+ allowParamOnConstructorProperties: false
+ UndocumentedPublicClass:
+ active: false
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ searchInNestedClass: true
+ searchInInnerClass: true
+ searchInInnerObject: true
+ searchInInnerInterface: true
+ searchInProtectedClass: false
+ ignoreDefaultCompanionObject: false
+ UndocumentedPublicFunction:
+ active: false
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ searchProtectedFunction: false
+ UndocumentedPublicProperty:
+ active: false
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ searchProtectedProperty: false
+
+complexity:
+ active: true
+ CognitiveComplexMethod:
+ active: false
+ threshold: 15
+ ComplexCondition:
+ active: true
+ threshold: 4
+ ComplexInterface:
+ active: false
+ threshold: 10
+ includeStaticDeclarations: false
+ includePrivateDeclarations: false
+ ignoreOverloaded: false
+ CyclomaticComplexMethod:
+ active: true
+ threshold: 15
+ ignoreSingleWhenExpression: false
+ ignoreSimpleWhenEntries: false
+ ignoreNestingFunctions: false
+ nestingFunctions:
+ - 'also'
+ - 'apply'
+ - 'forEach'
+ - 'isNotNull'
+ - 'ifNull'
+ - 'let'
+ - 'run'
+ - 'use'
+ - 'with'
+ LabeledExpression:
+ active: false
+ ignoredLabels: []
+ LargeClass:
+ active: true
+ threshold: 600
+ LongMethod:
+ active: true
+ threshold: 60
+ LongParameterList:
+ active: true
+ functionThreshold: 6
+ constructorThreshold: 7
+ ignoreDefaultParameters: false
+ ignoreDataClasses: true
+ ignoreAnnotatedParameter: []
+ MethodOverloading:
+ active: false
+ threshold: 6
+ NamedArguments:
+ active: false
+ threshold: 3
+ ignoreArgumentsMatchingNames: false
+ NestedBlockDepth:
+ active: true
+ threshold: 4
+ NestedScopeFunctions:
+ active: false
+ threshold: 1
+ functions:
+ - 'kotlin.apply'
+ - 'kotlin.run'
+ - 'kotlin.with'
+ - 'kotlin.let'
+ - 'kotlin.also'
+ ReplaceSafeCallChainWithRun:
+ active: false
+ StringLiteralDuplication:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ threshold: 3
+ ignoreAnnotation: true
+ excludeStringsWithLessThan5Characters: true
+ ignoreStringsRegex: '$^'
+ TooManyFunctions:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ thresholdInFiles: 11
+ thresholdInClasses: 11
+ thresholdInInterfaces: 11
+ thresholdInObjects: 11
+ thresholdInEnums: 11
+ ignoreDeprecated: false
+ ignorePrivate: false
+ ignoreOverridden: false
+ ignoreAnnotatedFunctions: []
+
+coroutines:
+ active: true
+ GlobalCoroutineUsage:
+ active: false
+ InjectDispatcher:
+ active: true
+ dispatcherNames:
+ - 'IO'
+ - 'Default'
+ - 'Unconfined'
+ RedundantSuspendModifier:
+ active: true
+ SleepInsteadOfDelay:
+ active: true
+ SuspendFunSwallowedCancellation:
+ active: false
+ SuspendFunWithCoroutineScopeReceiver:
+ active: false
+ SuspendFunWithFlowReturnType:
+ active: true
+
+empty-blocks:
+ active: true
+ EmptyCatchBlock:
+ active: true
+ allowedExceptionNameRegex: '_|(ignore|expected).*'
+ EmptyClassBlock:
+ active: true
+ EmptyDefaultConstructor:
+ active: true
+ EmptyDoWhileBlock:
+ active: true
+ EmptyElseBlock:
+ active: true
+ EmptyFinallyBlock:
+ active: true
+ EmptyForBlock:
+ active: true
+ EmptyFunctionBlock:
+ active: true
+ ignoreOverridden: false
+ EmptyIfBlock:
+ active: true
+ EmptyInitBlock:
+ active: true
+ EmptyKtFile:
+ active: true
+ EmptySecondaryConstructor:
+ active: true
+ EmptyTryBlock:
+ active: true
+ EmptyWhenBlock:
+ active: true
+ EmptyWhileBlock:
+ active: true
+
+exceptions:
+ active: true
+ ExceptionRaisedInUnexpectedLocation:
+ active: true
+ methodNames:
+ - 'equals'
+ - 'finalize'
+ - 'hashCode'
+ - 'toString'
+ InstanceOfCheckForException:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ NotImplementedDeclaration:
+ active: false
+ ObjectExtendsThrowable:
+ active: false
+ PrintStackTrace:
+ active: true
+ RethrowCaughtException:
+ active: true
+ ReturnFromFinally:
+ active: true
+ ignoreLabeled: false
+ SwallowedException:
+ active: true
+ ignoredExceptionTypes:
+ - 'InterruptedException'
+ - 'MalformedURLException'
+ - 'NumberFormatException'
+ - 'ParseException'
+ allowedExceptionNameRegex: '_|(ignore|expected).*'
+ ThrowingExceptionFromFinally:
+ active: true
+ ThrowingExceptionInMain:
+ active: false
+ ThrowingExceptionsWithoutMessageOrCause:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ exceptions:
+ - 'ArrayIndexOutOfBoundsException'
+ - 'Exception'
+ - 'IllegalArgumentException'
+ - 'IllegalMonitorStateException'
+ - 'IllegalStateException'
+ - 'IndexOutOfBoundsException'
+ - 'NullPointerException'
+ - 'RuntimeException'
+ - 'Throwable'
+ ThrowingNewInstanceOfSameException:
+ active: true
+ TooGenericExceptionCaught:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ exceptionNames:
+ - 'ArrayIndexOutOfBoundsException'
+ - 'Error'
+ - 'Exception'
+ - 'IllegalMonitorStateException'
+ - 'IndexOutOfBoundsException'
+ - 'NullPointerException'
+ - 'RuntimeException'
+ - 'Throwable'
+ allowedExceptionNameRegex: '_|(ignore|expected).*'
+ TooGenericExceptionThrown:
+ active: true
+ exceptionNames:
+ - 'Error'
+ - 'Exception'
+ - 'RuntimeException'
+ - 'Throwable'
+
+naming:
+ active: true
+ BooleanPropertyNaming:
+ active: false
+ allowedPattern: '^(is|has|are)'
+ ClassNaming:
+ active: true
+ classPattern: '[A-Z][a-zA-Z0-9]*'
+ ConstructorParameterNaming:
+ active: true
+ parameterPattern: '[a-z][A-Za-z0-9]*'
+ privateParameterPattern: '[a-z][A-Za-z0-9]*'
+ excludeClassPattern: '$^'
+ EnumNaming:
+ active: true
+ enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
+ ForbiddenClassName:
+ active: false
+ forbiddenName: []
+ FunctionMaxLength:
+ active: false
+ maximumFunctionNameLength: 30
+ FunctionMinLength:
+ active: false
+ minimumFunctionNameLength: 3
+ FunctionNaming:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ functionPattern: '[a-z][a-zA-Z0-9]*'
+ excludeClassPattern: '$^'
+ FunctionParameterNaming:
+ active: true
+ parameterPattern: '[a-z][A-Za-z0-9]*'
+ excludeClassPattern: '$^'
+ InvalidPackageDeclaration:
+ active: true
+ rootPackage: ''
+ requireRootInDeclaration: false
+ LambdaParameterNaming:
+ active: false
+ parameterPattern: '[a-z][A-Za-z0-9]*|_'
+ MatchingDeclarationName:
+ active: true
+ mustBeFirst: true
+ multiplatformTargets:
+ - 'ios'
+ - 'android'
+ - 'js'
+ - 'jvm'
+ - 'native'
+ - 'iosArm64'
+ - 'iosX64'
+ - 'macosX64'
+ - 'mingwX64'
+ - 'linuxX64'
+ MemberNameEqualsClassName:
+ active: true
+ ignoreOverridden: true
+ NoNameShadowing:
+ active: true
+ NonBooleanPropertyPrefixedWithIs:
+ active: false
+ ObjectPropertyNaming:
+ active: true
+ constantPattern: '[A-Za-z][_A-Za-z0-9]*'
+ propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
+ privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
+ PackageNaming:
+ active: true
+ packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
+ TopLevelPropertyNaming:
+ active: true
+ constantPattern: '[A-Z][_A-Z0-9]*'
+ propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
+ privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
+ VariableMaxLength:
+ active: false
+ maximumVariableNameLength: 64
+ VariableMinLength:
+ active: false
+ minimumVariableNameLength: 1
+ VariableNaming:
+ active: true
+ variablePattern: '[a-z][A-Za-z0-9]*'
+ privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*'
+ excludeClassPattern: '$^'
+
+performance:
+ active: true
+ ArrayPrimitive:
+ active: true
+ CouldBeSequence:
+ active: false
+ threshold: 3
+ ForEachOnRange:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ SpreadOperator:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ UnnecessaryPartOfBinaryExpression:
+ active: false
+ UnnecessaryTemporaryInstantiation:
+ active: true
+
+potential-bugs:
+ active: true
+ AvoidReferentialEquality:
+ active: true
+ forbiddenTypePatterns:
+ - 'kotlin.String'
+ CastNullableToNonNullableType:
+ active: false
+ CastToNullableType:
+ active: false
+ Deprecation:
+ active: false
+ DontDowncastCollectionTypes:
+ active: false
+ DoubleMutabilityForCollection:
+ active: true
+ mutableTypes:
+ - 'kotlin.collections.MutableList'
+ - 'kotlin.collections.MutableMap'
+ - 'kotlin.collections.MutableSet'
+ - 'java.util.ArrayList'
+ - 'java.util.LinkedHashSet'
+ - 'java.util.HashSet'
+ - 'java.util.LinkedHashMap'
+ - 'java.util.HashMap'
+ ElseCaseInsteadOfExhaustiveWhen:
+ active: false
+ ignoredSubjectTypes: []
+ EqualsAlwaysReturnsTrueOrFalse:
+ active: true
+ EqualsWithHashCodeExist:
+ active: true
+ ExitOutsideMain:
+ active: false
+ ExplicitGarbageCollectionCall:
+ active: true
+ HasPlatformType:
+ active: true
+ IgnoredReturnValue:
+ active: true
+ restrictToConfig: true
+ returnValueAnnotations:
+ - 'CheckResult'
+ - '*.CheckResult'
+ - 'CheckReturnValue'
+ - '*.CheckReturnValue'
+ ignoreReturnValueAnnotations:
+ - 'CanIgnoreReturnValue'
+ - '*.CanIgnoreReturnValue'
+ returnValueTypes:
+ - 'kotlin.sequences.Sequence'
+ - 'kotlinx.coroutines.flow.*Flow'
+ - 'java.util.stream.*Stream'
+ ignoreFunctionCall: []
+ ImplicitDefaultLocale:
+ active: true
+ ImplicitUnitReturnType:
+ active: false
+ allowExplicitReturnType: true
+ InvalidRange:
+ active: true
+ IteratorHasNextCallsNextMethod:
+ active: true
+ IteratorNotThrowingNoSuchElementException:
+ active: true
+ LateinitUsage:
+ active: false
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ ignoreOnClassesPattern: ''
+ MapGetWithNotNullAssertionOperator:
+ active: true
+ MissingPackageDeclaration:
+ active: false
+ excludes: ['**/*.kts']
+ NullCheckOnMutableProperty:
+ active: false
+ NullableToStringCall:
+ active: false
+ PropertyUsedBeforeDeclaration:
+ active: false
+ UnconditionalJumpStatementInLoop:
+ active: false
+ UnnecessaryNotNullCheck:
+ active: false
+ UnnecessaryNotNullOperator:
+ active: true
+ UnnecessarySafeCall:
+ active: true
+ UnreachableCatchBlock:
+ active: true
+ UnreachableCode:
+ active: true
+ UnsafeCallOnNullableType:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ UnsafeCast:
+ active: true
+ UnusedUnaryOperator:
+ active: true
+ UselessPostfixExpression:
+ active: true
+ WrongEqualsTypeParameter:
+ active: true
+
+style:
+ active: true
+ AlsoCouldBeApply:
+ active: false
+ BracesOnIfStatements:
+ active: false
+ singleLine: 'never'
+ multiLine: 'always'
+ BracesOnWhenStatements:
+ active: false
+ singleLine: 'necessary'
+ multiLine: 'consistent'
+ CanBeNonNullable:
+ active: false
+ CascadingCallWrapping:
+ active: false
+ includeElvis: true
+ ClassOrdering:
+ active: false
+ CollapsibleIfStatements:
+ active: false
+ DataClassContainsFunctions:
+ active: false
+ conversionFunctionPrefix:
+ - 'to'
+ allowOperators: false
+ DataClassShouldBeImmutable:
+ active: false
+ DestructuringDeclarationWithTooManyEntries:
+ active: true
+ maxDestructuringEntries: 3
+ DoubleNegativeLambda:
+ active: false
+ negativeFunctions:
+ - reason: 'Use `takeIf` instead.'
+ value: 'takeUnless'
+ - reason: 'Use `all` instead.'
+ value: 'none'
+ negativeFunctionNameParts:
+ - 'not'
+ - 'non'
+ EqualsNullCall:
+ active: true
+ EqualsOnSignatureLine:
+ active: false
+ ExplicitCollectionElementAccessMethod:
+ active: false
+ ExplicitItLambdaParameter:
+ active: true
+ ExpressionBodySyntax:
+ active: false
+ includeLineWrapping: false
+ ForbiddenAnnotation:
+ active: false
+ annotations:
+ - reason: 'it is a java annotation. Use `Suppress` instead.'
+ value: 'java.lang.SuppressWarnings'
+ - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
+ value: 'java.lang.Deprecated'
+ - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
+ value: 'java.lang.annotation.Documented'
+ - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
+ value: 'java.lang.annotation.Target'
+ - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
+ value: 'java.lang.annotation.Retention'
+ - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
+ value: 'java.lang.annotation.Repeatable'
+ - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
+ value: 'java.lang.annotation.Inherited'
+ ForbiddenComment:
+ active: true
+ comments:
+ - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
+ value: 'FIXME:'
+ - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
+ value: 'STOPSHIP:'
+ - reason: 'Forbidden TODO todo marker in comment, please do the changes.'
+ value: 'TODO:'
+ allowedPatterns: ''
+ ForbiddenImport:
+ active: false
+ imports: []
+ forbiddenPatterns: ''
+ ForbiddenMethodCall:
+ active: false
+ methods:
+ - reason: 'print does not allow you to configure the output stream. Use a logger instead.'
+ value: 'kotlin.io.print'
+ - reason: 'println does not allow you to configure the output stream. Use a logger instead.'
+ value: 'kotlin.io.println'
+ ForbiddenSuppress:
+ active: false
+ rules: []
+ ForbiddenVoid:
+ active: true
+ ignoreOverridden: false
+ ignoreUsageInGenerics: false
+ FunctionOnlyReturningConstant:
+ active: true
+ ignoreOverridableFunction: true
+ ignoreActualFunction: true
+ excludedFunctions: []
+ LoopWithTooManyJumpStatements:
+ active: true
+ maxJumpCount: 1
+ MagicNumber:
+ active: true
+ excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts']
+ ignoreNumbers:
+ - '-1'
+ - '0'
+ - '1'
+ - '2'
+ ignoreHashCodeFunction: true
+ ignorePropertyDeclaration: false
+ ignoreLocalVariableDeclaration: false
+ ignoreConstantDeclaration: true
+ ignoreCompanionObjectPropertyDeclaration: true
+ ignoreAnnotation: false
+ ignoreNamedArgument: true
+ ignoreEnums: false
+ ignoreRanges: false
+ ignoreExtensionFunctions: true
+ MandatoryBracesLoops:
+ active: false
+ MaxChainedCallsOnSameLine:
+ active: false
+ maxChainedCalls: 5
+ MaxLineLength:
+ active: true
+ maxLineLength: 180
+ excludePackageStatements: true
+ excludeImportStatements: true
+ excludeCommentStatements: false
+ excludeRawStrings: true
+ MayBeConst:
+ active: true
+ ModifierOrder:
+ active: true
+ MultilineLambdaItParameter:
+ active: false
+ MultilineRawStringIndentation:
+ active: false
+ indentSize: 4
+ trimmingMethods:
+ - 'trimIndent'
+ - 'trimMargin'
+ NestedClassesVisibility:
+ active: true
+ NewLineAtEndOfFile:
+ active: true
+ NoTabs:
+ active: false
+ NullableBooleanCheck:
+ active: false
+ ObjectLiteralToLambda:
+ active: true
+ OptionalAbstractKeyword:
+ active: true
+ OptionalUnit:
+ active: false
+ PreferToOverPairSyntax:
+ active: false
+ ProtectedMemberInFinalClass:
+ active: true
+ RedundantExplicitType:
+ active: false
+ RedundantHigherOrderMapUsage:
+ active: true
+ RedundantVisibilityModifierRule:
+ active: false
+ ReturnCount:
+ active: true
+ max: 2
+ excludedFunctions:
+ - 'equals'
+ excludeLabeled: false
+ excludeReturnFromLambda: true
+ excludeGuardClauses: false
+ SafeCast:
+ active: true
+ SerialVersionUIDInSerializableClass:
+ active: true
+ SpacingBetweenPackageAndImports:
+ active: false
+ StringShouldBeRawString:
+ active: false
+ maxEscapedCharacterCount: 2
+ ignoredCharacters: []
+ ThrowsCount:
+ active: true
+ max: 2
+ excludeGuardClauses: false
+ TrailingWhitespace:
+ active: false
+ TrimMultilineRawString:
+ active: false
+ trimmingMethods:
+ - 'trimIndent'
+ - 'trimMargin'
+ UnderscoresInNumericLiterals:
+ active: false
+ acceptableLength: 4
+ allowNonStandardGrouping: false
+ UnnecessaryAbstractClass:
+ active: true
+ UnnecessaryAnnotationUseSiteTarget:
+ active: false
+ UnnecessaryApply:
+ active: true
+ UnnecessaryBackticks:
+ active: false
+ UnnecessaryBracesAroundTrailingLambda:
+ active: false
+ UnnecessaryFilter:
+ active: true
+ UnnecessaryInheritance:
+ active: true
+ UnnecessaryInnerClass:
+ active: false
+ UnnecessaryLet:
+ active: false
+ UnnecessaryParentheses:
+ active: false
+ allowForUnclearPrecedence: false
+ UntilInsteadOfRangeTo:
+ active: false
+ UnusedImports:
+ active: false
+ UnusedParameter:
+ active: true
+ allowedNames: 'ignored|expected'
+ UnusedPrivateClass:
+ active: true
+ UnusedPrivateMember:
+ active: true
+ allowedNames: ''
+ UnusedPrivateProperty:
+ active: true
+ allowedNames: '_|ignored|expected|serialVersionUID'
+ UseAnyOrNoneInsteadOfFind:
+ active: true
+ UseArrayLiteralsInAnnotations:
+ active: true
+ UseCheckNotNull:
+ active: true
+ UseCheckOrError:
+ active: true
+ UseDataClass:
+ active: false
+ allowVars: false
+ UseEmptyCounterpart:
+ active: false
+ UseIfEmptyOrIfBlank:
+ active: false
+ UseIfInsteadOfWhen:
+ active: false
+ ignoreWhenContainingVariableDeclaration: false
+ UseIsNullOrEmpty:
+ active: true
+ UseLet:
+ active: false
+ UseOrEmpty:
+ active: true
+ UseRequire:
+ active: true
+ UseRequireNotNull:
+ active: true
+ UseSumOfInsteadOfFlatMapSize:
+ active: false
+ UselessCallOnNotNull:
+ active: true
+ UtilityClassWithPublicConstructor:
+ active: true
+ VarCouldBeVal:
+ active: true
+ ignoreLateinitVar: false
+ WildcardImport:
+ active: true
+ excludeImports:
+ - 'java.util.*'
diff --git a/docs/app-masking-feature-action-plan.md b/docs/app-masking-feature-action-plan.md
new file mode 100644
index 000000000..31e06733b
--- /dev/null
+++ b/docs/app-masking-feature-action-plan.md
@@ -0,0 +1,47 @@
+## App masking feature for Save app — action plan
+
+### 1. Current context
+- Entry point already exposed in `SettingsFragment` under Security, directly beneath “Set up passcode”.
+- The previous `AppMaskingActivity` (checkbox toggles) will be removed; the screen will instead be provided as a Compose destination in `app_nav_graph` (hosted by `SpaceSetupActivity`).
+- Passcode is optional, so masking cannot depend on lock verification.
+
+### 2. Immediate implementation goals (Compose rewrite)
+1. **Navigation migration**
+ - Remove the legacy `AppMaskingActivity`.
+ - Add a Compose destination entry (``) to `app_nav_graph.xml` and a matching enum in `StartDestination`.
+ - Update `SpaceSetupActivity` to accept a new start destination for app masking and launch this nav graph when the Settings preference is tapped.
+2. **Mask catalog**
+ - Provide four selectable personas backed by data class `AppMask`:
+ - `Save (default)` – standard icon and label.
+ - `QuickCalc` – calculator disguise.
+ - `LexiNote` – dictionary disguise.
+ - `Daylight Planner` – calendar disguise.
+ - Each entry carries alias name, icon, short label, description, and confirmation copy.
+3. **Layout & interactions**
+ - Display mask cards in a vertical list (LazyColumn) with icon, title, subtitle, and status chip (“Active”).
+ - Tapping a card opens a confirmation sheet/modal (“Use QuickCalc appearance?”).
+ - Confirmation triggers alias switch using `AppMaskingUtils.setLauncherActivityAlias` and shows inline progress indicator for a brief moment.
+ - After success, show toast/snackbar “Appearance updated” and close activity with `finishAffinity()` to restart entry point smoothly.
+4. **Utilities & state**
+ - Extend `AppMaskingUtils` to expose `availableMasks(context)` and `getCurrentMask()` wrappers.
+ - Persist currently applied mask metadata for UI state (alias + friendly name).
+ - Ensure manifest defines four `` entries with matching labels/icons.
+5. **UX polish**
+ - Add header text explaining limitation (“Appearance change may still show Save in system settings”).
+ - Provide `Use this look` button within each card when inactive; disabled `Current look` chip when active.
+ - Progress overlay uses Compose `Dialog` or full-width `LinearProgressIndicator` to keep transition smooth.
+
+### 3. Risks & mitigations
+- **Restart confusion**: clearly message that Save will restart; call `finishAffinity()` after switch.
+- **Alias mismatch**: guard alias change result and show error snackbar if PackageManager throws.
+- **Accessibility**: ensure cards are focusable, large touch targets, TalkBack-friendly labels describing new appearance.
+
+### 4. Next step (enhanced redesign direction)
+Once the Compose MVP ships, explore a richer disguise experience that feels unique to Save:
+1. **Persona gallery** – introduce storytelling cards with subtle animations, showing context screenshots (e.g., a mock calculator keypad) and allowing preview before confirmation.
+2. **Quick toggles** – pin the active mask in Settings summary with one-tap switcher chip to revert to Save without leaving Settings.
+3. **Smart reminders** – after enabling a disguise, offer optional prompt to set/verify passcode for increased privacy (non-blocking).
+4. **Custom sets** – allow users to combine icon + label + accent color (within pre-approved safe set) to craft their own believable disguise, ensuring differentiation from other apps.
+5. **Automation hooks** – integrate with panic mode/quick exit so that activating a panic trigger can automatically fall back to the default identity or switch to the calmest persona.
+
+This two-stage approach keeps the first release focused and achievable (Compose rewrite with 4 masks) while paving the way for a distinct, advanced masking experience tailored to Save. Once you confirm this plan, I can start implementing the Compose migration and mask catalog.
diff --git a/fastlane/.env.example b/fastlane/.env.example
index e0ffb28a2..95cd26e4b 100644
--- a/fastlane/.env.example
+++ b/fastlane/.env.example
@@ -1,4 +1,4 @@
# Add your SLACK_URL & ACCESS_TOKEN HERE
SLACK_URL="https://hooks.slack.com/services/..."
-ACCESS_TOKEN = "xoxp-106043..."
\ No newline at end of file
+ACCESS_TOKEN="xoxp-106043..."
\ No newline at end of file
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index ae6145983..51d466584 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -22,6 +22,13 @@ platform :android do
# )
# end
+ # Lane for detekt
+ desc "Run Detekt for code analysis"
+ lane :detekt do
+ gradle(task: "detekt")
+ end
+
+
desc "Runs all the tests"
lane :test do
gradle(task: "test")
diff --git a/fastlane/README.md b/fastlane/README.md
index 855be4ccc..4f77c842e 100644
--- a/fastlane/README.md
+++ b/fastlane/README.md
@@ -15,6 +15,14 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## Android
+### android detekt
+
+```sh
+[bundle exec] fastlane android detekt
+```
+
+Run Detekt for code analysis
+
### android test
```sh
diff --git a/fastlane/report.xml b/fastlane/report.xml
index 9b2b379cd..322259278 100644
--- a/fastlane/report.xml
+++ b/fastlane/report.xml
@@ -5,19 +5,12 @@
-
+
-
-
-
-
-
-
-
-
+
diff --git a/gradle.properties b/gradle.properties
index a4059be70..0bf745de8 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -22,3 +22,5 @@ android.enableJetifier=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
+# Navigation Safe Args 2.9.6 is not configuration cache compatible under Gradle 9 (b/443261197)
+org.gradle.configuration-cache=false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 000000000..53dd7b367
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,243 @@
+[versions]
+activity = "1.12.0"
+agp = "8.13.1"
+androidx-exifinterface = "1.4.1"
+androidx-security-crypto = "1.1.0"
+androidx-test-runner = "1.7.0"
+appcompat = "1.7.1"
+biometric = "1.1.0"
+bitcoinj-core = "0.16.2"
+bouncycastle-bcpg = "1.71"
+bouncycastle-bcpkix = "1.72"
+bouncycastle-bcprov = "1.72"
+camerax = "1.5.1"
+clean-insights = "2.8.0"
+coil = "3.3.0"
+compose = "1.9.5"
+compose-material-icons = "1.7.8"
+compose-preference = "1.1.1"
+constraintlayout = "2.2.1"
+constraintlayout-compos = "1.1.1"
+coordinatorlayout = "1.3.0"
+core-ktx = "1.17.0"
+core-splashscreen = "1.2.0"
+coroutines = "1.10.2"
+detekt = "1.23.8"
+detekt-compose = "0.4.28"
+detekt-rules-compose = "1.4.0"
+dotsindicator = "5.1.0"
+espresso-core = "3.5.1"
+firebase-crashlytics = "20.0.3"
+fragment = "1.8.9"
+google-api-client-android = "1.26.0"
+google-firebase-crashlytics = "3.0.6"
+google-gms-auth = "21.4.0"
+google-gms-google-services = "4.4.4"
+google-http-client-gson = "1.42.3"
+google-play-app-update-ktx = "2.1.0"
+google-play-asset-delivery-ktx = "2.3.0"
+google-play-feature-delivery = "2.1.0"
+google-play-review = "2.0.2"
+gson = "2.13.2"
+guava = "31.0.1-jre"
+guava-listenablefuture = "9999.0-empty-to-avoid-conflict-with-guava"
+j2v8 = "6.2.1@aar"
+jtorctl = "0.4.5.7"
+junit = "4.13.2"
+junit-android = "1.3.0"
+koin = "4.2.0-alpha2"
+kotlin = "2.2.21"
+ksp = "2.3.3"
+lifecycle = "2.10.0"
+material = "1.13.0"
+material3 = "1.4.0"
+media3 = "1.8.0"
+mixpanel = "8.2.4"
+navigation = "2.9.6"
+navigation3 = "1.0.0"
+netcipher = "2.2.0-alpha"
+okhttp = "4.12.0"
+permissionx = "1.8.1"
+preference = "1.2.1"
+proofmode = "1.0.30"
+recyclerview = "1.4.0"
+recyclerview-selection = "1.2.0"
+retrofit = "3.0.0"
+robolectric = "4.16"
+satyan-sugar = "1.5"
+serialization = "1.9.0"
+swiperefreshlayout = "1.1.0"
+timber = "5.0.1"
+tor-android = "0.4.8.19"
+viewpager2 = "1.1.0"
+work = "2.11.0"
+
+[libraries]
+# AndroidX - Activity
+androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
+
+# AndroidX - Biometric
+androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
+
+# AndroidX - Camera
+androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
+androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
+androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
+androidx-camera-video = { group = "androidx.camera", name = "camera-video", version.ref = "camerax" }
+androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
+androidx-camera-extensions = { group = "androidx.camera", name = "camera-extensions", version.ref = "camerax" }
+
+# AndroidX - Compose
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
+androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "compose" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
+androidx-compose-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "compose-material-icons" }
+androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "compose" }
+androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "compose" }
+
+# AndroidX - Core
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
+androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "core-splashscreen" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidx-exifinterface" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintlayout-compos" }
+androidx-coordinatorlayout = { group = "androidx.coordinatorlayout", name = "coordinatorlayout", version.ref = "coordinatorlayout" }
+androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
+androidx-recyclerview-selection = { group = "androidx.recyclerview", name = "recyclerview-selection", version.ref = "recyclerview-selection" }
+androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" }
+androidx-swiperefresh = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" }
+
+# AndroidX - Fragment
+androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
+androidx-fragment-compose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "fragment" }
+
+# AndroidX - Lifecycle
+androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
+
+# AndroidX - Media
+androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
+androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
+
+# AndroidX - Navigation
+androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
+androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
+androidx-navigation-fragment-compose = { group = "androidx.navigation", name = "navigation-fragment-compose", version.ref = "navigation" }
+
+#AndroidX - Navigation 3
+androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
+androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
+androidx-navigationevent = { module = "androidx.navigationevent:navigationevent", version.ref = "navigation3" }
+
+# AndroidX - Other
+androidx-preferences = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference" }
+androidx-security-crypto = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "androidx-security-crypto" }
+androidx-work = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
+
+# AndroidX - Testing
+androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit-android" }
+androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" }
+
+# Compose - Third Party
+compose-preferences = { group = "me.zhanghai.compose.preference", name = "library", version.ref = "compose-preference" }
+
+# Detekt
+detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" }
+detekt-rules-libraries = { group = "io.gitlab.arturbosch.detekt", name = "detekt-rules-libraries", version.ref = "detekt" }
+detekt-rules-authors = { group = "io.gitlab.arturbosch.detekt", name = "detekt-rules-ruleauthors", version.ref = "detekt" }
+detekt-compose = { group = "io.nlopez.compose.rules", name = "detekt", version.ref = "detekt-compose" }
+detekt-rules-compose = { group = "ru.kode", name = "detekt-rules-compose", version.ref = "detekt-rules-compose" }
+
+# Firebase
+firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebase-crashlytics" }
+
+# Google - APIs
+#google-api-client-android = { group = "com.google.api-client", name = "google-api-client-android", version.ref = "google-api-client-android" }
+#google-http-client-gson = { group = "com.google.http-client", name = "google-http-client-gson", version.ref = "google-http-client-gson" }
+#google-drive-api = { group = "com.google.apis", name = "google-api-services-drive", version = "v3-rev136-1.25.0" }
+
+# Google - Material Design
+google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+
+# Google - Play Services
+#google-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "google-gms-auth" }
+#google-play-asset-delivery-ktx = { group = "com.google.android.play", name = "asset-delivery-ktx", version.ref = "google-play-asset-delivery-ktx" }
+#google-play-feature-delivery = { group = "com.google.android.play", name = "feature-delivery", version.ref = "google-play-feature-delivery" }
+#google-play-feature-delivery-ktx = { group = "com.google.android.play", name = "feature-delivery-ktx", version.ref = "google-play-feature-delivery" }
+google-play-review = { group = "com.google.android.play", name = "review", version.ref = "google-play-review" }
+google-play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "google-play-review" }
+google-play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "google-play-app-update-ktx" }
+
+# Images
+coil = { group = "io.coil-kt.coil3", name = "coil", version.ref = "coil" }
+coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
+coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
+coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
+
+# Koin - Dependency Injection
+koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" }
+koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
+koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
+koin-androidx-navigation = { group = "io.insert-koin", name = "koin-androidx-navigation", version.ref = "koin" }
+koin-compose = { group = "io.insert-koin", name = "koin-compose", version.ref = "koin" }
+koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin" }
+koin-compose-viewmodel-navigation = { group = "io.insert-koin", name = "koin-compose-viewmodel-navigation", version.ref = "koin" }
+koin-compose-navigation3 = { group = "io.insert-koin", name = "koin-compose-navigation3", version.ref = "koin" }
+
+# Kotlin
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
+
+# Networking
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
+guardianproject-sardine = { group = "com.github.guardianproject", name = "sardine-android", version = "89f7eae512" }
+
+# Security & Cryptography
+bouncycastle-bcprov = { group = "org.bouncycastle", name = "bcprov-jdk15to18", version.ref = "bouncycastle-bcprov" }
+bouncycastle-bcpkix = { group = "org.bouncycastle", name = "bcpkix-jdk15to18", version.ref = "bouncycastle-bcpkix" }
+bouncycastle-bcpg = { group = "org.bouncycastle", name = "bcpg-jdk15to18", version.ref = "bouncycastle-bcpg" }
+
+# Testing
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
+work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
+
+# Third-Party Libraries
+bitcoinj-core = { group = "org.bitcoinj", name = "bitcoinj-core", version.ref = "bitcoinj-core" }
+clean-insights = { group = "org.cleaninsights.sdk", name = "clean-insights-sdk", version.ref = "clean-insights" }
+dotsindicator = { group = "com.tbuonomo", name = "dotsindicator", version.ref = "dotsindicator" }
+gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
+guava = { group = "com.google.guava", name = "guava", version.ref = "guava" }
+guava-listenablefuture = { group = "com.google.guava", name = "listenablefuture", version.ref = "guava-listenablefuture" }
+j2v8 = { group = "com.eclipsesource.j2v8", name = "j2v8", version.ref = "j2v8" }
+jtorctl = { group = "info.guardianproject", name = "jtorctl", version.ref = "jtorctl" }
+mixpanel = { group = "com.mixpanel.android", name = "mixpanel-android", version.ref = "mixpanel" }
+netcipher = { group = "info.guardianproject.netcipher", name = "netcipher", version.ref = "netcipher" }
+permissionx = { group = "com.guolindev.permissionx", name = "permissionx", version.ref = "permissionx" }
+proofmode = { group = "org.proofmode", name = "android-libproofmode", version.ref = "proofmode" }
+satyan-sugar = { group = "com.github.satyan", name = "sugar", version.ref = "satyan-sugar" }
+timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
+tor-android = { group = "info.guardianproject", name = "tor-android", version.ref = "tor-android" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+detekt-plugin = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
+google-firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "google-firebase-crashlytics" }
+google-gms-google-services = { id = "com.google.gms.google-services", version.ref = "google-gms-google-services" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref = "navigation" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index d89419cc4..bbdfa7c0d 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-all.zip
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index abaf95ae8..e657fe635 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -86,7 +86,6 @@ dependencyResolutionManagement {
maven("https://jitpack.io") {
content {
includeModule("com.github.esafirm", "android-image-picker")
- includeModule("com.github.derlio", "audio-waveform")
includeModule("com.github.abdularis", "circularimageview")
includeModule("com.github.guardianproject", "sardine-android")
}