diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 42c583e1..00000000 --- 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 974e48a9..89690488 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@v4 - # 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@v4 + 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@v3 + 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 7c0199cf..48fdc122 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,8 @@ 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/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 823bbcdc..904641e0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,6 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import java.io.FileInputStream import java.util.Properties plugins { @@ -6,29 +9,62 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.serialization") id("com.google.devtools.ksp") + id("androidx.navigation.safeargs.kotlin") + 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 { - 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 = 30018 + 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 +83,44 @@ android { signingConfig = signingConfigs.getByName("debug") isMinifyEnabled = false isShrinkResources = false - applicationIdSuffix = ".release" proguardFiles(getDefaultProguardFile("proguard-android.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,6 +139,12 @@ android { abortOnError = false } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + namespace = "net.opendasharchive.openarchive" configurations.all { @@ -100,119 +157,130 @@ 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") + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) // 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") + implementation(libs.androidx.appcompat) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.viewpager2) + implementation(libs.androidx.recyclerview.selection) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.constraintlayout.compose) + implementation(libs.androidx.coordinatorlayout) + implementation(libs.androidx.core.splashscreen) + + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.lifecycle.runtime.compose) + + implementation(libs.androidx.preferences) + implementation(libs.androidx.biometric) + implementation(libs.androidx.work) + implementation(libs.androidx.security.crypto) + + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.fragment.compose) // Compose Preferences - implementation("me.zhanghai.compose.preference:library:1.1.1") + implementation(libs.compose.preferences) // Material Design - implementation("com.google.android.material:material:$material") + implementation(libs.google.material) // AndroidX SwipeRefreshLayout - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01") + implementation(libs.androidx.swiperefresh) // 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") + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.icons.extended) + implementation(libs.firebase.crashlytics) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.runtime.livedata) // Navigation - implementation("androidx.navigation:navigation-compose:2.8.6") + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation.ui) + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.fragment.compose) // Preference - implementation("androidx.preference:preference-ktx:1.2.1") + implementation(libs.androidx.preferences) // 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") + 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) // 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") + implementation(libs.coil) + implementation(libs.coil.compose) + implementation(libs.coil.video) + implementation(libs.coil.network) // Networking and Data // 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") + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.guardianproject.sardine) // 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") + implementation(libs.timber) + //implementation(libs.orhanobut.logger) + //implementation(libs.abdularis.circularimageview) + implementation(libs.dotsindicator) + implementation(libs.permissionx) // Barcode Scanning - implementation("com.google.zxing:core:3.4.1") - implementation("com.journeyapps:zxing-android-embedded:4.2.0") + //implementation("com.google.zxing:core:3.5.3") + //implementation("com.journeyapps:zxing-android-embedded:4.3.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.bouncycastle.bcpkix) + implementation(libs.bouncycastle.bcprov) + api(libs.bouncycastle.bcpg) // 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") + implementation(libs.google.http.client.gson) + implementation(libs.google.api.client.android) + implementation(libs.google.drive.api) // Tor Libraries - implementation("info.guardianproject:tor-android:0.4.7.14") - implementation("info.guardianproject:jtorctl:0.4.5.7") + implementation(libs.tor.android) + implementation(libs.jtorctl) - implementation("org.bitcoinj:bitcoinj-core:0.16.2") - implementation("com.eclipsesource.j2v8:j2v8:6.2.1@aar") + implementation(libs.bitcoinj.core) + //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") { + implementation(libs.proofmode) { //transitive = false exclude(group = "org.bitcoinj") exclude(group = "com.google.protobuf") @@ -227,40 +295,51 @@ dependencies { } // 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") + implementation(libs.guava) + implementation(libs.guava.listenablefuture) + implementation(libs.satyan.sugar) // 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(libs.clean.insights) + implementation(libs.netcipher) - implementation("org.cleaninsights.sdk:clean-insights-sdk:2.8.0") - implementation("info.guardianproject.netcipher:netcipher:2.2.0-alpha") - + // Mixpanel analytics + implementation(libs.mixpanel) // 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") + testImplementation(libs.junit) + testImplementation(libs.robolectric) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.runner) + testImplementation(libs.work.testing) + + // Detekt + detektPlugins(libs.detekt.formatting) + detektPlugins(libs.detekt.rules.authors) + detektPlugins(libs.detekt.rules.libraries) + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.rules.compose) + +// debugImplementation("com.squareup.leakcanary:leakcanary-android:3.0-alpha-8") } 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 +} \ No newline at end of file diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml new file mode 100644 index 00000000..e23be966 --- /dev/null +++ b/app/detekt-baseline.xml @@ -0,0 +1,1922 @@ + + + + + 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:GDriveActivity.kt$net.opendasharchive.openarchive.services.gdrive.GDriveActivity.kt + FinalNewline:GDriveConduit.kt$net.opendasharchive.openarchive.services.gdrive.GDriveConduit.kt + FinalNewline:GDriveFragment.kt$net.opendasharchive.openarchive.services.gdrive.GDriveFragment.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.GDRIVE$4 + 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 00000000..a2ba8be0 --- /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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2459bf39..e3c4c0fe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,7 @@ - + @@ -31,6 +31,10 @@ + + + + + + + + @@ -93,37 +101,95 @@ + + + + + + + + + + + + + + + + + + + android:name=".features.main.HomeActivity" + android:exported="true" + android:screenOrientation="portrait"> + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -131,12 +197,6 @@ android:name=".services.gdrive.GDriveActivity" android:theme="@style/SaveAppTheme.NoActionBar" /> - - - + + + + + + android:theme="@style/SaveAppTheme.NoActionBar.Onboarding" /> - + + + + + - + android:exported="false" + android:foregroundServiceType="dataSync" /> diff --git a/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt index 9428aee2..2d7e2df5 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 e99f921a..e752e6b0 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt @@ -2,35 +2,53 @@ 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) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val uiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager + val darkMode = if (useDarkMode) UiModeManager.MODE_NIGHT_YES else UiModeManager.MODE_NIGHT_NO + uiModeManager.setApplicationNightMode(darkMode) + } else { + val darkMode = if (useDarkMode) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO + AppCompatDelegate.setDefaultNightMode(darkMode) + } + } + override fun onCreate() { super.onCreate() + Analytics.init(this) AppLogger.init(applicationContext, initDebugger = true) registerActivityLifecycleCallbacks(PasscodeManager()) startKoin { @@ -40,32 +58,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 +112,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 a9d91456..217a43d2 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,41 @@ package net.opendasharchive.openarchive.core.di +import android.content.Context +import com.google.api.services.drive.Drive +import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine +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 0d743787..2fecd113 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,8 @@ 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.snowbird.ISnowbirdFileRepository import net.opendasharchive.openarchive.services.snowbird.ISnowbirdGroupRepository import net.opendasharchive.openarchive.services.snowbird.ISnowbirdRepoRepository @@ -20,6 +13,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 +21,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 +34,7 @@ val featuresModule = module { viewModel { (application: Application) -> SnowbirdGroupViewModel(application, get()) } viewModel { (application: Application) -> SnowbirdFileViewModel(application, get()) } viewModel { (application: Application) -> SnowbirdRepoViewModel(application, get()) } + + + viewModelOf(::SpaceListViewModel) } \ 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 00000000..1282bc25 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/PasscodeModule.kt @@ -0,0 +1,48 @@ +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 + ) + } + + 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 923d98a7..6058e621 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 c7e2ff25..39612410 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 1efc7585..a608bd4c 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,10 +71,10 @@ 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 = c23_darker_medium_grey, outline = Color.Black, inverseOnSurface = Color.White, @@ -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, 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 c0261494..598566c0 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 @@ -35,6 +35,26 @@ fun DefaultScaffoldPreview( } +@Composable +fun DefaultEmptyScaffoldPreview( + content: @Composable () -> Unit +) { + + SaveAppTheme { + + Scaffold { paddingValues -> + + Box( + modifier = Modifier.Companion.padding(paddingValues), + contentAlignment = Alignment.Companion.Center + ) { + content() + } + } + } + +} + @Composable fun DefaultBoxPreview( content: @Composable () -> Unit 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 20c022d3..deb3a5d3 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 00000000..2306459e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Type.kt @@ -0,0 +1,78 @@ +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 net.opendasharchive.openarchive.R + +// Define Montserrat FontFamily +val MontserratFontFamily = FontFamily( + Font(R.font.montserrat_thin, FontWeight.Thin), // 100 + Font(R.font.montserrat_extra_light, FontWeight.ExtraLight), // 200 + Font(R.font.montserrat_light, FontWeight.Light), // 300 + Font(R.font.montserrat_regular, FontWeight.Normal), // 400 + Font(R.font.montserrat_medium, FontWeight.Medium), // 500 + Font(R.font.montserrat_semi_bold, FontWeight.SemiBold), // 600 + Font(R.font.montserrat_bold, FontWeight.Bold), // 700 + Font(R.font.montserrat_extra_bold, FontWeight.ExtraBold), // 800 + Font(R.font.montserrat_black, FontWeight.Black) // 900 +) + +// Define Montserrat Italic FontFamily +val MontserratItalicFontFamily = FontFamily( + Font(R.font.montserrat_thin_italic, FontWeight.Thin), // 100 + Font(R.font.montserrat_extra_light_italic, FontWeight.ExtraLight), // 200 + Font(R.font.montserrat_light_italic, FontWeight.Light), // 300 + Font(R.font.montserrat_italic, FontWeight.Normal), // 400 + Font(R.font.montserrat_medium_italic, FontWeight.Medium), // 500 + Font(R.font.montserrat_semi_bold_italic, FontWeight.SemiBold), // 600 + Font(R.font.montserrat_bold_italic, FontWeight.Bold), // 700 + Font(R.font.montserrat_extra_bold_italic, FontWeight.ExtraBold), // 800 + Font(R.font.montserrat_black_italic, FontWeight.Black) // 900 +) + + +val Typography = Typography( + headlineSmall = TextStyle( + fontFamily = MontserratFontFamily, + fontSize = 18.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold // 600 + ), + bodyLarge = TextStyle( + fontFamily = MontserratFontFamily, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.SemiBold // 600 + ), + bodyMedium = TextStyle( + fontFamily = MontserratFontFamily, + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium // 500 + ), + bodySmall = TextStyle( + fontFamily = MontserratFontFamily, + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Medium // 500 + ), + labelMedium = TextStyle( + fontFamily = MontserratFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium // 500 + ), + titleLarge = TextStyle( + fontFamily = MontserratFontFamily, + fontSize = 22.sp, // Adjust according to UI needs + fontWeight = FontWeight.Normal // Default for TitleLarge + ), + titleMedium = TextStyle( + fontFamily = MontserratFontFamily, + fontSize = 18.sp, // Adjust according to UI needs + fontWeight = FontWeight.Medium // 500 + ) +) 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 6301d2ff..41181342 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 4d1b82fd..00000000 --- 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 036dea04..00000000 --- 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 f9439ccb..fa938e78 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt @@ -6,10 +6,16 @@ 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.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +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 @@ -53,6 +59,7 @@ data class Space( name = IaConduit.NAME host = IaConduit.ARCHIVE_API_ENDPOINT } + Type.GDRIVE -> { name = GDriveConduit.NAME } @@ -62,10 +69,10 @@ data class Space( } 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 +98,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 +109,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 +125,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 +147,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 +172,97 @@ 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 + ).size > 0 } fun getAvatar(context: Context, style: IconStyle = IconStyle.SOLID): Drawable? { - val color = ContextCompat.getColor(context, R.color.colorOnBackground) + 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 + ) // ?.tint(color) - Type.INTERNET_ARCHIVE -> ContextCompat.getDrawable(context, R.drawable.ic_internet_archive) // ?.tint(color) + 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) + Type.GDRIVE -> ContextCompat.getDrawable( + context, + R.drawable.logo_gdrive_outline + ) // ?.tint(color) Type.RAVEN -> ContextCompat.getDrawable(context, R.drawable.snowbird) // ?.tint(color) - else -> BitmapDrawable(context.resources, DrawableUtil.createCircularTextDrawable(initial, color)) + else -> { + val color = ContextCompat.getColor(context, R.color.colorOnBackground) + BitmapDrawable( + context.resources, + DrawableUtil.createCircularTextDrawable(initial, color) + ) + } + + } + } + + @Composable + fun getAvatar(): Painter { + + return when (tType) { + Type.WEBDAV -> painterResource(R.drawable.ic_space_private_server) + + Type.INTERNET_ARCHIVE -> painterResource(R.drawable.ic_space_interent_archive) + + Type.GDRIVE -> painterResource(R.drawable.logo_gdrive_outline) + Type.RAVEN -> painterResource(R.drawable.ic_space_dweb) + null -> { + val context = LocalContext.current + val color = ContextCompat.getColor(context, R.color.colorOnBackground) + val bitmap = DrawableUtil.createCircularTextDrawable(initial, color) + val imageBitmap = bitmap.asImageBitmap() + BitmapPainter(imageBitmap) + } } } 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 00000000..c0751153 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaAdapter.kt @@ -0,0 +1,261 @@ +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 com.google.android.material.snackbar.Snackbar +import net.opendasharchive.openarchive.R +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 00000000..e75909ee --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/db/UploadMediaViewHolder.kt @@ -0,0 +1,205 @@ +package net.opendasharchive.openarchive.db + +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.crossfade +import coil3.request.placeholder +import com.github.derlio.waveform.soundfile.SoundFile +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.RvMediaRowSmallBinding +import net.opendasharchive.openarchive.util.extensions.hide +import net.opendasharchive.openarchive.util.extensions.show +import timber.log.Timber +import java.io.InputStream + +class UploadMediaViewHolder( + private val binding: RvMediaRowSmallBinding, + private val onDeleteClick: (Int) -> Unit +) : RecyclerView.ViewHolder(binding.root) { + + + companion object { + val soundCache = HashMap() + } + + + private val mContext = itemView.context + + 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 + + binding.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() + + binding.image.apply { + scaleType = ImageView.ScaleType.CENTER_CROP + show() + load(media.fileUri) { + placeholder(progress) + crossfade(true) + } + } + binding.waveform.hide() + } else if (media?.mimeType?.startsWith("video") == true) { + val progress = CircularProgressDrawable(mContext) + progress.strokeWidth = 5f + progress.centerRadius = 30f + progress.start() + binding.image.apply { + scaleType = ImageView.ScaleType.CENTER_CROP + show() + load(media.fileUri) { + placeholder(progress) + } + } + binding.waveform.hide() + } else if (media?.mimeType?.startsWith("audio") == true) { + + val soundFile = soundCache[media.originalFilePath] + + if (soundFile != null) { + binding.image.hide() + binding.waveform.setAudioFile(soundFile) + binding.waveform.show() + } else { + binding.image.setImageDrawable( + ContextCompat.getDrawable( + mContext, + R.drawable.no_thumbnail + ) + ) + binding.image.scaleType = ImageView.ScaleType.CENTER_CROP + binding.image.show() + binding.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 { + binding.waveform.setAudioFile(soundFile) + binding.image.hide() + binding.waveform.show() + } + } + } + } + } else { + binding.image.setImageDrawable( + ContextCompat.getDrawable( + mContext, + R.drawable.ic_unknown_file + ) + ) + binding.image.scaleType = ImageView.ScaleType.CENTER_INSIDE + binding.image.show() + binding.waveform.hide() + } + + 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()) { + binding.title.text = sbTitle.toString() + binding.title.show() + } else { + binding.title.hide() + } + } +} 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 00000000..6a9f4f18 --- /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 1b367e5c..58724054 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 00000000..3037a3da --- /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 06d1a955..d36f872e 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 00000000..c5d78c22 --- /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 00000000..ad69572e --- /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 00000000..fa4f865e --- /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/ToolbarConfigurable.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/ToolbarConfigurable.kt new file mode 100644 index 00000000..403a6750 --- /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 00000000..04e5a5f4 --- /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 00000000..68bf024f --- /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 00000000..b1dd87f1 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/BaseDialog.kt @@ -0,0 +1,332 @@ +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.headlineSmall.copy( + fontSize = 18.sp, + 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 = "Success", + message = "You have added a folder successfully", + positiveButton = ButtonData(UiText.DynamicString("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 00000000..d5027c04 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/dialog/DialogConfigBuilder.kt @@ -0,0 +1,427 @@ +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.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.primary + } + 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.colorPrimary) + } + 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 = "", + onRetry: () -> Unit = {}, + onCancel: () -> 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.retry) + action = onRetry + } + + neutralButton { + text = UiText.StringResource(R.string.lbl_Cancel) + action = onCancel + } + } +} + +// 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_got_it) + 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 index 12dd7285..f04c5ee0 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderActivity.kt @@ -3,14 +3,13 @@ package net.opendasharchive.openarchive.features.folders import android.content.Intent import android.os.Bundle import android.view.MenuItem +import androidx.activity.compose.setContent 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.core.presentation.theme.SaveAppTheme 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() { @@ -19,7 +18,6 @@ class AddFolderActivity : BaseActivity() { const val EXTRA_FOLDER_NAME = "folder_name" } - private lateinit var mBinding: ActivityAddFolderBinding private lateinit var mResultLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { @@ -34,7 +32,7 @@ class AddFolderActivity : BaseActivity() { val name = it.data?.getStringExtra(EXTRA_FOLDER_NAME) if (!name.isNullOrBlank()) { - val i = Intent(this, CreateNewFolderActivity::class.java) + val i = Intent(this, CreateNewFolderFragment::class.java) i.putExtra(EXTRA_FOLDER_NAME, name) mResultLauncher.launch(i) @@ -42,27 +40,35 @@ class AddFolderActivity : BaseActivity() { } } - mBinding = ActivityAddFolderBinding.inflate(layoutInflater) - setContentView(mBinding.root) + //mBinding = ActivityAddFolderBinding.inflate(layoutInflater) + //setContentView(mBinding.root) - setupToolbar( - title = getString(R.string.add_a_folder), - showBackButton = true - ) - mBinding.addFolderContainer.setOnClickListener { - setFolder(false) - } + setContent { + + SaveAppTheme { - mBinding.browseFolderContainer.setOnClickListener { - setFolder(true) + AddFolderScreen( +// onCreateFolder = { +// setFolder(browse = false) +// }, +// onBrowseFolders = { +// setFolder(browse = true) +// }, +// onNavigateBack = { +// finish() +// } + ) + } } + + // 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() + //mBinding.browseFolderContainer.hide() finish() setFolder(false) @@ -92,7 +98,7 @@ class AddFolderActivity : BaseActivity() { mResultLauncher.launch( Intent( this, - if (browse) BrowseFoldersActivity::class.java else CreateNewFolderActivity::class.java + if (browse) BrowseFoldersFragment::class.java else CreateNewFolderFragment::class.java ) ) } 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 00000000..f7dd6932 --- /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.onBackground) + ) { + 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 00000000..67dd1470 --- /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 a04be3d3..00000000 --- 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 cc86d19b..699fb3ca 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 00000000..3539da52 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersFragment.kt @@ -0,0 +1,148 @@ +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.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.applyEdgeToEdgeInsets( +// typeMask = WindowInsetsCompat.Type.navigationBars() +// ) { insets -> +// bottomMargin = insets.bottom +// } + + 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(AddFolderActivity.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 3eb5945b..0f1055df 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 @@ -15,9 +15,11 @@ 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 +28,7 @@ class BrowseFoldersViewModel : ViewModel() { val progressBarFlag = MutableLiveData(false) - fun getFiles(context: Context, space: Space) { + fun getFiles(space: Space) { viewModelScope.launch { progressBarFlag.value = true 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 ea764496..00000000 --- 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 00000000..62bdbdb0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderFragment.kt @@ -0,0 +1,158 @@ +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.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(AddFolderActivity.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(AddFolderActivity.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 a7c18b17..f958fad9 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/presentation/InternetArchiveActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt deleted file mode 100644 index 05b47be5..00000000 --- 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 5aeff9b9..00000000 --- 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 4429b4cd..00000000 --- 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 177d5615..00000000 --- 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/components/InternetArchiveHeader.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/InternetArchiveHeader.kt index bd53c808..3885aec2 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/components/InternetArchiveHeader.kt @@ -18,6 +18,7 @@ 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 @@ -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, ) } } 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 b05abbbc..4a23c011 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,87 @@ 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.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 +110,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 +130,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 +137,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.remove) + action = { + onAction(InternetArchiveDetailsAction.Remove) + } + } + + neutralButton { + text = UiText.StringResource(R.string.action_cancel) + action = { + //dismiss + } + } + } + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text( + stringResource(id = R.string.remove_from_app), + fontSize = 18.sp + ) + } } - }, 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 d81558c9..72682a29 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 b5a267c2..de56c7a8 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/login/InternetArchiveLoginScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginScreen.kt index dd6b636d..259afc1f 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,37 +1,49 @@ 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.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api 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 @@ -43,8 +55,12 @@ 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 +70,151 @@ 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.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.core.BaseFragment +import net.opendasharchive.openarchive.features.core.ToolbarConfigurable +import net.opendasharchive.openarchive.features.core.UiText 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.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) - } + val context = LocalContext.current LaunchedEffect(state.isLoginError) { while (state.isLoginError) { delay(3000) - dispatch(Action.ErrorClear) + onAction(InternetArchiveLoginAction.ErrorClear) } } 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, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + } + } + CustomTextField( value = state.username, - onValueChange = { dispatch(UpdateUsername(it)) }, + onValueChange = { 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,43 +225,54 @@ private fun InternetArchiveLoginContent( CustomSecureField( value = state.password, - onValueChange = { dispatch(UpdatePassword(it)) }, + onValueChange = { 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 + color = ThemeColors.material.onBackground, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp ) 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, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, style = MaterialTheme.typography.bodyLarge ) } @@ -195,31 +281,50 @@ 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)) } + 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.extra_light_grey)//MaterialTheme.colorScheme.onBackground + ), + 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)) } } } @@ -227,14 +332,18 @@ 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" - ) - ) {} + username = "", + password = "", + isLoginError = true + ), + onAction = {} + ) } } @@ -254,7 +363,7 @@ fun ComposeAppBar( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, + containerColor = MaterialTheme.colorScheme.tertiary, navigationIconContentColor = Color.White, titleContentColor = Color.White, actionIconContentColor = Color.White @@ -276,17 +385,19 @@ fun CustomTextField( imeAction: ImeAction = ImeAction.Next, ) { - TextField( + OutlinedTextField( modifier = modifier.fillMaxWidth(), value = value, - enabled = !isLoading, + enabled = !isLoading && enabled, onValueChange = onValueChange, - label = { - Text(label) - }, placeholder = { placeholder?.let { - Text(placeholder) + Text( + text = placeholder, + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Italic + ) } }, singleLine = true, @@ -301,9 +412,13 @@ fun CustomTextField( hintLocales = null ), 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, ), ) } @@ -325,16 +440,18 @@ fun CustomSecureField( mutableStateOf(false) } - TextField( + OutlinedTextField( modifier = modifier.fillMaxWidth(), value = value, enabled = !isLoading, onValueChange = onValueChange, - label = { - Text(label) - }, placeholder = { - Text(placeholder) + Text( + text = placeholder, + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Italic + ) }, singleLine = true, shape = RoundedCornerShape(ThemeDimensions.roundedCorner), @@ -349,12 +466,17 @@ 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 }) { Icon( @@ -365,3 +487,60 @@ fun CustomSecureField( }, ) } + + +@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()) + } + } + } +} \ No newline at end of file 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 12bdc378..e4d3aab2 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 a04aee8a..94f1186f 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) } + } } } + private fun performLogin() { + _uiState.update { it.copy(isBusy = true) } + viewModelScope.launch { + val currentState = _uiState.value + loginUseCase(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, 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 00000000..5b215b85 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt @@ -0,0 +1,220 @@ +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 org.koin.androidx.viewmodel.ext.android.viewModel +import timber.log.Timber + +class HomeActivity: FragmentActivity() { + + 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 -> { + // Launch the camera using Picker. + Picker.takePhotoModern(this, 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/MainActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt index f3580fdd..8d28e7e5 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,31 +1,61 @@ package net.opendasharchive.openarchive.features.main -import android.Manifest +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.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +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.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.folders.AddFolderActivity +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 @@ -34,47 +64,67 @@ import net.opendasharchive.openarchive.features.media.Picker import net.opendasharchive.openarchive.features.media.PreviewActivity 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 currentSelectionCount = 0 + + 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) { @@ -82,271 +132,670 @@ class MainActivity : BaseActivity() { } } - 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 + + override fun onCreate(savedInstanceState: Bundle?) { +// enableEdgeToEdge() + super.onCreate(savedInstanceState) +// WindowCompat.setDecorFitsSystemWindows(window, false) + installSplashScreen() + + + +// 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 { 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() + + + if (appConfig.isDwebEnabled) { + permissionManager.checkNotificationPermission { + AppLogger.i("Notification permission granted") } + SnowbirdBridge.getInstance().initialize() + startForegroundService(Intent(this, SnowbirdService::class.java)) + handleIntent(intent) } - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (isGranted) { - Timber.d("Able to post notifications") - } else { - Timber.d("Need to explain") + 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() } + override fun onResume() { + super.onResume() + if (!Prefs.didCompleteOnboarding) { + startActivity(Intent(this, Onboarding23Activity::class.java)) + } + 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 + } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - installSplashScreen() + // ───────────────────────────────────────────────────────────────────────── + // 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 + } + } + // ───────────────────────────────────────────────────────────────────────── + } - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + override fun onStart() { + super.onStart() + + ProofModeHelper.init(this) { + // Check for any queued uploads and restart, only after ProofMode is correctly initialized. + UploadService.startUploadService(this) + } + } + // ----- 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() } - - 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 + } + onAddClick = { addClicked(AddMediaType.GALLERY) } + onSettingsClick = { + mCurrentPagerItem = mPagerAdapter.settingsIndex + } + + if (Picker.canPickFiles(this@MainActivity)) { + setAddButtonLongClickEnabled() + onAddLongClick = { + if (Space.current == null) { + navigateToAddServer() + } else if (getSelectedProject() == null) { + navigateToAddFolder() + } else { + getCurrentMediaFragment()?.setArrowVisible(false) + val addMediaBottomSheet = + ContentPickerFragment { actionType -> addClicked(actionType) } + addMediaBottomSheet.show(supportFragmentManager, ContentPickerFragment.TAG) + } + } + 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) + } + + // 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() + // New helper: update the cancel selection TextView to show the number of selected items. + fun updateSelectedCount(count: Int) { + // For example, if count > 0 display “Selected: X”; otherwise, revert to “Select Media”. + //binding.contentMain.tvSelectedCount.text = if (count > 0) "Selected: $count" else "Select Media" + } - ProofModeHelper.init(this) { - // Check for any queued uploads and restart, only after ProofMode is correctly initialized. - UploadService.startUploadService(this) + private fun showDeleteSelectedMediaConfirmDialog() { + dialogManager.showDialog( + config = DialogConfig( + type = DialogType.Warning, + title = R.string.menu_delete.asUiText(), + message = R.string.menu_delete_desc.asUiText(), + icon = UiImage.DrawableResource(R.drawable.ic_trash), + positiveButton = ButtonData( + text = R.string.lbl_ok.asUiText(), + action = { + getCurrentMediaFragment()?.deleteSelected() + updateSelectedCount(0) + refreshCurrentFolderCount() + } + ), + neutralButton = + ButtonData( + text = UiText.StringResource(R.string.lbl_Cancel), + action = { + + } + ) + ) + ) + } + + private fun showDeleteFolderConfirmDialog() { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Error + icon = UiImage.DrawableResource(R.drawable.ic_trash) + title = UiText.StringResource(R.string.remove_from_app) + message = UiText.StringResource(R.string.action_remove_project) + destructiveButton { + text = UiText.StringResource(R.string.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() + } + } } } - override fun onResume() { - super.onResume() + private fun getCurrentMediaFragment(): MainMediaFragment? { + val currentItem = binding.contentMain.pager.currentItem + return supportFragmentManager.findFragmentByTag("f$currentItem") as? MainMediaFragment + } - refreshSpace() - mCurrentPagerItem = mLastItem + // ----- Drawer Helpers ----- + private fun toggleDrawerState() { + if (binding.drawerLayout.isDrawerOpen(binding.drawerContent)) { + closeDrawer() + } else { + openDrawer() + } + } - if (!Prefs.didCompleteOnboarding) { - startActivity(Intent(this, Onboarding23Activity::class.java)) + private fun openDrawer() { + binding.drawerLayout.openDrawer(binding.drawerContent) + } + + private fun closeDrawer() { + binding.drawerLayout.closeDrawer(binding.drawerContent) + } + + private fun toggleSpacesList() { + if (serverListCurOffset == serverListOffset) { + expandSpacesList() + } else { + collapseSpacesList() } + } - importSharedMedia(intent) + 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 } + 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).setDuration(200) + binding.navigationDrawerHeader.elevation = 0f + } - private fun navigateToFolder(folderId: Long) { - val folderIndex = mPagerAdapter.getProjectIndexById(folderId) - if (folderIndex >= 0) { - binding.pager.setCurrentItem(folderIndex, true) - mCurrentPagerItem = folderIndex + private fun updateCurrentSpaceAtDrawer() { + Space.current?.setAvatar(binding.currentSpaceIcon) + mSpaceAdapter.notifyDataSetChanged() + if (Space.current == null) { + binding.btnAddFolder.visibility = View.INVISIBLE } else { - Toast.makeText(this, "Folder not found", Toast.LENGTH_SHORT).show() + binding.btnAddFolder.visibility = View.VISIBLE } } + // ----- 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 + } - fun updateAfterDelete(done: Boolean) { - mMenuDelete?.isVisible = !done + 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) + } - if (done) refreshCurrentFolderCount() + mFolderAdapter.notifyDataSetChanged() } - private fun addFolder() { - mNewFolderResultLauncher.launch(Intent(this, AddFolderActivity::class.java)) + 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 + updateCurrentSpaceAtDrawer() + currentSpace.setAvatar(binding.contentMain.spaceIcon) + } else { + binding.contentMain.spaceIcon.visibility = View.INVISIBLE + binding.spaceNameLayout.visibility = View.INVISIBLE } + mSpaceAdapter.update(Space.getAll().asSequence().toList()) + updateCurrentSpaceAtDrawer() refreshProjects() + refreshCurrentProject() + updateCurrentFolderVisibility() } 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 +803,225 @@ 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("start_destination", StartDestination.ADD_NEW_FOLDER.name) } else { - binding.folderCount.cloak() + intent.putExtra("start_destination", StartDestination.ADD_FOLDER.name) + } + mNewFolderResultLauncher.launch(intent) +// mNewFolderResultLauncher.launch(Intent(this, AddFolderActivity::class.java)) + } + + private fun addClicked(mediaType: AddMediaType) { + + when { + getSelectedProject() != null -> { + if (Prefs.addMediaHint) { + when (mediaType) { + AddMediaType.CAMERA -> { + //permissionManager.checkCameraPermission { + //Picker.takePhoto(this@MainActivity, mediaLaunchers.cameraLauncher) + Picker.takePhotoModern(this@MainActivity, 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) { - - // Check if there's any project selected - if (getSelectedProject() != null) { - - if (Prefs.addMediaHint) { - when (mediaType) { - AddMediaType.CAMERA -> Picker.takePhoto( - this@MainActivity, - mediaLaunchers.cameraLauncher - ) - - AddMediaType.GALLERY -> Picker.pickMedia( - this, - mediaLaunchers.imagePickerLauncher - ) + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + val shouldShowSideMenu = + Space.current != null && mCurrentPagerItem != mPagerAdapter.settingsIndex + menu?.findItem(R.id.menu_folders)?.apply { + isVisible = shouldShowSideMenu + } + 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) + } + } - } else if (Space.current == null) { // Check if there's any space available - startActivity(Intent(this, SpaceSetupActivity::class.java)) - } else { + // ----- Adapter Listeners ----- + override fun onProjectSelected(project: Project) { + binding.drawerLayout.closeDrawer(binding.drawerContent) + mCurrentPagerItem = mPagerAdapter.projects.indexOf(project) + } - 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 getSelectedProject(): Project? { + return mPagerAdapter.getProject(mCurrentPagerItem) } - 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 onSpaceSelected(space: Space) { + Space.current = space + refreshSpace() + updateCurrentSpaceAtDrawer() + collapseSpacesList() + binding.drawerLayout.closeDrawer(binding.drawerContent) + } - shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { - showNotificationPermissionRationale() - } + override fun onAddNewSpace() { + collapseSpacesList() + closeDrawer() + val intent = Intent(this, SpaceSetupActivity::class.java) + startActivity(intent) + } - else -> { - requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - } + override fun getSelectedSpace(): Space? { + val currentSpace = Space.current + AppLogger.i("current space requested by adapter... = $currentSpace") + return Space.current } - private fun showNotificationPermissionRationale() { - Utility.showMaterialWarning(this, "Accept!") { - Timber.d("thing") + /** + * 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) } } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { + /** + * 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) + } + } + }) + } + } + } - menuInflater.inflate(R.menu.menu_main, menu) + override fun onDestroy() { + super.onDestroy() - return super.onCreateOptionsMenu(menu) + // Clear pending callbacks/messages + window?.decorView?.handler?.removeCallbacksAndMessages(null) } - - 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) - } + 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 b64b3647..dc3cb18c 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,7 +7,6 @@ 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 @@ -22,18 +21,24 @@ 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.upload.UploadService import net.opendasharchive.openarchive.util.AlertHelper +import net.opendasharchive.openarchive.util.extensions.Position 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 +52,16 @@ 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 lateinit var binding: FragmentMainMediaBinding private val mMessageReceiver: BroadcastReceiver = object : BroadcastReceiver() { @@ -80,11 +90,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 +100,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 +119,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 +205,13 @@ class MainMediaFragment : Fragment() { binding.addMediaHint.toggle(mCollections.isEmpty()) } + fun cancelSelection() { + isSelecting = false + selectedMediaIds.clear() + mAdapters.values.forEach { it.clearSelections() } + updateSelectionCount() + } + fun deleteSelected() { val toDelete = ArrayList() @@ -229,20 +232,24 @@ class MainMediaFragment : Fragment() { 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.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 +258,50 @@ 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.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 isSelecting = mAdapters.values.any { it.selecting } + (activity as? MainActivity)?.setSelectionMode(isSelecting) + val totalSelected = mAdapters.values.sumOf { it.getSelectedCount() } + (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 +317,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 00000000..fba870c1 --- /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 59ae3522..5495f775 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 1cf0d1b5..60f34bff 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 a3a147cd..b9ce6e83 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 00000000..6e068676 --- /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 00000000..54caa8ba --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaAdapter.kt @@ -0,0 +1,317 @@ +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 { it.selected = false } + notifyDataSetChanged() + } + + 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) + } +} \ No newline at end of file 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 00000000..da193c32 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/adapters/MainMediaViewHolder.kt @@ -0,0 +1,202 @@ +package net.opendasharchive.openarchive.features.main.adapters + +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.placeholder +import coil3.video.VideoFrameDecoder +import coil3.video.videoFrameMillis +import com.github.derlio.waveform.soundfile.SoundFile +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.db.Media +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) { + + companion object { + val soundCache = HashMap() + } + + private val mContext = itemView.context + + 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 + + // Update selection visuals. + if (isInSelectionMode && media?.selected == true) { + 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) { + val progress = CircularProgressDrawable(mContext) + progress.strokeWidth = 5f + progress.centerRadius = 30f + progress.start() + + binding.image.load(media.fileUri, imageLoader) { + placeholder(progress) + crossfade(true) + crossfade(300) + listener(onError = { req, res -> + AppLogger.e(res.throwable) + }) + } + + binding.image.scaleType = ImageView.ScaleType.CENTER_CROP + binding.image.show() + binding.waveform.hide() + binding.videoIndicator.hide() + } else if (media?.mimeType?.startsWith("video") == true) { + + binding.image.load(media.originalFilePath, imageLoader) { + val progress = CircularProgressDrawable(mContext) + progress.strokeWidth = 5f + progress.centerRadius = 30f + progress.start() + videoFrameMillis(1000) // Extracts the frame at 1 second (1000ms) + placeholder(progress) + crossfade(true) + crossfade(300) + listener(onError = { req, res -> AppLogger.e(res.throwable) }) + } + + binding.image.scaleType = ImageView.ScaleType.CENTER_CROP + binding.image.show() + binding.waveform.hide() + binding.videoIndicator.show() + } else if (media?.mimeType?.startsWith("audio") == true) { + binding.videoIndicator.hide() + + val soundFile = soundCache[media.originalFilePath] + + if (soundFile != null) { + binding.image.hide() + binding.waveform.setAudioFile(soundFile) + binding.waveform.show() + } else { + binding.image.setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.no_thumbnail)) + binding.image.scaleType = ImageView.ScaleType.CENTER_CROP + binding.image.show() + binding.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 { + binding.waveform.setAudioFile(soundFile) + binding.image.hide() + binding.waveform.show() + } + } + } + } + } else if (media?.mimeType?.startsWith("application") == true) { + binding.image.setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.ic_unknown_file)) + binding.image.scaleType = ImageView.ScaleType.CENTER_INSIDE + binding.image.show() + binding.waveform.hide() + binding.videoIndicator.hide() + } else { + binding.image.setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.ic_unknown_file)) + binding.image.scaleType = ImageView.ScaleType.CENTER_INSIDE + binding.image.show() + binding.waveform.hide() + 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%" + } +} 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 00000000..9abcddc4 --- /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 00000000..044e5c85 --- /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 00000000..64a64207 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt @@ -0,0 +1,414 @@ +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.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +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.features.media.PreviewActivity +import net.opendasharchive.openarchive.upload.BroadcastManager +import net.opendasharchive.openarchive.upload.UploadManagerActivity +import org.koin.androidx.compose.koinViewModel + +/** + * 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)) + } + + 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 00000000..30e0cb08 --- /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 00000000..b36287c1 --- /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 00000000..d81b59d6 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/ExpandableSpaceList.kt @@ -0,0 +1,169 @@ +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.GDRIVE -> painterResource(R.drawable.logo_gdrive_outline) + 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 00000000..499e244c --- /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 00000000..3d092349 --- /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 00000000..f257f95e --- /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 00000000..c96f535a --- /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 1ea462a8..4cb98d75 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 06bfe950..871e93d5 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,11 @@ 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 ) \ 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 537ad9a9..f7c10ee2 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,31 +1,21 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -33,9 +23,12 @@ 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.util.Date @@ -50,13 +43,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 +59,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 +67,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 +77,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 +97,49 @@ 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)) + + activity.lifecycleScope.launch(Dispatchers.IO) { + try { + // Import the captured photo with proof generation enabled + val media = import(activity, project(), listOf(capturedImageUri), true) - 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.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) - } + return MediaLaunchers( + galleryLauncher = galleryLauncher, + filePickerLauncher = filePickerLauncher, + cameraLauncher = legacyCameraLauncher, + modernCameraLauncher = modernCameraLauncher + ) + } - launcher.launch(config) + fun pickMedia(launcher: ActivityResultLauncher) { + val request = PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + launcher.launch(request) } fun canPickFiles(context: Context): Boolean { @@ -143,32 +155,70 @@ object Picker { type = "application/*" } - private fun needAskForPermission(activity: Activity, permissions: Array): Boolean { - var needAsk = false + /** + * 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() + } + } - for (permission in permissions) { - needAsk = ContextCompat.checkSelfPermission( - activity, - permission - ) != PackageManager.PERMISSION_GRANTED - && ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + /** + * 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") - if (needAsk) break - } + file?.let { + val uri = FileProvider.getUriForFile( + activity, "${activity.packageName}.provider", + it + ) - if (!needAsk) return false + currentPhotoUri = uri - ActivityCompat.requestPermissions(activity, permissions, 2) + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, uri) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) // Ensure permission is granted + } - return true + 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 +228,21 @@ 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)) { + AppLogger.e("Failed to write stream to file for URI: $uri") 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) } @@ -212,28 +261,53 @@ object Picker { 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 = HashUtils.getSHA256FromFileContent( + context.contentResolver.openInputStream(uri) + ) + } 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) { - fun takePhoto(context: Context, launcher: ActivityResultLauncher) { - val file = Utility.getOutputMediaFileByCache(context, "IMG_${System.currentTimeMillis()}.jpg") + 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 - file?.let { - val uri = FileProvider.getUriForFile( - context, "${context.packageName}.provider", - it - ) - currentPhotoUri = uri - launcher.launch(uri) + AppLogger.d("Generating ProofMode data for URI: $uri, Hash: ${media.mediaHashString}") + + // Generate proof using the ProofMode library + ProofMode.generateProof(context, uri, media.mediaHashString) + + AppLogger.i("ProofMode generation completed for media: ${media.title}") + } catch (e: Exception) { + AppLogger.e("Failed to generate ProofMode data", e) + Timber.w("ProofMode generation failed: ${e.message}") + } + } else { + if (generateProof) { + AppLogger.w("ProofMode generation requested but useProofMode is disabled") + } + Timber.w("Skipping proof generation - generateProof: $generateProof, useProofMode: ${Prefs.useProofMode}") } + return media } + + @SuppressLint("RestrictedApi") private fun showProgressSnackBar(activity: Activity, root: View, message: String): Snackbar { val bar = root.makeSnackBar(message) 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 e332f4da..17d13e92 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,24 +1,27 @@ 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 androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog +import androidx.core.view.WindowInsetsCompat 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.show import net.opendasharchive.openarchive.util.extensions.toggle @@ -60,12 +63,25 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis } } + private lateinit var permissionManager: PermissionManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mBinding = ActivityPreviewBinding.inflate(layoutInflater) + + 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 }, { @@ -104,7 +120,8 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis } R.id.action_upload_camera -> { - Picker.takePhoto(this@PreviewActivity, mediaLaunchers.cameraLauncher) + //Picker.takePhoto(this@PreviewActivity, mediaLaunchers.cameraLauncher) + Picker.takePhotoModern(this@PreviewActivity, mediaLaunchers.modernCameraLauncher) } R.id.action_upload_files -> { @@ -127,7 +144,12 @@ 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 -> { +// permissionManager.checkCameraPermission { + Picker.takePhotoModern(this@PreviewActivity, mediaLaunchers.modernCameraLauncher) +// } + + } AddMediaType.FILES -> Picker.pickFiles(mediaLaunchers.filePickerLauncher) AddMediaType.GALLERY -> onClick(mBinding.btAddMore) } @@ -161,7 +183,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 -> { @@ -227,10 +251,20 @@ class PreviewActivity : BaseActivity(), View.OnClickListener, PreviewAdapter.Lis 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 +281,33 @@ 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() - - // 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 - + } + positiveButton { + text = UiText.DynamicString("Proceed to upload") + action = { + Prefs.dontShowUploadHint = doNotShowAgain + queue() + } + } + neutralButton { + text = UiText.DynamicString("Actually, let me edit") + } + } } } } \ No newline at end of file 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 591d7407..2768c210 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 + 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,7 +83,7 @@ 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) @@ -284,13 +291,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 - ) + if (!Prefs.flagHintShown) return + + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + title = UiText.StringResource(R.string.popup_flag_title) + message = UiText.StringResource(R.string.popup_flag_desc) + positiveButton { + text = UiText.StringResource(R.string.lbl_got_it) + action = { + dialogManager.dismissDialog() + } + } + } Prefs.flagHintShown = true } @@ -310,24 +322,17 @@ class ReviewActivity : BaseActivity(), View.OnClickListener { waveform?.hide() if (media?.mimeType?.startsWith("image") == true) { - Glide.with(this) - .load(media.fileUri) - .into(imageView) + imageView.load(media.fileUri) } else if (media?.mimeType?.startsWith("video") == true) { - Picasso.Builder(this) - .addRequestHandler(VideoRequestHandler(this)) - .build() - .load(VideoRequestHandler.SCHEME_VIDEO + ":" + media.originalFilePath) - ?.fit() - ?.centerCrop() - ?.into(imageView) + + imageView.load(media.originalFilePath.toUri()) } else if (media?.mimeType?.startsWith("audio") == true) { imageView.setImageResource(R.drawable.audio_waveform) if (waveform != null) { - val soundFile = MediaViewHolder.soundCache[media.originalFilePath] + val soundFile = UploadMediaViewHolder.soundCache[media.originalFilePath] if (soundFile != null) { waveform.setAudioFile(soundFile) waveform.show() 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 00000000..86bcc4b8 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/adapter/PreviewViewHolder.kt @@ -0,0 +1,157 @@ +package net.opendasharchive.openarchive.features.media.adapter + +import android.annotation.SuppressLint +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.placeholder +import com.github.derlio.waveform.soundfile.SoundFile +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.db.Media +import net.opendasharchive.openarchive.util.extensions.hide +import net.opendasharchive.openarchive.util.extensions.show + +class PreviewViewHolder(val binding: RvMediaBoxBinding) : + RecyclerView.ViewHolder(binding.root) { + + companion object { + val soundCache = HashMap() + } + + private val mContext = itemView.context + + @SuppressLint("SetTextI18n") + fun bind(media: Media? = null, batchMode: Boolean = false, doImageFade: Boolean = true) { + itemView.tag = media?.id + if (batchMode && media?.selected == true) { + 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 + binding.image.apply { + show() + hideWaveformIfAny() + load(media.fileUri) { + placeholder(progress) + } + } + binding.videoIndicator.hide() + } else if (media?.mimeType?.startsWith("video") == true) { + // video thumbnail + binding.image.apply { + show() + hideWaveformIfAny() + load(media.fileUri) { + placeholder(progress) + } + } + binding.videoIndicator.show() + + } else if (media?.mimeType?.startsWith("audio") == true) { + binding.videoIndicator.hide() + handleAudio(media) + } else { + binding.image.setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.no_thumbnail)) + binding.image.show() + binding.waveform.hide() + binding.videoIndicator.hide() + } + media?.let { updateOverlay(it) } + } + + private fun ImageView.hideWaveformIfAny() { + binding.waveform.hide() + } + + private fun handleAudio(media: Media) { + val soundFile = soundCache[media.originalFilePath] + if (soundFile != null) { + binding.image.hide() + binding.waveform.setAudioFile(soundFile) + binding.waveform.show() + } else { + binding.image.apply { + setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.no_thumbnail)) + show() + } + binding.waveform.hide() + + CoroutineScope(Dispatchers.IO).launch { + val sf = runCatching { + SoundFile.create(media.originalFilePath) { true } + }.getOrNull() + + sf?.let { + soundCache[media.originalFilePath] = it + MainScope().launch { + binding.waveform.setAudioFile(it) + binding.image.hide() + binding.waveform.show() + } + } + } + } + } + + 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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23InstructionsActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/Onboarding23InstructionsActivity.kt index 49e8f42e..0215e1d1 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 { @@ -65,7 +75,7 @@ class Onboarding23InstructionsActivity : BaseActivity() { mBinding.skipButton.visibility = View.INVISIBLE mBinding.fab.setImageDrawable( ContextCompat.getDrawable( - mBinding.fab.context, com.esafirm.imagepicker.R.drawable.ef_ic_done_white, + mBinding.fab.context, R.drawable.ic_done, ) ) } else { @@ -99,10 +109,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 +129,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 c9d55975..22502ac1 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,72 +1,25 @@ 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.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.util.extensions.applyEdgeToEdgeInsets + +enum class StartDestination { + SPACE_TYPE, + SPACE_LIST, + DWEB_DASHBOARD, + ADD_FOLDER, + ADD_NEW_FOLDER } class SpaceSetupActivity : BaseActivity() { @@ -75,47 +28,68 @@ class SpaceSetupActivity : BaseActivity() { const val FRAGMENT_TAG = "ssa_fragment" } - 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) - if (supportFragmentManager.backStackEntryCount > 1) { - // We still have fragments in the back stack to pop - supportFragmentManager.popBackStack() - true // fully handled here - } else { - // No more fragments left in back stack, let the system finish Activity - false - } - } + initSpaceSetupNavigation() + } + + private fun initSpaceSetupNavigation() { + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.space_nav_host_fragment) as NavHostFragment + + navController = navHostFragment.navController + navGraph = navController.navInflater.inflate(R.navigation.space_setup_navigation) - intent.getBooleanExtra("snowbird", false).let { - if (it) { - navigateToFragment(SnowbirdFragment.newInstance()) + val startDestinationString = + intent.getStringExtra("start_destination") ?: StartDestination.SPACE_TYPE.name + val startDestination = StartDestination.valueOf(startDestinationString) + when (startDestination) { + StartDestination.SPACE_LIST -> { + navGraph.setStartDestination(R.id.fragment_space_list) + } + StartDestination.ADD_FOLDER -> { + navGraph.setStartDestination(R.id.fragment_add_folder) + } + StartDestination.ADD_NEW_FOLDER -> { + navGraph.setStartDestination(R.id.fragment_create_new_folder) + } + else -> { + navGraph.setStartDestination(R.id.fragment_space_setup) } } + navController.graph = navGraph + + appBarConfiguration = AppBarConfiguration(emptySet()) + setupActionBarWithNavController(navController, appBarConfiguration) } fun updateToolbarFromFragment(fragment: Fragment) { @@ -132,240 +106,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() - - } - - private fun initSnowbirdFragmentBindings() { - supportFragmentManager.setFragmentResultListener( - SnowbirdFragment.RESULT_REQUEST_KEY, - this - ) { key, bundle -> - when (bundle.getString(SnowbirdFragment.RESULT_BUNDLE_KEY)) { - - SnowbirdFragment.RESULT_VAL_RAVEN_MY_GROUPS -> { - navigateToFragment(SnowbirdGroupListFragment.newInstance()) - } - - 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)) - } - } - } + override fun onSupportNavigateUp(): Boolean { + return findNavController(R.id.space_nav_host_fragment).navigateUp() || super.onSupportNavigateUp() } - private fun initSnowbirdGroupListFragmentBindings() { - supportFragmentManager.setFragmentResultListener( - SnowbirdGroupListFragment.RESULT_REQUEST_KEY, - this - ) { key, bundle -> + override fun onDestroy() { + super.onDestroy() - 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) - } - } - } - } + // Clear any pending messages or callbacks in the main thread handler + window?.decorView?.handler?.removeCallbacksAndMessages(null) + binding.commonAppBar.commonToolbar.setNavigationOnClickListener(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) + // 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 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 175db133..00000000 --- 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 00000000..f7572b98 --- /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 index 004e7ce3..9f0ef994 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderActivity.kt @@ -7,7 +7,10 @@ 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.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 import net.opendasharchive.openarchive.util.extensions.Position import net.opendasharchive.openarchive.util.extensions.setDrawable @@ -53,16 +56,15 @@ class EditFolderActivity : BaseActivity() { false } - mBinding.btRemove.setDrawable(R.drawable.ic_delete, Position.Start, 0.5) mBinding.btRemove.setOnClickListener { - removeProject() + showDeleteFolderConfirmDialog() } mBinding.btArchive.setOnClickListener { archiveProject() } - CcSelector.init(mBinding.cc, null) { + CreativeCommonsLicenseManager.initialize(mBinding.cc, null) { mProject.licenseUrl = it mProject.save() } @@ -70,14 +72,26 @@ class EditFolderActivity : BaseActivity() { 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 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.remove) + action = { + mProject.delete() + finish() + } + } + neutralButton { + text = UiText.StringResource(R.string.lbl_Cancel) + action = { + dialogManager.dismissDialog() + } + } + } } private fun archiveProject() { @@ -101,10 +115,10 @@ class EditFolderActivity : BaseActivity() { 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) + mBinding.cc.tvCcLabel.setText(R.string.set_the_same_creative_commons_license_for_all_folders_on_this_server) } - CcSelector.set(mBinding.cc, mProject.licenseUrl, !mProject.isArchived && !global) + CreativeCommonsLicenseManager.initialize(mBinding.cc, mProject.licenseUrl, !mProject.isArchived && !global) } override fun onOptionsItemSelected(item: MenuItem): Boolean { 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 index 35c43879..3e1ac954 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersActivity.kt @@ -13,9 +13,9 @@ 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 +import net.opendasharchive.openarchive.util.extensions.toggle -class FoldersActivity : BaseActivity(), FolderAdapterListener { +class FoldersActivity : BaseActivity(), FolderAdapterListener { companion object { const val EXTRA_SHOW_ARCHIVED = "show_archived" @@ -26,7 +26,7 @@ class FoldersActivity : BaseActivity(), FolderAdapterListener { private lateinit var mBinding: ActivityFoldersBinding private lateinit var mAdapter: FolderAdapter - private var mArchived = false + private var mArchived = true private var mSelectedSpaceId = -1L private var mSelectedProjectId: Long = -1L @@ -57,9 +57,18 @@ class FoldersActivity : BaseActivity(), FolderAdapterListener { } private fun setupButtons() { - mBinding.fabAdd.apply { - visibility = if (mArchived) View.INVISIBLE else View.VISIBLE - setOnClickListener { addFolder() } +// mBinding.fabAdd.apply { +// visibility = if (mArchived) View.INVISIBLE else View.VISIBLE +// setOnClickListener { addFolder() } +// } + + mBinding.btViewArchived.apply { + toggle(!mArchived) + setOnClickListener { + val i = Intent(this@FoldersActivity, FoldersActivity::class.java) + i.putExtra(EXTRA_SHOW_ARCHIVED, true) + startActivity(i) + } } } @@ -77,6 +86,14 @@ class FoldersActivity : BaseActivity(), FolderAdapterListener { } ?: 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 + } } @@ -112,23 +129,13 @@ class FoldersActivity : BaseActivity(), FolderAdapterListener { 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 - } + //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) } 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 index 42b10140..82fdee99 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.view.MenuItem import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.compose.ui.res.stringResource import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat @@ -113,7 +114,7 @@ class GeneralSettingsActivity: BaseActivity() { } findPreference(Prefs.THEME)?.setOnPreferenceChangeListener { _, newValue -> - Theme.set(Theme.get(newValue as? String)) + Theme.set(requireActivity(), Theme.get(newValue as? String)) true } 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 00000000..4031b903 --- /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.internetarchive.presentation.login.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 000b96fc..a6d3652c 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 f9c600eb..bd58fe24 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,31 @@ 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.core.dialog.showWarningDialog +import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity +import net.opendasharchive.openarchive.features.onboarding.StartDestination +import net.opendasharchive.openarchive.features.settings.app_masking.AppMaskingActivity +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 +83,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 +118,91 @@ class SettingsFragment : PreferenceFragmentCompat() { true } + // Check if app masking is enabled in the app config + if (appConfig.appMaskingEnabled) { + getPrefByKey(R.string.pref_app_masking)?.setOnPreferenceClickListener { + startActivity(Intent(context, AppMaskingActivity::class.java)) + 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("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, FoldersActivity::class.java) + intent.putExtra(FoldersActivity.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)) + Theme.set(requireActivity(), 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(requireActivity(), theme) + // Save the preference + Prefs.putBoolean(getString(R.string.pref_key_use_dark_mode), useDarkMode) true } @@ -138,39 +217,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 d612e334..aa2002d3 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 3d096282..dfad9635 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 @@ -6,10 +6,16 @@ import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult +import androidx.fragment.compose.content +import androidx.navigation.NavDirections +import androidx.navigation.fragment.findNavController +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme import net.opendasharchive.openarchive.databinding.FragmentSpaceSetupBinding 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.features.spaces.SpaceSetupScreen import net.opendasharchive.openarchive.util.extensions.hide import net.opendasharchive.openarchive.util.extensions.show import org.koin.android.ext.android.inject @@ -19,54 +25,45 @@ 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 5c94f6f1..e08e741c 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/AppMaskingActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/app_masking/AppMaskingActivity.kt new file mode 100644 index 00000000..1e474c19 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/app_masking/AppMaskingActivity.kt @@ -0,0 +1,137 @@ +package net.opendasharchive.openarchive.features.settings.app_masking + +import android.os.Bundle +import androidx.preference.CheckBoxPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.databinding.ActivityAppMaskingBinding +import net.opendasharchive.openarchive.features.core.BaseActivity + +class AppMaskingActivity : BaseActivity() { + + private lateinit var _binding: ActivityAppMaskingBinding + private val binding: ActivityAppMaskingBinding get() = _binding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + _binding = ActivityAppMaskingBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupToolbar(getString(R.string.label_app_masking)) + + supportFragmentManager.beginTransaction() + .replace(binding.maskingSettingsContainer.id, AppMaskingSettingsFragment()) + .commit() + } + + /** + * Fragment that handles the "Save App" vs "Masked App" toggles. + */ + class AppMaskingSettingsFragment : PreferenceFragmentCompat() { + + // Track if we are programmatically updating checkboxes, to avoid infinite loops + private var isUpdating = false + + private lateinit var saveAppPref: CheckBoxPreference + private lateinit var maskAppPref: CheckBoxPreference + private lateinit var aliasStatusPref: Preference // to display which alias is active + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + + setPreferencesFromResource(R.xml.prefs_app_masking, rootKey) + + saveAppPref = findPreference("save_app")!! + maskAppPref = findPreference("mask_app")!! + + // Initialize the checkboxes based on what was last saved + initCheckboxStates() + + // Listen to toggles + saveAppPref.setOnPreferenceChangeListener { _, newValue -> + if (newValue as Boolean) { + enableSaveAlias() + // Return true to let Preference store the new value + true + } else { + // If user is unchecking “Save App”, see if we must force it or let them check the other + if (!maskAppPref.isChecked) { + // Force at least one to be true, or do your logic + // e.g. re-check it or do nothing + false + } else { + true + } + } + } + + maskAppPref.setOnPreferenceChangeListener { _, newValue -> + if (newValue as Boolean) { + enableMaskAlias() + true + } else { + if (!saveAppPref.isChecked) { + false + } else { + true + } + } + } + } + + private fun initCheckboxStates() { + // Figure out which alias is active + val currentAlias = AppMaskingUtils.getCurrentAlias(requireContext()) + val saveAlias = "${requireContext().packageName}.alias.SaveAlias" + val maskAlias = "${requireContext().packageName}.alias.MaskAlias" + + isUpdating = true + when (currentAlias) { + saveAlias -> { + saveAppPref.isChecked = true + maskAppPref.isChecked = false + } + maskAlias -> { + saveAppPref.isChecked = false + maskAppPref.isChecked = true + } + else -> { + // If you have no stored preference, default to Save + saveAppPref.isChecked = true + maskAppPref.isChecked = false + } + } + isUpdating = false + } + + private fun enableSaveAlias() { + if (isUpdating) return + isUpdating = true + + // Turn off the other + maskAppPref.isChecked = false + + // Enable the SAVE alias + val saveAlias = "${requireContext().packageName}.alias.SaveAlias" + AppMaskingUtils.setLauncherActivityAlias(requireContext(), saveAlias) + + isUpdating = false + } + + private fun enableMaskAlias() { + if (isUpdating) return + isUpdating = true + + // Turn off the other + saveAppPref.isChecked = false + + // Enable the MASK alias + val maskAlias = "${requireContext().packageName}.alias.MaskAlias" + AppMaskingUtils.setLauncherActivityAlias(requireContext(), maskAlias) + + isUpdating = false + } + + } +} \ No newline at end of file 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 00000000..f8d99f15 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/app_masking/AppMaskingUtils.kt @@ -0,0 +1,68 @@ +package net.opendasharchive.openarchive.features.settings.app_masking + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.edit + +object AppMaskingUtils { + + private const val PREFS_NAME = "app_masking_prefs" + private const val KEY_ENABLED_ALIAS = "key_enabled_alias" + + /** + * Call this to enable a specific activity-alias and disable all others. + * @param context Application or activity context + * @param aliasToEnable The fully qualified name of the alias to enable + */ + fun setLauncherActivityAlias(context: Context, aliasToEnable: String) { + val packageName = context.packageName + val pm = context.packageManager + + // List the aliases you care about (two in your case: Save vs. Mask). + val allAliases = listOf( + "$packageName.alias.SaveAlias", + "$packageName.alias.MaskAlias" + ) + + // For each known alias, enable the chosen one, disable the rest + allAliases.forEach { alias -> + val newState = + if (alias == aliasToEnable) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + pm.setComponentEnabledSetting( + ComponentName(packageName, alias), + newState, + PackageManager.DONT_KILL_APP + ) + } + + // Persist the chosen alias in SharedPreferences + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { + putString(KEY_ENABLED_ALIAS, aliasToEnable) + } + } + + /** + * Returns the currently enabled alias, if any, as a fully-qualified name. + */ + fun getCurrentAlias(context: Context): String? { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_ENABLED_ALIAS, null) + } + + /** + * A convenience method to translate the stored fully-qualified alias + * into something human-readable for display (optional). + */ + fun getCurrentAliasDisplayName(context: Context): String { + return when (getCurrentAlias(context)) { + "${context.packageName}.alias.SaveAlias" -> "Save App (default)" + "${context.packageName}.alias.MaskAlias" -> "Masked App" + else -> "Unknown / Default" + } + } +} \ No newline at end of file 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 00000000..c5db97de --- /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.INVISIBLE + } + + 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 00000000..941f8f72 --- /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 d837f93b..3e08dc26 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,7 @@ data class AppConfig( val maxRetryLimitEnabled: Boolean = false, val biometricAuthEnabled: Boolean = false, val maxFailedAttempts: Int = 5, - val snowbirdEnabled: Boolean = false + val isDwebEnabled: Boolean = true, + val appMaskingEnabled: Boolean = true, + val multipleProjectSelectionMode: 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 fa6b2322..edf3e8e8 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 5b379134..827ca7b9 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 f4bd63bc..1da3385e 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,6 +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.ArrowForward +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 @@ -28,11 +32,14 @@ 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.R import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme import net.opendasharchive.openarchive.features.settings.passcode.AppHapticFeedbackType import net.opendasharchive.openarchive.features.settings.passcode.HapticManager @@ -42,13 +49,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 +82,11 @@ fun NumericKeypad( label = label, enabled = isEnabled, onClick = { - onNumberClick(label) + when (label) { + "delete" -> onDeleteClick() + "submit" -> onSubmitClick() + else -> onNumberClick(label) + } } ) } else { @@ -108,7 +121,9 @@ private fun NumericKeypadPreview() { isEnabled = true, onNumberClick = { number -> - } + }, + onDeleteClick = {}, + onSubmitClick = {} ) Spacer(modifier = Modifier.height(16.dp)) @@ -130,8 +145,39 @@ 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 = "" ) @@ -148,17 +194,30 @@ private fun NumberButton( 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( + imageVector = Icons.Default.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 = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + 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 1e143abb..9b093b75 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 38b01d7e..be4322bc 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,7 @@ fun PasscodeEntryScreenContent( ) { Text( - text = "Enter Your Passcode", style = TextStyle( + text = stringResource(R.string.enter_passcode), style = TextStyle( fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground @@ -160,52 +166,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 +226,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 1f9f8b57..0cb3f2a7 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 c36ca919..e974329a 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.internetarchive.presentation.login.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 229de783..8a8c8d2c 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 @@ -17,12 +17,15 @@ 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.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.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -51,6 +54,8 @@ fun PasscodeSetupScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val hapticFeedback = LocalHapticFeedback.current LaunchedEffect(Unit) { @@ -64,7 +69,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 +104,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 = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) ) 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.", + text = stringResource(R.string.set_passcode_warning), color = MaterialTheme.colorScheme.error, + fontSize = 11.sp, textAlign = TextAlign.Center, - fontWeight = FontWeight.Light, + fontWeight = FontWeight.Medium, style = MaterialTheme.typography.labelMedium ) } + 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 +148,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 01d3e7a8..1af8b2ae 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 00000000..d39582f0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/ServerOptionItem.kt @@ -0,0 +1,162 @@ +package net.opendasharchive.openarchive.features.spaces + +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.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.onBackground), + 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.Top + ) { + Text( + text = title, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + + Text( + text = subtitle, + fontWeight = FontWeight.Normal, + fontSize = 14.sp + ) + } + + Icon( + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically), + painter = painterResource(R.drawable.ic_arrow_forward_ios), + contentDescription = null, + ) + } + + + } +} + +@Preview +@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 = {} + ) + } + + + } +} + +@Preview +@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 00000000..ed1befd5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt @@ -0,0 +1,94 @@ +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 net.opendasharchive.openarchive.services.gdrive.GDriveActivity +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.GDRIVE -> { + val intent = Intent(requireContext(), GDriveActivity::class.java) + intent.putExtra(EXTRA_DATA_SPACE, space.id) + startActivity(intent) + } + + 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 00000000..57be2ae6 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListScreen.kt @@ -0,0 +1,200 @@ +package net.opendasharchive.openarchive.features.spaces + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +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.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.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.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.ThemeDimensions +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.main.ui.components.SpaceIcon +import net.opendasharchive.openarchive.features.main.ui.components.dummySpaceList +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), + 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 + .align(Alignment.BottomCenter) + .fillMaxWidth(0.7f) + .padding(bottom = 24.dp), + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + ) + ) { + Text( + text = "+ Add Server", + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) + ) + } + } +} + +@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, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 1.sp + ) + + Text( + text = space.tType.friendlyName, + color = MaterialTheme.colorScheme.onBackground, + 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 00000000..af8fabe9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt @@ -0,0 +1,111 @@ +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), + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + 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, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } + + 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 db97e20b..00000000 --- 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 b345c02d..00000000 --- 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 a4b50fd0..524675a4 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 @@ -17,8 +16,7 @@ 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 +49,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() } } 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 index 0c58fbd6..a7e7a898 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFragment.kt @@ -10,6 +10,7 @@ import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.core.text.HtmlCompat import androidx.fragment.app.setFragmentResult +import androidx.navigation.fragment.findNavController import com.google.android.gms.auth.api.Auth import com.google.android.gms.auth.api.signin.GoogleSignIn import kotlinx.coroutines.CoroutineScope @@ -19,7 +20,7 @@ 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 +import net.opendasharchive.openarchive.features.core.BaseFragment class GDriveFragment : BaseFragment() { @@ -56,7 +57,7 @@ class GDriveFragment : BaseFragment() { mBinding.error.visibility = View.GONE mBinding.btBack.setOnClickListener { - setFragmentResult(RESP_CANCEL, bundleOf()) + findNavController().popBackStack() } mBinding.btAuthenticate.setOnClickListener { @@ -79,7 +80,10 @@ class GDriveFragment : BaseFragment() { ) } else { // permission was already granted, we're already signed in, continue. - setFragmentResult(RESP_AUTHENTICATED, bundleOf()) + val message = getString(R.string.you_have_successfully_connected_to_gdrive) + val action = GDriveFragmentDirections.actionFragmentGdriveToFragmentSpaceSetupSuccess(message) + findNavController().navigate(action) + } } @@ -150,5 +154,5 @@ class GDriveFragment : BaseFragment() { } } - override fun getToolbarTitle() = "Google Drive" + override fun getToolbarTitle() = getString(R.string.gdrive) } \ 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 6c7ddda2..7ac8a122 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 84c1c73e..00000000 --- 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 90805648..5dc49d1d 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 40b24a04..e26b869b 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 741055a2..89f516d7 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 0c11a8b3..a3dce89f 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 e5cc9dd2..4d0b366f 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 4c92fbc7..e76c8e3c 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 5532ed31..6547f2fc 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 ac4a0e56..b49290d3 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 00000000..d57e8e9c --- /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 00000000..1e6d5b1c --- /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 a6bd2af7..00000000 --- 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 e95860e7..9826d2c5 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 b935a9ab..79e717a0 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,29 +1,50 @@ package net.opendasharchive.openarchive.services.webdav -import android.content.Context 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.core.os.bundleOf +import androidx.core.view.MenuProvider +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import com.google.android.material.snackbar.Snackbar 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.applyEdgeToEdgeInsets +import net.opendasharchive.openarchive.util.extensions.hide import net.opendasharchive.openarchive.util.extensions.makeSnackBar +import net.opendasharchive.openarchive.util.extensions.show import okhttp3.Call import okhttp3.Callback import okhttp3.Request @@ -32,16 +53,16 @@ import java.io.IOException import kotlin.coroutines.suspendCoroutine class WebDavFragment : BaseFragment() { - private var mSpaceId: Long? = null + private lateinit var mSpace: Space private lateinit var mSnackbar: Snackbar 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: WebDavFragmentArgs by navArgs() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -49,12 +70,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 -> + + bottomMargin = insets.bottom + } - if (mSpaceId != ARG_VAL_NEW_SPACE) { - // setup views for editing and existing space + 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 +98,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 +108,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 +175,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 +197,7 @@ class WebDavFragment : BaseFragment() { binding.password.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_NULL) { - attemptLogin() + //attemptLogin() } false @@ -153,6 +209,102 @@ 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() + if (!enteredName.isNullOrEmpty()) { + mSpace.name = enteredName + mSpace.save() + originalName = enteredName + isNameChanged = false + requireActivity().invalidateOptionsMenu() //Refresh menu to hide confirm btn again + showSuccessDialog() + } else { + Snackbar.make(binding.root, getString(R.string.empty_name_warning), Snackbar.LENGTH_LONG).show() + } + } + + 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? { @@ -194,8 +346,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 @@ -225,7 +375,7 @@ class WebDavFragment : BaseFragment() { // perform the user login attempt. mSnackbar.show() - CoroutineScope(Dispatchers.IO).launch { + lifecycleScope.launch(Dispatchers.IO) { try { testConnection() mSpace.save() @@ -238,7 +388,7 @@ class WebDavFragment : BaseFragment() { navigate(mSpace.id) } catch (exception: IOException) { if (exception.message?.startsWith("401") == true) { - showError(getString(R.string.error_incorrect_username_or_password), true) + showInvalidCredentialsError() } else { showError(exception.localizedMessage ?: getString(R.string.error)) } @@ -246,16 +396,27 @@ class WebDavFragment : BaseFragment() { } } - 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 { + mSnackbar.dismiss() + binding.errorHint.text = getString(R.string.error_incorrect_username_or_password) + binding.errorHint.show() } } + private fun dismissCredentialsError() { + binding.errorHint.hide() + } + + private fun navigate(spaceId: Long) = CoroutineScope(Dispatchers.Main).launch { + val action = + WebDavFragmentDirections.actionFragmentWebDavToFragmentSetupLicense( + spaceId = spaceId, + spaceType = Space.Type.WEBDAV + ) + findNavController().navigate(action) + } + private suspend fun testConnection() { val url = mSpace.hostUrl ?: throw IOException("400 Bad Request") @@ -292,7 +453,7 @@ class WebDavFragment : BaseFragment() { mSnackbar.dismiss() if (onForm) { - binding.password.error = text + binding.errorHint.error = text binding.password.requestFocus() } else { mSnackbar = binding.root.makeSnackBar(text, Snackbar.LENGTH_LONG) @@ -305,6 +466,9 @@ class WebDavFragment : BaseFragment() { 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() @@ -312,48 +476,67 @@ class WebDavFragment : BaseFragment() { 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_ok), + 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() { + 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 + binding.btAuthenticate.isEnabled = + url.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty() + } + + companion object { + const val ARG_VAL_NEW_SPACE = -1L + + // other internal constants + const val REMOTE_PHP_ADDRESS = "/remote.php/webdav/" } - override fun getToolbarTitle(): String = if (mSpaceId == ARG_VAL_NEW_SPACE) { - "Add Private Server" + override fun getToolbarTitle(): String = if (args.spaceId == ARG_VAL_NEW_SPACE) { + "Private Server" } else { - "Edit Private Server" + val space = Space.get(args.spaceId) + space?.name ?: "Private Server" } } \ No newline at end of file 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 d1804df1..00000000 --- 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/upload/SKBottomSheetDialogFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/SKBottomSheetDialogFragment.kt new file mode 100644 index 00000000..a1c26a06 --- /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 78d628a1..c3cf500c 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 index 493a1ce1..f56093dd 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt @@ -36,22 +36,11 @@ class UploadManagerActivity : BaseActivity() { 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 + private var mEditMode = true // Setting Edit mode as the default mode override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -60,11 +49,15 @@ class UploadManagerActivity : BaseActivity() { setContentView(mBinding.root) setupToolbar( - title = getString(R.string.uploads), + title = getString(R.string.upload_manager_screen_title), + subtitle = getString(R.string.upload_manager_screen_subtitle), showBackButton = true ) - mFrag = supportFragmentManager.findFragmentById(R.id.fragUploadManager) as? UploadManagerFragment + //mFrag = supportFragmentManager.findFragmentById(R.id.fragUploadManager) as? UploadManagerFragment + + val bottomSheet = UploadManagerFragment() + bottomSheet.show(supportFragmentManager, UploadManagerFragment.TAG) } override fun onResume() { @@ -73,7 +66,7 @@ class UploadManagerActivity : BaseActivity() { BroadcastManager.register(this, mMessageReceiver) - updateTitle() + onStartEdit() } override fun onPause() { @@ -82,9 +75,17 @@ class UploadManagerActivity : BaseActivity() { BroadcastManager.unregister(this, mMessageReceiver) } + private fun onStartEdit() { + UploadService.stopUploadService(this) + } + + private fun onCompleteEdit() { + UploadService.startUploadService(this) + } + private fun toggleEditMode() { mEditMode = !mEditMode - mFrag?.setEditMode(mEditMode) + mFrag?.refresh() if (mEditMode) { @@ -103,7 +104,7 @@ class UploadManagerActivity : BaseActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_upload, menu) - mMenuEdit = menu.findItem(R.id.menu_edit) + mMenuEdit = menu.findItem(R.id.menu_done) return super.onCreateOptionsMenu(menu) } @@ -114,8 +115,8 @@ class UploadManagerActivity : BaseActivity() { finish() return true } - R.id.menu_edit -> { - toggleEditMode() + R.id.menu_done -> { + onCompleteEdit() return true } } @@ -135,8 +136,7 @@ class UploadManagerActivity : BaseActivity() { if (mEditMode) { supportActionBar?.title = getString(R.string.edit_media) supportActionBar?.subtitle = getString(R.string.uploading_is_paused) - } - else { + } else { val count = mFrag?.getUploadingCounter() ?: 0 supportActionBar?.title = if (count < 1) { 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 cef0814b..ec764f54 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.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 9a60215d..da34903f 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