diff --git a/.circleci/config.yml b/.circleci/config.yml index d3b31ac9..d9867073 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,16 +3,16 @@ jobs: build: working_directory: ~/code docker: - - image: circleci/android:api-30 + - image: cimg/android:2024.01 environment: JVM_OPTS: -Xmx3200m steps: - checkout - restore_cache: key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} -# - run: -# name: Chmod permissions #if permission for Gradlew Dependencies fail, use this. -# command: sudo chmod +x ./gradlew + # - 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 @@ -26,10 +26,9 @@ jobs: - run: name: Run Tests command: ./gradlew lint test - - store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/ + - 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/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 849d1dd4..5e46cdcd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,17 +1,20 @@ --- name: Bug report about: Create a report to document a bug in the app and to help us improve -title: '' +title: "" labels: bug -assignees: '' - +assignees: "" --- **Describe the bug** -A clear and concise description of what the bug is. +A clear and concise description of what the bug is, who it affects, where it happens, and when it occurs. + +**Preconditions** +Include any common configuration, users, or assumptions on how to reproduce. **To Reproduce** -Steps to reproduce the behavior: +Steps to reproduce the behavior. + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -20,13 +23,22 @@ Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. +**Actual behaviour** +Describe the error, flaw, or failure when interacting in more detail. + **Screenshots** If applicable, add screenshots to help explain your problem. +**Logs** +If applicable, include any device logs. + **Environment (please complete the following information):** - - OS version: [e.g. Android 10] - - Device: [e.g. Samsung Galaxy A2] - - App Version [e.g. 2.2] + +- OS version: [e.g. Android 10] +- Device: [e.g. Samsung Galaxy A2] +- App Version [e.g. 2.2] +- Backend(s): [e.g. Internet Archive, Nextcloud] +- Component(s): [e.g. Onboarding, Settings] **Additional context** -Add any other context about the problem here. +Add any other context about the problem here. Please indicate priority and severity. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 5ecce628..10f303df 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,20 +1,70 @@ --- name: Feature request -about: Suggest an idea for this project. Please provide your feedback using the 'I +about: + Suggest an idea for this project. Please provide your feedback using the 'I Like, I Wish, What if' feedback format -title: '' +title: "" labels: enhancement -assignees: '' - +assignees: "" --- -A preview of the [**I Like, I Wish, What if feedback format**](https://public-media.interaction-design.org/pdf/I-Like-I-Wish-What-If.pdf) +A preview of the [**I Like, I Wish, What if feedback format**](https://public-media.interaction-design.org/pdf/I-Like-I-Wish-What-If.pdf) to fascilitate discussions. **I Like...** + **I Wish...** + **What If...** + + +--- + +A preview of the **User Story format** + +## Motivation and Impact + +A clear reasoning of the value of the feature and why it should exist. + +**We believe** +**Will result** +**We can proceed when** + +## User Story (High-Level Acceptance Criteria) + +A description of high-level outcomes for this feature as an end-user. + +As a _(type of user)_, I want _(some goal)_ so that _(some reason)_. + +Given _(precondition)_, when _(action)_, then _(outcome)_. + +## User Flow (optional) + +A step-by-step run-through of the user's journey with screenshots or mockups. + +## Functional Requirements and Developer Notes + +A technical discussion about the requirements with diagrams, schemas, or references. + +## Quality Assurance and Security Notes + +Describe what could go wrong with the system and how it would be handled. + +## Not in Scope, Questions, and Answers + +Mention aspects of the feature that are not in scope. +Ask any remaining questions and answer known issues. + +## Acceptance Criteria + +A detailed list of rules that will consider this feature "done". + +## Affected + +Take note of any people, departments, users, platforms, or systems affected. + +Be sure to tag individuals or representatives. diff --git a/.gitignore b/.gitignore index d439813a..f574ebe4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +private-* openarchive-release.keystore # Built application files @@ -43,9 +44,11 @@ proguard/ app/releaseflavor/* # fastlane +fastlane/metadata/* fastlane/report.xml fastlane/Preview.html fastlane/screenshots fastlane/test_output fastlane/README.md -fastlane/.env \ No newline at end of file +fastlane/.env +.env.default diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..b734015f --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +source "https://rubygems.org" + +gem 'fastlane' + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..ab503b53 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,224 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.3.0) + aws-partitions (1.907.0) + aws-sdk-core (3.191.6) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.8) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.78.0) + aws-sdk-core (~> 3, >= 3.191.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.146.1) + aws-sdk-core (~> 3, >= 3.191.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.8) + aws-sigv4 (1.8.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.110.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.3.1) + fastlane (2.220.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-android_version_manager (0.4.1) + semantic (~> 1.6.0) + fastlane-plugin-increment_version_code (0.4.3) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.7.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.4.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.5) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.7.2) + jwt (2.8.1) + base64 + mini_magick (4.12.0) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.4.0) + nanaimo (0.3.0) + naturally (2.2.1) + nkf (0.2.0) + optparse (0.4.0) + os (1.1.4) + plist (3.7.1) + public_suffix (5.0.5) + rake (13.2.0) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.6) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.5) + semantic (1.6.1) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.5.0) + word_wrap (1.0.0) + xcodeproj (1.24.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + fastlane + fastlane-plugin-android_version_manager + fastlane-plugin-increment_version_code + +BUNDLED WITH + 2.4.22 diff --git a/app/build.gradle b/app/build.gradle index b29e07bf..6b8548eb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,15 +1,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply from : '../dependencies.gradle' -apply from : '../config.gradle' apply plugin: 'org.jetbrains.kotlin.android' -def getDropboxKey() { - Properties properties = new Properties() - properties.load(project.rootProject.file('local.properties').newDataInputStream()) - return properties.getProperty("dropbox_key") -} - android { compileOptions { sourceCompatibility 1.8 @@ -19,14 +11,14 @@ android { signingConfigs { } - compileSdkVersion config.compileSdkVersion - buildToolsVersion config.buildToolsVersion + compileSdk 34 +// buildToolsVersion '34.0.0' defaultConfig { applicationId "net.opendasharchive.openarchive" - minSdkVersion config.minSdkVersion - targetSdkVersion config.targetSdkVersion - versionCode config.versionCode - versionName config.versionName + minSdkVersion 29 + targetSdkVersion 34 + versionCode 30001 + versionName '0.7.4' archivesBaseName = "Save-$versionName" multiDexEnabled true vectorDrawables.useSupportLibrary = true @@ -35,16 +27,9 @@ android { flavorDimensions += "free" buildTypes { - debug { - buildConfigField "String", "dropbox_key", getDropboxKey() - resValue 'string', "dropbox_key", getDropboxKey() - } - release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - buildConfigField "String", "dropbox_key", getDropboxKey() - resValue 'string', "dropbox_key", getDropboxKey() } } packagingOptions { @@ -65,43 +50,64 @@ android { buildFeatures { viewBinding true + buildConfig true + compose true } + lint { abortOnError false } + composeOptions { + kotlinCompilerExtensionVersion "1.5.13" + } + namespace 'net.opendasharchive.openarchive' } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" + implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.23" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" - implementation "androidx.core:core-ktx:1.12.0" +// implementation "androidx.core:core-ktx:1.13.1" implementation "androidx.appcompat:appcompat:1.6.1" implementation 'androidx.biometric:biometric:1.1.0' implementation "androidx.constraintlayout:constraintlayout:2.1.4" implementation "androidx.coordinatorlayout:coordinatorlayout:1.2.0" implementation "androidx.legacy:legacy-support-v4:1.0.0" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.2" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" implementation 'androidx.preference:preference-ktx:1.2.1' - implementation "androidx.work:work-runtime-ktx:2.8.1" + implementation "androidx.work:work-runtime:2.9.0" + implementation "androidx.work:work-runtime-ktx:2.9.0" + implementation "androidx.work:work-testing:2.9.0" + + implementation "androidx.compose.ui:ui:1.6.7" + implementation "androidx.compose.material3:material3:1.2.1" + implementation 'androidx.compose.foundation:foundation:1.6.7' + implementation "androidx.compose.ui:ui-tooling-preview:1.6.7" + implementation "androidx.activity:activity-compose:1.9.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0" + + implementation "io.insert-koin:koin-core:3.5.3" + implementation "io.insert-koin:koin-android:3.5.3" + implementation "io.insert-koin:koin-androidx-compose:3.5.3" implementation "com.github.satyan:sugar:1.5" - implementation "com.google.code.gson:gson:2.9.1" - implementation "com.squareup.okhttp3:okhttp:4.10.0" - implementation "com.dropbox.core:dropbox-core-sdk:5.4.4" + implementation "com.google.code.gson:gson:2.10.1" + implementation "com.squareup.okhttp3:okhttp:4.12.0" // adding web dav support: https://github.com/thegrizzlylabs/sardine-android' implementation "com.github.guardianproject:sardine-android:89f7eae512" + + implementation "com.google.android.material:material:1.11.0" + implementation "androidx.compose.material:material-icons-extended:1.6.7" - implementation "com.google.android.material:material:1.10.0" - - implementation "com.github.bumptech.glide:glide:$versions.glide" - annotationProcessor "com.github.bumptech.glide:compiler:$versions.glide" + implementation "com.github.bumptech.glide:glide:4.16.0" + annotationProcessor "com.github.bumptech.glide:compiler:4.16.0" implementation "com.github.derlio:audio-waveform:v1.0.1" implementation "com.github.esafirm:android-image-picker:3.0.0" implementation "com.github.stfalcon:frescoimageviewer:0.5.0" @@ -115,7 +121,7 @@ dependencies { implementation "info.guardianproject.netcipher:netcipher:2.2.0-alpha" //from here: https://github.com/guardianproject/proofmode - implementation ("org.proofmode:android-libproofmode:1.0.26") { + implementation("org.proofmode:android-libproofmode:1.0.26") { transitive = false @@ -131,7 +137,7 @@ dependencies { exclude group: "com.squareup.okio", module: "okio" } - implementation "com.google.guava:guava:31.0.1-android" + implementation "com.google.guava:guava:31.0.1-jre" implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' implementation 'org.bouncycastle:bcpkix-jdk15to18:1.72' @@ -143,11 +149,28 @@ dependencies { implementation 'com.jakewharton.timber:timber:5.0.1' - // google drive api - implementation 'com.google.android.gms:play-services-auth:20.7.0' + // Google Drive API + implementation 'com.google.android.gms:play-services-auth:21.1.1' implementation 'com.google.http-client:google-http-client-gson:1.42.1' - 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 'com.google.api-client:google-api-client-android:1.26.0' + implementation 'com.google.apis:google-api-services-drive:v3-rev136-1.25.0' + + // Tor + implementation 'info.guardianproject:tor-android:0.4.7.14' + implementation 'info.guardianproject:jtorctl:0.4.5.7' + + // New Play libraries + implementation 'com.google.android.play:asset-delivery:2.2.2' + implementation 'com.google.android.play:asset-delivery-ktx:2.2.2' + + implementation 'com.google.android.play:feature-delivery:2.1.0' + implementation 'com.google.android.play:feature-delivery-ktx:2.1.0' + + implementation 'com.google.android.play:review:2.0.1' + implementation 'com.google.android.play:review-ktx:2.0.1' + + implementation 'com.google.android.play:app-update:2.1.0' + implementation 'com.google.android.play:app-update-ktx:2.1.0' // Tests testImplementation 'junit:junit:4.13.2' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index da927fff..12125fc7 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -21,4 +21,17 @@ -dontwarn javax.annotation.** -dontwarn org.conscrypt.** # A resource is loaded with a relative path so the package of this class must be preserved. --keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase \ No newline at end of file +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase + +-assumenosideeffects class androidx.compose.material.icons.extended.{ + !Visibility, + !VisibilityOff, + ** +} { + ; +} + +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 21a7fbf4..bc5fe7df 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,8 +6,8 @@ - - + + @@ -17,6 +17,7 @@ + @@ -43,13 +44,15 @@ android:allowBackup="false" android:fullBackupContent="false" android:dataExtractionRules="@xml/data_extraction_rules" + android:enableOnBackInvokedCallback="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:requestLegacyExternalStorage="true" android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/AppTheme.NoActionBar" tools:replace="android:icon,android:allowBackup" - tools:ignore="UnusedAttribute"> + tools:ignore="UnusedAttribute,LockedOrientationActivity"> +private val c23_teal_90 = Color(0xff00e7d5) // v=90.6 --> +private val c23_teal_80 = Color(0xff00cebe) // v=80.6 --> +private val c23_teal = Color(0xff00b4a6) // v=70.6 --> +private val c23_teal_60 = Color(0xff009b8f) // v=60.6 --> +private val c23_teal_50 = Color(0xff008177) // v=50.6 --> +private val c23_teal_40 = Color(0xff00685f) // v=40.6 --> +private val c23_teal_30 = Color(0xff004e48) // v=30.6 --> +private val c23_teal_20 = Color(0xff003530) // v=20.6 --> +private val c23_teal_10 = Color(0xff001b19) // v=10.6 --> +private val c23_powder_blue = Color(0xffaae6e1) + +@Immutable +data class ColorTheme( + val material: ColorScheme, + val primaryDark: Color = c23_teal_40, + val primaryBright: Color = c23_powder_blue, + + val disabledContainer: Color = c23_teal_20, + val onDisabledContainer: Color = c23_light_grey, +) + +private val LightColorScheme = ColorTheme( + material = lightColorScheme( + + primary = c23_teal, + onPrimary = Color.Black, + primaryContainer = c23_teal, + onPrimaryContainer = Color.Black, + + secondary = c23_teal, + onSecondary = Color.Black, + secondaryContainer = c23_teal_90, + onSecondaryContainer = Color.Black, + + tertiary = c23_powder_blue, + onTertiary = Color.Black, + tertiaryContainer = c23_powder_blue, + onTertiaryContainer = Color.Black, + + error = Color.Red, + onError = Color.Black, + errorContainer = Color.Red, + onErrorContainer = Color.Black, + + background = Color.White, + onBackground = Color.Black, + + surface = c23_light_grey, + onSurface = Color.Black, + surfaceVariant = c23_grey, + onSurfaceVariant = c23_darker_grey, + + outline = Color.Black, + inverseOnSurface = Color.White, + inverseSurface = c23_dark_grey, + inversePrimary = Color.Black, + surfaceTint = c23_teal, + outlineVariant = c23_darker_grey, + scrim = c23_light_grey, + surfaceBright = c23_light_grey, + surfaceContainer = Color.White, + surfaceDim = c23_light_grey + ), +) + +private val DarkColorScheme = ColorTheme( + material = darkColorScheme( + primary = c23_teal, + onPrimary = Color.Black, + primaryContainer = c23_teal, + onPrimaryContainer = Color.White, + + secondary = c23_teal, + onSecondary = Color.Black, + secondaryContainer = c23_teal_20, + onSecondaryContainer = Color.White, + + tertiary = c23_powder_blue, + onTertiary = Color.Black, + tertiaryContainer = c23_powder_blue, + onTertiaryContainer = Color.Black, + + error = Color.Red, + onError = Color.Black, + errorContainer = Color.Red, + onErrorContainer = Color.Black, + + background = Color.Black, + onBackground = Color.White, + + surface = c23_darker_grey, + onSurface = Color.White, + surfaceVariant = c23_dark_grey, + onSurfaceVariant = c23_light_grey, + + outline = Color.White, + inverseSurface = c23_light_grey, + inverseOnSurface = Color.Black, + inversePrimary = Color.White, + surfaceTint = c23_teal, + outlineVariant = c23_light_grey, + scrim = c23_light_grey, + surfaceBright = c23_grey, + surfaceContainer = c23_medium_grey, + surfaceDim = c23_dark_grey + ), +) + +fun getThemeColors(isDarkTheme: Boolean) = if (isDarkTheme) DarkColorScheme else LightColorScheme + +val LocalColors = staticCompositionLocalOf { LightColorScheme } + diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Dimensions.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Dimensions.kt new file mode 100644 index 00000000..a813a0b6 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Dimensions.kt @@ -0,0 +1,44 @@ +package net.opendasharchive.openarchive.core.presentation.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class Elevations(val card: Dp = 12.dp) + +@Immutable +data class Icons( + val small: Dp = 24.dp, + val medium: Dp = 48.dp, + val large: Dp = 72.dp +) + +@Immutable +data class Spacing( + val xsmall: Dp = 4.dp, + val small: Dp = 8.dp, + val medium: Dp = 16.dp, + val large: Dp = 24.dp, + val xlarge: Dp = 32.dp +) + +@Immutable +data class DimensionsTheme( + val touchable: Dp = 48.dp, + val spacing: Spacing = Spacing(), + val elevations: Elevations = Elevations(), + val icons: Icons = Icons(), + val bubbleArrow: Dp = 24.dp, + val roundedCorner: Dp = 8.dp +) + +private val DimensionsLight = DimensionsTheme() + +private val DimensionsDark = DimensionsTheme(elevations = Elevations(card = 0.dp)) + +fun getThemeDimensions(isDarkTheme: Boolean) = + if (isDarkTheme) DimensionsDark else DimensionsLight + +val LocalDimensions = staticCompositionLocalOf { DimensionsLight } 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 new file mode 100644 index 00000000..e0bfbea0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Theme.kt @@ -0,0 +1,33 @@ +package net.opendasharchive.openarchive.core.presentation.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState + +@Composable +fun Theme( + content: @Composable () -> Unit +) { + val isDarkTheme by rememberUpdatedState(newValue = isSystemInDarkTheme()) + + val colors = getThemeColors(isDarkTheme) + + val dimensions = getThemeDimensions(isDarkTheme) + + CompositionLocalProvider( + LocalDimensions provides dimensions, + LocalColors provides colors, + ) { + MaterialTheme( + colorScheme = colors.material, + content = content + ) + } +} + + +val ThemeColors: ColorTheme @Composable get() = LocalColors.current +val ThemeDimensions: DimensionsTheme @Composable get() = LocalDimensions.current diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Dispatcher.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Dispatcher.kt new file mode 100644 index 00000000..c03cb9f3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Dispatcher.kt @@ -0,0 +1,8 @@ +package net.opendasharchive.openarchive.core.state + +typealias Dispatch = (A) -> Unit + +fun interface Dispatcher { + + fun dispatch(action: Action) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Effects.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Effects.kt new file mode 100644 index 00000000..677c100c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Effects.kt @@ -0,0 +1,4 @@ +package net.opendasharchive.openarchive.core.state + + +typealias Effects = suspend (T, A) -> Unit \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Listener.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Listener.kt new file mode 100644 index 00000000..9fdc43a3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Listener.kt @@ -0,0 +1,7 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.flow.Flow + +interface Listener { + val actions: Flow +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Notifier.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Notifier.kt new file mode 100644 index 00000000..c5901fb7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Notifier.kt @@ -0,0 +1,8 @@ +package net.opendasharchive.openarchive.core.state + + +typealias Notify = suspend (A) -> Unit + +fun interface Notifier { + suspend fun notify(action: Action) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Reducer.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Reducer.kt new file mode 100644 index 00000000..0f20ef84 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Reducer.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.updateAndGet + +typealias Reducer = (T, A) -> T + +fun MutableStateFlow.apply(action: A, reducer: Reducer) = + updateAndGet { reducer(it, action) } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/StateDispatcher.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/StateDispatcher.kt new file mode 100644 index 00000000..e9ec57b7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/StateDispatcher.kt @@ -0,0 +1,25 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class StateDispatcher( + private val scope: CoroutineScope, + initialState: T, + private val reducer: Reducer, + private val effects: Effects +) : Dispatcher, Stateful { + private val _state = MutableStateFlow(initialState) + + override val state = _state.asStateFlow() + + override fun dispatch(action: A) { + val state = _state.apply(action, reducer) + scope.launch(Dispatchers.Default) { + effects(state, action) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Stateful.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Stateful.kt new file mode 100644 index 00000000..9dfcda1e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Stateful.kt @@ -0,0 +1,7 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.flow.StateFlow + +interface Stateful { + val state: StateFlow +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Store.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Store.kt new file mode 100644 index 00000000..c5453fb3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Store.kt @@ -0,0 +1,6 @@ +package net.opendasharchive.openarchive.core.state + +interface Store : Dispatcher, Listener, Notifier { + + operator fun invoke(action: Action) = dispatch(action) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/StoreObserver.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/StoreObserver.kt new file mode 100644 index 00000000..94f7fa3c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/StoreObserver.kt @@ -0,0 +1,13 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +class StoreObserver : Notifier, Listener { + private val _actions = Channel() + override val actions = _actions.receiveAsFlow() + + override suspend fun notify(action: T) { + _actions.send(action) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/MediaAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/db/MediaAdapter.kt index ed43a413..31f18de9 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/MediaAdapter.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/MediaAdapter.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.view.* import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar +import net.opendasharchive.openarchive.CleanInsightsManager import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.features.media.PreviewActivity import net.opendasharchive.openarchive.upload.BroadcastManager @@ -69,16 +70,20 @@ class MediaAdapter( Media.Status.Error -> { if (supportedStatuses.contains(Media.Status.Error)) { + //CleanInsightsManager.measureEvent("backend", "upload-error", media[pos].space?.friendlyName) mActivity.get()?.let { AlertHelper.show( - it, media[pos].statusMessage, + it, it.getString(R.string.upload_unsuccessful_description), R.string.upload_unsuccessful, R.drawable.ic_error, listOf( AlertHelper.positiveButton(R.string.retry) { _, _ -> - media[pos].sStatus = Media.Status.Queued - media[pos].statusMessage = "" - media[pos].save() - updateItem(media[pos].id) + media[pos].apply { + sStatus = Media.Status.Queued + statusMessage = "" + save() + + BroadcastManager.postChange(it, collectionId, id) + } UploadService.startUploadService(it) }, @@ -134,14 +139,16 @@ class MediaAdapter( holder.handle?.toggle(isEditMode) } - fun updateItem(mediaId: Long): Boolean { + fun updateItem(mediaId: Long, progress: Long): Boolean { val idx = media.indexOfFirst { it.id == mediaId } if (idx < 0) return false - val item = Media.get(mediaId) ?: return false - - media[idx] = item - + if (progress >= 0) { + media[idx].progress = progress + } else { + val item = Media.get(mediaId) ?: return false + media[idx] = item + } notifyItemChanged(idx) return true @@ -160,7 +167,6 @@ class MediaAdapter( return true } - @SuppressLint("NotifyDataSetChanged") fun updateData(media: List) { this.media = ArrayList(media) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/MediaViewHolder.kt b/app/src/main/java/net/opendasharchive/openarchive/db/MediaViewHolder.kt index 6dfcbdd9..74110417 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/MediaViewHolder.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/MediaViewHolder.kt @@ -28,6 +28,7 @@ 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 import kotlin.math.roundToInt abstract class MediaViewHolder(protected val binding: ViewBinding): RecyclerView.ViewHolder(binding.root) { @@ -303,18 +304,19 @@ abstract class MediaViewHolder(protected val binding: ViewBinding): RecyclerView fileInfo?.text = Formatter.formatShortFileSize(mContext, file.length()) } else { if (media.contentLength == -1L) { + var iStream: InputStream? = null try { - val iStream = mContext.contentResolver.openInputStream(media.fileUri) + iStream = mContext.contentResolver.openInputStream(media.fileUri) if (iStream != null) { media.contentLength = iStream.available().toLong() media.save() - - iStream.close() } } catch (e: Throwable) { Timber.e(e) + } finally { + iStream?.close() } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Project.kt b/app/src/main/java/net/opendasharchive/openarchive/db/Project.kt index eea3d903..0e1b1d84 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/Project.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/Project.kt @@ -37,7 +37,7 @@ data class Project( } val isUploading - get() = collections.firstOrNull { it.isUploading } != null + get() = collections.any { it.isUploading } val collections: List get() = find(Collection::class.java, "project_id = ?", id.toString()) 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 e8f9e5dc..bf5c8ba4 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt @@ -11,14 +11,12 @@ import com.github.abdularis.civ.AvatarImageView import com.orm.SugarRecord import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity -import net.opendasharchive.openarchive.services.dropbox.DropboxConduit import net.opendasharchive.openarchive.services.gdrive.GDriveConduit import net.opendasharchive.openarchive.services.internetarchive.IaConduit import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.extensions.tint import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import java.util.* +import java.util.Locale data class Space( @@ -28,8 +26,9 @@ data class Space( var displayname: String = "", var password: String = "", var host: String = "", + var metaData: String = "", private var licenseUrl: String? = null, - private var chunking: Boolean? = null + // private var chunking: Boolean? = null ) : SugarRecord() { constructor(type: Type) : this() { @@ -41,11 +40,6 @@ data class Space( name = IaConduit.NAME host = IaConduit.ARCHIVE_API_ENDPOINT } - Type.DROPBOX -> { - name = DropboxConduit.NAME - host = DropboxConduit.HOST - username = DropboxConduit.HOST - } Type.GDRIVE -> { name = GDriveConduit.NAME } @@ -55,10 +49,13 @@ data class Space( enum class Type(val id: Int, val friendlyName: String) { WEBDAV(0, "WebDAV"), INTERNET_ARCHIVE(1, IaConduit.NAME), - DROPBOX(3, DropboxConduit.NAME), GDRIVE(4, GDriveConduit.NAME), } + enum class IconStyle { + SOLID, OUTLINE + } + companion object { fun getAll(): Iterator { return findAll(Space::class.java) @@ -139,12 +136,12 @@ data class Space( } } - var useChunking: Boolean - // Fallback to old preferences setting. - get() = chunking ?: Prefs.useNextcloudChunking - set(value) { - chunking = value - } +// var useChunking: Boolean +// // Fallback to old preferences setting. +// get() = chunking ?: Prefs.useNextcloudChunking +// set(value) { +// chunking = value +// } val projects: List get() = find(Project::class.java, "space_id = ? AND NOT archived", arrayOf(id.toString()), null, "id DESC", null) @@ -157,16 +154,15 @@ data class Space( return find(Project::class.java, "space_id = ? AND description = ?", id.toString(), description).size > 0 } - fun getAvatar(context: Context): Drawable? { + 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.INTERNET_ARCHIVE -> ContextCompat.getDrawable(context, R.drawable.ic_internet_archive)?.tint(color) + return when (tType) { + Type.WEBDAV -> ContextCompat.getDrawable(context, R.drawable.ic_private_server) // ?.tint(color) - Type.DROPBOX -> ContextCompat.getDrawable(context, R.drawable.ic_dropbox23)?.tint(color) + Type.INTERNET_ARCHIVE -> ContextCompat.getDrawable(context, R.drawable.ic_internet_archive) // ?.tint(color) - Type.GDRIVE -> ContextCompat.getDrawable(context, R.drawable.logo_drive_2020q4_color_2x_web_64dp) + Type.GDRIVE -> ContextCompat.getDrawable(context, R.drawable.logo_gdrive_outline) // ?.tint(color) else -> TextDrawable.builder().buildRound(initial, color) } @@ -174,7 +170,7 @@ data class Space( fun setAvatar(view: ImageView) { when (tType) { - Type.INTERNET_ARCHIVE, Type.DROPBOX -> { + Type.INTERNET_ARCHIVE -> { if (view is AvatarImageView) { view.state = AvatarImageView.SHOW_IMAGE } diff --git a/app/src/main/java/net/opendasharchive/openarchive/extensions/ViewExtension.kt b/app/src/main/java/net/opendasharchive/openarchive/extensions/ViewExtension.kt new file mode 100644 index 00000000..fe485e35 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/extensions/ViewExtension.kt @@ -0,0 +1,10 @@ +package net.opendasharchive.openarchive.extensions + +import android.view.View + +fun View.getMeasurments(): Pair { + measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + val width = measuredWidth + val height = measuredHeight + return width to height +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt index efb8db26..cb3870e9 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 @@ -12,7 +12,7 @@ abstract class BaseActivity: AppCompatActivity() { } override fun dispatchTouchEvent(event: MotionEvent?): Boolean { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q && event != null) { + if (event != null) { val obscuredTouch = event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED != 0 if (obscuredTouch) return false } 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 8c599021..580859f7 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 @@ -1,6 +1,5 @@ package net.opendasharchive.openarchive.features.folders -import android.annotation.SuppressLint import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.ViewGroup @@ -51,9 +50,6 @@ class BrowseFoldersAdapter( private val onClick: (folder: BrowseFoldersViewModel.Folder) -> Unit ) : RecyclerView.ViewHolder(binding.root) { - - - @SuppressLint("NotifyDataSetChanged") fun onBindView(position: Int, selected: Boolean) { val color = if (selected) sHighlightColor else sOriginalColor 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 72035445..1ec74006 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 @@ -20,6 +20,7 @@ class BrowseFoldersViewModel : ViewModel() { data class Folder(val name: String, val modified: Date) private val mFolders = MutableLiveData>() + val folders: LiveData> get() = mFolders @@ -34,8 +35,6 @@ class BrowseFoldersViewModel : ViewModel() { when (space.tType) { Space.Type.WEBDAV -> getWebDavFolders(context, space) - Space.Type.DROPBOX -> getDropboxFolders(context, space) - Space.Type.GDRIVE -> getGDriveFolders(context, space) else -> emptyList() @@ -69,12 +68,6 @@ class BrowseFoldersViewModel : ViewModel() { } ?: emptyList() } - private suspend fun getDropboxFolders(context: Context, space: Space): List { - val result = SaveClient.getDropbox(context, space.password).files().listFolder("") - - return result.entries.map { Folder(it.name, Date()) } - } - private fun getGDriveFolders(context: Context, space: Space): List { return GDriveConduit.listFoldersInRoot(GDriveConduit.getDrive(context)) } 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 new file mode 100644 index 00000000..f512d6b9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt @@ -0,0 +1,32 @@ +package net.opendasharchive.openarchive.features.internetarchive + +import com.google.gson.FieldNamingPolicy +import com.google.gson.Gson +import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.InternetArchiveLoginUseCase +import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.ValidateLoginCredentialsUseCase +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveLocalSource +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveRemoteSource +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.mapping.InternetArchiveMapper +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository.InternetArchiveRepository +import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsViewModel +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +typealias InternetArchiveGson = Gson + +val internetArchiveModule = module { + single { + Gson().newBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create() + } + factory { ValidateLoginCredentialsUseCase() } + factory { InternetArchiveRemoteSource(get(), get()) } + single { InternetArchiveLocalSource() } + factory { InternetArchiveMapper() } + factory { InternetArchiveRepository(get(), get(), get()) } + factory { args -> InternetArchiveLoginUseCase(get(), get(), args.get()) } + viewModel { args -> InternetArchiveDetailsViewModel(get(), args.get()) } + viewModel { args -> InternetArchiveLoginViewModel(get(), args.get()) } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/model/InternetArchive.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/model/InternetArchive.kt new file mode 100644 index 00000000..843cefd6 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/model/InternetArchive.kt @@ -0,0 +1,18 @@ +package net.opendasharchive.openarchive.features.internetarchive.domain.model + +data class InternetArchive( + val meta: MetaData, + val auth: Auth +) { + data class MetaData( + val userName: String, + val screenName: String, + val email: String, + ) + + + data class Auth( + val access: String, + val secret: String, + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt new file mode 100644 index 00000000..84ea461a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt @@ -0,0 +1,32 @@ +package net.opendasharchive.openarchive.features.internetarchive.domain.usecase + +import com.google.gson.Gson +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository.InternetArchiveRepository + +class InternetArchiveLoginUseCase( + private val repository: InternetArchiveRepository, + private val gson: Gson, + private val space: Space, +) { + + suspend operator fun invoke(email: String, password: String): Result = + repository.login(email, password).mapCatching { response -> + + response.auth.let { auth -> + repository.testConnection(auth).getOrThrow() + space.username = auth.access + space.password = auth.secret + } + + // TODO: use local data source for database + space.metaData = gson.toJson(response.meta) + space.save() + + Space.current = space + + response + } + +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/ValidateLoginCredentialsUseCase.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/ValidateLoginCredentialsUseCase.kt new file mode 100644 index 00000000..d8054ee8 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/ValidateLoginCredentialsUseCase.kt @@ -0,0 +1,18 @@ +package net.opendasharchive.openarchive.features.internetarchive.domain.usecase + +class ValidateLoginCredentialsUseCase { + + operator fun invoke(identifier: String, factor: String): Boolean { + return if (identifier.contains('@')) { + validateEmail(identifier) + } else { + validateUsername(identifier) + } && validatePassword(factor) + } + + private fun validateEmail(identifier: String) = identifier.isNotBlank() + + private fun validateUsername(identifier: String) = identifier.isNotBlank() + + private fun validatePassword(factor: String) = factor.isNotBlank() +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveLocalSource.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveLocalSource.kt new file mode 100644 index 00000000..01037892 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveLocalSource.kt @@ -0,0 +1,21 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive + +class InternetArchiveLocalSource { + // TODO: just use a memory cache for demo, will need to store in DB + // the database should be SQLCipher (https://www.zetetic.net/sqlcipher/) + // as we are storing access keys. Sugar record does not support sql cipher + // so planning a migration using local data sources. + private val cache = MutableStateFlow(null) + + fun set(value: InternetArchive) = cache.update { value } + + fun get() = cache.value + + fun subscribe() = cache.filterNotNull() +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveRemoteSource.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveRemoteSource.kt new file mode 100644 index 00000000..3e1bb47e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveRemoteSource.kt @@ -0,0 +1,48 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource + +import android.content.Context +import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult +import net.opendasharchive.openarchive.features.internetarchive.InternetArchiveGson +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginRequest +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginResponse +import net.opendasharchive.openarchive.services.SaveClient +import net.opendasharchive.openarchive.services.internetarchive.IaConduit.Companion.ARCHIVE_API_ENDPOINT +import okhttp3.FormBody +import okhttp3.Request + +private const val LOGIN_URI = "https://archive.org/services/xauthn?op=login" + +class InternetArchiveRemoteSource( + private val context: Context, + private val gson: InternetArchiveGson +) { + suspend fun login(request: InternetArchiveLoginRequest): Result = + SaveClient.get(context).enqueueResult( + Request.Builder() + .url(LOGIN_URI) + .post( + FormBody.Builder() + .add("email", request.email) + .add("password", request.password).build() + ) + .build() + ) { response -> + val data = gson.fromJson( + response.body?.string(), + InternetArchiveLoginResponse::class.java + ) + Result.success(data) + } + + suspend fun testConnection(auth: InternetArchive.Auth): Result = + SaveClient.get(context).enqueueResult( + Request.Builder() + .url(ARCHIVE_API_ENDPOINT) + .method("GET", null) + .addHeader("Authorization", "LOW ${auth.access}:${auth.secret}") + .build() + ) { response -> + Result.success(response.isSuccessful) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/mapping/InternetArchiveMapper.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/mapping/InternetArchiveMapper.kt new file mode 100644 index 00000000..665403c7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/mapping/InternetArchiveMapper.kt @@ -0,0 +1,20 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.mapping + +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginResponse + +class InternetArchiveMapper { + + private operator fun invoke(response: InternetArchiveLoginResponse.S3) = InternetArchive.Auth( + access = response.access, secret = response.secret + ) + + operator fun invoke(response: InternetArchiveLoginResponse.Values) = InternetArchive( + meta = InternetArchive.MetaData( + userName = response.itemname ?: "", + email = response.email ?: "", + screenName = response.screenname ?: "" + ), + auth = response.s3?.let { invoke(it) } ?: InternetArchive.Auth("", "") + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginRequest.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginRequest.kt new file mode 100644 index 00000000..9e9f9889 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginRequest.kt @@ -0,0 +1,6 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.model + +data class InternetArchiveLoginRequest( + val email: String, + val password: String, +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginResponse.kt new file mode 100644 index 00000000..28734530 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginResponse.kt @@ -0,0 +1,20 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.model + +data class InternetArchiveLoginResponse( + val success: Boolean, + val values: Values, + val version: Int, +) { + data class Values( + val s3: S3? = null, + val screenname: String? = null, + val email: String? = null, + val itemname: String? = null, + val reason: String? = null, + ) + + data class S3( + val access: String, + val secret: String, + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/UnauthenticatedException.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/UnauthenticatedException.kt new file mode 100644 index 00000000..a803cb4b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/UnauthenticatedException.kt @@ -0,0 +1,3 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.model + +class UnauthenticatedException : RuntimeException() diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/repository/InternetArchiveRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/repository/InternetArchiveRepository.kt new file mode 100644 index 00000000..cec01ebc --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/repository/InternetArchiveRepository.kt @@ -0,0 +1,36 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveLocalSource +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveRemoteSource +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.mapping.InternetArchiveMapper +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginRequest +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.UnauthenticatedException + +class InternetArchiveRepository( + private val remoteSource: InternetArchiveRemoteSource, + private val localSource: InternetArchiveLocalSource, + private val mapper: InternetArchiveMapper +) { + suspend fun login(email: String, password: String): Result = + withContext(Dispatchers.IO) { + remoteSource.login( + InternetArchiveLoginRequest(email, password) + ).mapCatching { response -> + if (response.success.not()) { + throw IllegalArgumentException(response.values.reason) + } + when (response.version) { + else -> mapper(response.values) + } + }.onSuccess { localSource.set(it) } + } + + suspend fun testConnection(auth: InternetArchive.Auth): Result = + withContext(Dispatchers.IO) { + remoteSource.testConnection(auth) + .mapCatching { if (!it) throw UnauthenticatedException() } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt new file mode 100644 index 00000000..be5500f7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt @@ -0,0 +1,49 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import net.opendasharchive.openarchive.CleanInsightsManager +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.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 { + 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) + else -> Unit + } + } +} + +//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 new file mode 100644 index 00000000..6c934e1f --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt @@ -0,0 +1,61 @@ +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.Fragment +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 + +@Deprecated("only used for backward compatibility") +class InternetArchiveFragment : Fragment() { + + 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()) + } +} 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 new file mode 100644 index 00000000..36338e8d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt @@ -0,0 +1,21 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation + +import androidx.compose.runtime.Composable +import net.opendasharchive.openarchive.core.presentation.theme.Theme +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) = Theme { + 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 new file mode 100644 index 00000000..177d5615 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/BundleExt.kt @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000..2f6d1a07 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/InternetArchiveHeader.kt @@ -0,0 +1,76 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.clip +import androidx.compose.ui.graphics.ColorFilter.Companion.tint +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.opendasharchive.openarchive.R +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) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(ThemeDimensions.touchable) + .background( + color = ThemeColors.material.surface, + shape = CircleShape + ) + .clip(CircleShape) + ) { + Image( + modifier = Modifier + .matchParentSize() + .padding(11.dp), + painter = painterResource(id = R.drawable.ic_internet_archive), + contentDescription = stringResource( + id = R.string.internet_archive + ), + colorFilter = tint(colorResource(id = R.color.colorPrimary)) + ) + } + Column(modifier = Modifier.padding(start = ThemeDimensions.spacing.medium)) { + Text( + text = stringResource(id = R.string.internet_archive), + fontSize = titleSize, + fontWeight = FontWeight.Bold, + color = ThemeColors.material.onSurface + ) + Text( + text = stringResource(id = R.string.internet_archive_description), + color = ThemeColors.material.onSurfaceVariant + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun InternetArchiveHeaderPreview() { + InternetArchiveHeader() +} 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 new file mode 100644 index 00000000..c851cde5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt @@ -0,0 +1,185 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.details + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +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.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.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.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 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) + } + + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actions.collect { action -> + when (action) { + is Action.Remove -> onResult(IAResult.Deleted) + is Action.Cancel -> onResult(IAResult.Cancelled) + else -> Unit + } + } + } + + InternetArchiveDetailsContent(state, viewModel::dispatch) +} + +@Composable +private fun InternetArchiveDetailsContent( + state: InternetArchiveDetailsState, + dispatch: Dispatch +) { + + var isRemoving by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + + Column { + + InternetArchiveHeader() + + Spacer(Modifier.height(ThemeDimensions.spacing.large)) + + Text( + text = stringResource(id = R.string.label_username), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = state.userName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + modifier = Modifier.padding(top = 16.dp), + text = stringResource(id = R.string.label_screen_name), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + text = state.screenName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + modifier = Modifier.padding(top = 16.dp), + text = stringResource(id = R.string.label_email), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + text = state.email, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + } + + Button( + modifier = Modifier + .padding(top = 12.dp) + .align(Alignment.BottomCenter), + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { + isRemoving = true + }, + colors = ButtonDefaults.buttonColors(containerColor = ThemeColors.material.error) + ) { + Text(stringResource(id = R.string.menu_delete)) + } + } + + if (isRemoving) { + RemoveInternetArchiveDialog(onDismiss = { isRemoving = false }) { + isRemoving = false + dispatch(Action.Remove) + } + } +} + +@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 = { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner) + ) { + Text(stringResource(id = R.string.action_cancel)) + } + }, confirmButton = { + Button( + onClick = onRemove, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors(containerColor = ThemeColors.material.error) + ) { + Text(stringResource(id = R.string.remove)) + } + }) +} + +@Composable +@Preview(showBackground = true) +private fun InternetArchiveScreenPreview() { + InternetArchiveDetailsContent( + state = InternetArchiveDetailsState( + email = "abc@example.com", + userName = "@abc_name", + screenName = "ABC Name" + ) + ) {} +} + +@Composable +@Preview(showBackground = true) +private fun RemoveInternetArchiveDialogPreview() { + RemoveInternetArchiveDialog(onDismiss = { }) {} +} 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 new file mode 100644 index 00000000..d81558c9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt @@ -0,0 +1,11 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.details + +import androidx.compose.runtime.Immutable + +@Immutable +data class InternetArchiveDetailsState( + val userName: String = "", + val screenName: String = "", + val email: String = "", +) + 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 new file mode 100644 index 00000000..b5a267c2 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt @@ -0,0 +1,54 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.details + +import com.google.gson.Gson +import net.opendasharchive.openarchive.core.presentation.StatefulViewModel +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 + +class InternetArchiveDetailsViewModel( + private val gson: Gson, + private val space: Space +) : StatefulViewModel(InternetArchiveDetailsState()) { + + init { + dispatch(Action.Load(space)) + } + + 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 + } + + override suspend fun effects(state: InternetArchiveDetailsState, action: Action) { + when (action) { + is Action.Remove -> { + space.delete() + notify(action) + } + + is Action.Load -> { + val metaData = gson.fromJson(space.metaData, InternetArchive.MetaData::class.java) + dispatch(Action.Loaded(metaData)) + } + + is Action.Cancel -> notify(action) + else -> Unit + } + } + + sealed interface Action { + + data class Load(val value: Space) : Action + + data class Loaded(val value: InternetArchive.MetaData) : Action + + data object Remove : Action + + data object Cancel : Action + } +} 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 new file mode 100644 index 00000000..65267de5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginScreen.kt @@ -0,0 +1,251 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.login + +import android.content.Intent +import android.net.Uri +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.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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +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.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.delay +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.core.state.Dispatch +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.InternetArchiveHeader +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.CreateLogin +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.Login +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdatePassword +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdateUsername +import 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) + } + + val state by viewModel.state.collectAsState() + + 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) + ) + ) + + is Action.Cancel -> onResult(IAResult.Cancelled) + + is Action.LoginSuccess -> onResult(IAResult.Saved) + + else -> Unit + } + } + } + + InternetArchiveLoginContent(state, viewModel::dispatch) +} + +@Composable +private fun InternetArchiveLoginContent( + state: InternetArchiveLoginState, dispatch: Dispatch +) { + + // If extra paranoid could pre-hash password in memory + // and use the store/dispatcher + var showPassword by rememberSaveable { + mutableStateOf(false) + } + + LaunchedEffect(state.isLoginError) { + while (state.isLoginError) { + delay(3000) + dispatch(Action.ErrorClear) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(ThemeDimensions.spacing.medium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + InternetArchiveHeader( + modifier = Modifier.padding(bottom = ThemeDimensions.spacing.large) + ) + + OutlinedTextField( + value = state.username, + enabled = !state.isBusy, + onValueChange = { dispatch(UpdateUsername(it)) }, + label = { + Text(stringResource(R.string.label_username)) + }, + placeholder = { + Text(stringResource(R.string.placeholder_email_or_username)) + }, + singleLine = true, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + autoCorrect = false, + keyboardType = KeyboardType.Email + ), + isError = state.isUsernameError, + ) + + Spacer(Modifier.height(ThemeDimensions.spacing.large)) + + OutlinedTextField( + value = state.password, + enabled = !state.isBusy, + onValueChange = { dispatch(UpdatePassword(it)) }, + label = { + Text(stringResource(R.string.label_password)) + }, + placeholder = { + Text(stringResource(R.string.placeholder_password)) + }, + singleLine = true, + trailingIcon = { + IconButton(modifier = Modifier.sizeIn(ThemeDimensions.touchable), onClick = { showPassword = !showPassword }) { + Icon( + imageVector = if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = "show password" + ) + } + }, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrect = false, + imeAction = ImeAction.Go + ), + isError = state.isPasswordError, + ) + + AnimatedVisibility( + visible = state.isLoginError, + enter = fadeIn(), + exit = fadeOut() + ) { + Text( + text = stringResource(R.string.error_incorrect_username_or_password), + color = MaterialTheme.colorScheme.error + ) + } + Row( + modifier = Modifier + .padding(top = ThemeDimensions.spacing.small) + .weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.prompt_no_account), + color = ThemeColors.material.onBackground + ) + TextButton( + modifier = Modifier.heightIn(ThemeDimensions.touchable), + onClick = { dispatch(CreateLogin) }) { + Text( + text = stringResource(R.string.label_create_login), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = ThemeDimensions.spacing.medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton( + modifier = Modifier + .weight(1f) + .heightIn(ThemeDimensions.touchable) + .padding(ThemeDimensions.spacing.small), + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { dispatch(Action.Cancel) }) { + Text(stringResource(R.string.action_cancel)) + } + Button( + modifier = Modifier + .heightIn(ThemeDimensions.touchable) + .weight(1f), + enabled = !state.isBusy && state.isValid, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { dispatch(Login) }, + ) { + if (state.isBusy) { + CircularProgressIndicator(color = ThemeColors.material.primary) + } else { + Text(stringResource(R.string.label_login)) + } + } + } + } +} + +@Composable +@Preview(showBackground = true) +private fun InternetArchiveLoginPreview() { + InternetArchiveLoginContent( + state = InternetArchiveLoginState( + username = "user@example.org", password = "abc123" + ) + ) {} +} 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 new file mode 100644 index 00000000..12bdc378 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt @@ -0,0 +1,34 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.login + +import androidx.compose.runtime.Immutable +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive + +@Immutable +data class InternetArchiveLoginState( + val username: String = "", + val password: String = "", + val isUsernameError: Boolean = false, + val isPasswordError: Boolean = false, + val isLoginError: Boolean = false, + val isBusy: Boolean = false, + val isValid: Boolean = false, +) + +sealed interface 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 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 +} 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 new file mode 100644 index 00000000..a04aee8a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt @@ -0,0 +1,65 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.login + +import net.opendasharchive.openarchive.core.presentation.StatefulViewModel +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 { + + 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) + ) + + 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 + } + + override suspend fun effects(state: State, action: Action) { + when (action) { + is Login -> + loginUseCase(state.username, state.password) + .onSuccess { ia -> + notify(LoginSuccess(ia)) + } + .onFailure { dispatch(LoginError(it)) } + + is CreateLogin, is Cancel -> notify(action) + else -> Unit + } + } + +} 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 34e4aefc..b261044d 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,27 +1,25 @@ package net.opendasharchive.openarchive.features.main import android.Manifest -import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.Menu import android.view.MenuItem -import android.widget.ProgressBar +import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.widget.TooltipCompat +import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.ViewPager2 import com.esafirm.imagepicker.features.ImagePickerLauncher import com.google.android.material.snackbar.Snackbar -import com.google.android.material.snackbar.Snackbar.SnackbarLayout -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.opendasharchive.openarchive.FolderAdapter import net.opendasharchive.openarchive.FolderAdapterListener import net.opendasharchive.openarchive.R @@ -30,6 +28,7 @@ import net.opendasharchive.openarchive.SpaceAdapterListener import net.opendasharchive.openarchive.databinding.ActivityMainBinding import net.opendasharchive.openarchive.db.Project import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.extensions.getMeasurments import net.opendasharchive.openarchive.features.core.BaseActivity import net.opendasharchive.openarchive.features.folders.AddFolderActivity import net.opendasharchive.openarchive.features.media.AddMediaDialogFragment @@ -37,23 +36,26 @@ 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.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.ProofModeHelper import net.opendasharchive.openarchive.util.extensions.Position import net.opendasharchive.openarchive.util.extensions.cloak -import net.opendasharchive.openarchive.util.extensions.disableAnimation import net.opendasharchive.openarchive.util.extensions.hide -import net.opendasharchive.openarchive.util.extensions.isVisible -import net.opendasharchive.openarchive.util.extensions.makeSnackBar import net.opendasharchive.openarchive.util.extensions.scaleAndTintDrawable import net.opendasharchive.openarchive.util.extensions.scaled import net.opendasharchive.openarchive.util.extensions.setDrawable import net.opendasharchive.openarchive.util.extensions.show -import net.opendasharchive.openarchive.util.extensions.toggle +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.net.InetSocketAddress +import java.net.Proxy import java.text.NumberFormat +import java.util.concurrent.TimeUnit +import kotlin.math.roundToInt + class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener { @@ -70,6 +72,8 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener private var mLastItem: Int = 0 private var mLastMediaItem: Int = 0 + private var serverListOffset: Float = 0F + private var serverListCurOffset: Float = 0F private var mCurrentItem get() = mBinding.pager.currentItem @@ -105,12 +109,12 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.title = null - mSnackBar = mBinding.root.makeSnackBar(getString(R.string.importing_media)) - (mSnackBar?.view as? SnackbarLayout)?.addView(ProgressBar(this)) +// mSnackBar = mBinding.root.makeSnackBar(getString(R.string.importing_media)) +// (mSnackBar?.view as? SnackbarLayout)?.addView(ProgressBar(this)) - mBinding.uploadEditButton.setOnClickListener { - startActivity(Intent(this, UploadManagerActivity::class.java)) - } +// mBinding.uploadEditButton.setOnClickListener { +// startActivity(Intent(this, UploadManagerActivity::class.java)) +// } mPagerAdapter = ProjectAdapter(supportFragmentManager, lifecycle) mBinding.pager.adapter = mPagerAdapter @@ -129,25 +133,48 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener } updateBottomNavbar(position) + refreshCurrentProject() } override fun onPageScrollStateChanged(state: Int) {} }) - mBinding.space.setOnClickListener { - mBinding.spacesCard.toggle() - mBinding.space.setDrawable( - if (mBinding.spacesCard.isVisible) R.drawable.ic_expand_less else R.drawable.ic_expand_more, - Position.End, - 0.75 - ) + mBinding.spaceName.setOnClickListener { + var newAlpha = 0F + + if (serverListCurOffset != serverListOffset) { + serverListCurOffset = serverListOffset + mBinding.spaceName.setDrawable(R.drawable.ic_expand_more, Position.End, 0.75) + } else { + newAlpha = 1F + serverListCurOffset = 0F + mBinding.spaceName.setDrawable(R.drawable.ic_expand_less, Position.End, 0.75) + } + + mBinding.spaces.visibility = View.VISIBLE + mBinding.currentSpaceName.visibility = View.VISIBLE + mBinding.newFolder.visibility = View.VISIBLE + mBinding.folders.visibility = View.VISIBLE + + mBinding.spaces.animate().translationY(serverListCurOffset).alpha(newAlpha).withEndAction( + Runnable { + run() { + if (newAlpha == 0F) { + mBinding.spaces.hide(false) + } + } + }) + mBinding.currentSpaceName.animate().alpha(1 - newAlpha) + mBinding.newFolder.animate().alpha(1 - newAlpha) + mBinding.folders.animate().alpha(1 - newAlpha) } - mBinding.space.setDrawable( - if (mBinding.spacesCard.isVisible) R.drawable.ic_expand_less else R.drawable.ic_expand_more, - Position.End, - 0.75 - ) + + mBinding.currentSpaceName.text = Space.current?.friendlyName + mBinding.currentSpaceName.setDrawable(Space.current?.getAvatar(applicationContext)?.scaled(32, applicationContext), + Position.Start, tint = true) + mBinding.currentSpaceName.compoundDrawablePadding = + applicationContext.resources.getDimension(R.dimen.padding_small).roundToInt() mSpaceAdapter = SpaceAdapter(this) mBinding.spaces.layoutManager = LinearLayoutManager(this) @@ -207,6 +234,7 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener } } + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) override fun onStart() { super.onStart() @@ -215,7 +243,107 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener UploadService.startUploadService(this) } - requestNotificationPermission() + // requestNotificationPermission() + +// registerReceiver(object : BroadcastReceiver() { +// override fun onReceive(context: Context, intent: Intent) { +// val status = intent.getStringExtra(TorService.EXTRA_STATUS) +// +// Toast.makeText(context, status, Toast.LENGTH_SHORT).show() +// +//// if (status == TorService.STATUS_ON) { +//// CoroutineScope(Dispatchers.IO).launch { +//// connectToRestEndpoint() +//// } +//// } +// } +// }, IntentFilter(TorService.ACTION_STATUS), RECEIVER_NOT_EXPORTED) + +// class StartTor(val appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { +// +// override fun doWork(): Result { +// Timber.d("StartTor") +// bindService(Intent(appContext, TorService::class.java), object : ServiceConnection { +// override fun onServiceConnected(name: ComponentName, service: IBinder) { +// val torService: TorService = (service as TorService.LocalBinder).service +// +// while (torService.torControlConnection == null) { +// try { +// Timber.d("Sleeping") +// Thread.sleep(500) +// } catch (e: InterruptedException) { +// e.printStackTrace() +// } +// } +// +// Toast.makeText( +// this@MainActivity, +// "Got Tor control connection", +// Toast.LENGTH_LONG +// ) +// .show() +// } +// +// override fun onServiceDisconnected(name: ComponentName) { +// // Things... +// } +// }, BIND_AUTO_CREATE) +// +// return Result.success() +// } +// } + +// class UploadMedia(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { +// override fun doWork(): Result { +// Timber.d("UploadMedia") +// val imageUriInput = +// inputData.getString("IMAGE_URI") ?: return Result.failure() +// +// return Result.success() +// } +// } +// +// val startTorRequest = OneTimeWorkRequestBuilder() +// .build() +// +// val uploadMediaRequest = OneTimeWorkRequestBuilder() +// .addTag("media_upload") +// .setInputData(workDataOf( +// "IMAGE_URI" to "http://..." +// )) +// .build() +// +// WorkManager.getInstance(this) +// .enqueue(uploadMediaRequest) + } + + suspend fun connectToRestEndpoint() { + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("localhost", 9050)) + + val client = OkHttpClient.Builder() + .connectTimeout(3000L, TimeUnit.MILLISECONDS) + .proxy(proxy) + .build() + + val request = Request.Builder() + .url("https://jsonplaceholder.typicode.com/todos/1") + .build() + + try { + val response = withContext(Dispatchers.IO) { + client.newCall(request).execute() + } + + val result = if (response.isSuccessful) { + response.body?.string() + } else { + null + } + + Timber.d("result: $result") + } catch (e: Exception) { + Timber.e(e) + } } override fun onResume() { @@ -225,11 +353,20 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener mCurrentItem = mLastItem - if (Space.current?.host.isNullOrEmpty()) { + if (!Prefs.didCompleteOnboarding) { startActivity(Intent(this, Onboarding23Activity::class.java)) } importSharedMedia(intent) + + if (serverListOffset == 0F) { + val dims = mBinding.spaces.getMeasurments() + serverListOffset = -dims.second.toFloat() + serverListCurOffset = serverListOffset + mBinding.spaces.visibility = View.GONE + mBinding.spaces.animate().translationY(serverListOffset) + mBinding.spaceName.setDrawable(R.drawable.ic_expand_more, Position.End, 0.75) + } } private val requestNotificationPermissionLauncher = registerForActivityResult( @@ -242,21 +379,19 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener } private fun requestNotificationPermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != - PackageManager.PERMISSION_GRANTED - ) { - // ask for the permission - requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + // ask for the permission + requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - - importSharedMedia(intent) - } +// override fun onNewIntent(intent: Intent?) { +// super.onNewIntent(intent) +// +// importSharedMedia(intent) +// } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_main, menu) @@ -295,18 +430,18 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener } private fun refreshSpace() { - val currentSpace = Space.current - - if (currentSpace != null) { - mBinding.space.setDrawable( - currentSpace.getAvatar(this) - ?.scaled(32, this), Position.Start, tint = false - ) - mBinding.space.text = currentSpace.friendlyName - } else { - mBinding.space.setDrawable(R.drawable.avatar_default, Position.Start, tint = false) - mBinding.space.text = getString(R.string.app_name) - } +// val currentSpace = Space.current + +// if (currentSpace != null) { +// mBinding.space.setDrawable( +// currentSpace.getAvatar(this@MainActivity) +// ?.scaled(32, this@MainActivity), Position.Start, tint = false +// ) + mBinding.spaceName.text = "Servers" // currentSpace.friendlyName +// } else { +// mBinding.space.setDrawable(R.drawable.avatar_default, Position.Start, tint = false) +// mBinding.space.text = getString(R.string.app_name) +// } mSpaceAdapter.update(Space.getAll().asSequence().toList()) @@ -357,10 +492,10 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener .reduceOrNull { acc, count -> acc + count } ?: 0) mBinding.currentFolderCount.show() - mBinding.uploadEditButton.toggle(project.isUploading) +// mBinding.uploadEditButton.toggle(project.isUploading) } else { mBinding.currentFolderCount.cloak() - mBinding.uploadEditButton.hide() +// mBinding.uploadEditButton.hide() } } @@ -376,10 +511,10 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener mSnackBar?.show() - CoroutineScope(Dispatchers.IO).launch { + lifecycleScope.launch(Dispatchers.IO) { val media = Picker.import(this@MainActivity, getSelectedProject(), uri) - MainScope().launch { + lifecycleScope.launch(Dispatchers.Main) { mSnackBar?.dismiss() intent = null @@ -412,23 +547,22 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener } } - private fun showAlertIcon() { - mBinding.alertIcon.show() - TooltipCompat.setTooltipText( - mBinding.alertIcon, - getString(R.string.unsecured_internet_connection) - ) - } +// private fun showAlertIcon() { +// mBinding.alertIcon.show() +// TooltipCompat.setTooltipText( +// mBinding.alertIcon, +// getString(R.string.unsecured_internet_connection) +// ) +// } - @SuppressLint("NotifyDataSetChanged") override fun projectClicked(project: Project) { mCurrentItem = mPagerAdapter.projects.indexOf(project) - mBinding.root.closeDrawer(mBinding.folderBar) +// mBinding.root.closeDrawer(mBinding.folderBar) - mBinding.spacesCard.disableAnimation { - mBinding.spacesCard.hide() - } +// mBinding.spacesCard.disableAnimation { +// mBinding.spacesCard.hide() +// } // make sure that even when navigating to settings and picking a folder there // the dataset will get update correctly @@ -446,18 +580,14 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener mBinding.root.closeDrawer(mBinding.folderBar) - mBinding.spacesCard.disableAnimation { - mBinding.spacesCard.hide() - } +// mBinding.spacesCard.disableAnimation { +// mBinding.spacesCard.hide() +// } } override fun addSpaceClicked() { mBinding.root.closeDrawer(mBinding.folderBar) - mBinding.spacesCard.disableAnimation { - mBinding.spacesCard.hide() - } - startActivity(Intent(this, SpaceSetupActivity::class.java)) } @@ -496,10 +626,10 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener private fun updateBottomNavbar(position: Int) { if (position == mPagerAdapter.settingsIndex) { - mBinding.myMediaButton.setIconResource(R.drawable.ic_home) + mBinding.myMediaButton.setIconResource(R.drawable.outline_perm_media_24) mBinding.settingsButton.setIconResource(R.drawable.ic_settings_filled) } else { - mBinding.myMediaButton.setIconResource(R.drawable.ic_home_filled) + mBinding.myMediaButton.setIconResource(R.drawable.perm_media_24px) mBinding.settingsButton.setIconResource(R.drawable.ic_settings) } } 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 8aaa3f7e..28311560 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 @@ -4,12 +4,18 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.databinding.FragmentMainMediaBinding import net.opendasharchive.openarchive.databinding.ViewSectionBinding @@ -42,25 +48,26 @@ class MainMediaFragment : Fragment() { private var mAdapters = HashMap() private var mSection = HashMap() private var mProjectId = -1L - private var mCollections = ArrayList() + private var mCollections = mutableMapOf() private lateinit var mBinding: FragmentMainMediaBinding private val mMessageReceiver: BroadcastReceiver = object : BroadcastReceiver() { - + private val handler = Handler(Looper.getMainLooper()) override fun onReceive(context: Context, intent: Intent) { - val action = BroadcastManager.getAction(intent) - val mediaId = action?.mediaId ?: return - - if (mediaId < 0) return + val action = BroadcastManager.getAction(intent) ?: return when (action) { BroadcastManager.Action.Change -> { - updateItem(mediaId) + handler.post { + updateItem(action.collectionId, action.mediaId, action.progress) + } } BroadcastManager.Action.Delete -> { - refresh() + handler.post { + refresh() + } } } } @@ -81,6 +88,7 @@ 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 -> { @@ -108,36 +116,54 @@ class MainMediaFragment : Fragment() { mBinding = FragmentMainMediaBinding.inflate(inflater, container, false) + return mBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + refresh() + } - return mBinding.root + fun updateItem(collectionId: Long, mediaId: Long, progress: Long) { + mAdapters[collectionId]?.apply { + updateItem(mediaId, progress) + if (progress == -1L) { + updateHeader(collectionId, media) + } + } } - fun updateItem(mediaId: Long) { - for (adapter in mAdapters.values) { - if (adapter.updateItem(mediaId)) break + private fun updateHeader(collectionId: Long, media: ArrayList) { + lifecycleScope.launch(Dispatchers.IO) { + Collection.get(collectionId)?.let { collection -> + mCollections[collectionId] = collection + withContext(Dispatchers.Main) { + mSection[collectionId]?.setHeader(collection, media) + } + } } } fun refresh() { - mCollections = ArrayList(Collection.getByProject(mProjectId)) + mCollections = Collection.getByProject(mProjectId).associateBy { it.id }.toMutableMap() // Remove all sections, which' collections don't exist anymore. val toDelete = mAdapters.keys.filter { id -> - mCollections.firstOrNull { it.id == id } == null + mCollections.containsKey(id).not() }.toMutableList() - mCollections.forEach { collection -> + mCollections.forEach { (id, collection) -> val media = collection.media // Also remove all empty collections. if (media.isEmpty()) { - toDelete.add(collection.id) + toDelete.add(id) return@forEach } - val adapter = mAdapters[collection.id] - val holder = mSection[collection.id] + val adapter = mAdapters[id] + val holder = mSection[id] if (adapter != null) { adapter.updateData(media) @@ -153,22 +179,20 @@ class MainMediaFragment : Fragment() { // while adding images. deleteCollections(toDelete, false) - if (::mBinding.isInitialized) { - mBinding.addMediaHint.toggle(mCollections.isEmpty()) - } + mBinding.addMediaHint.toggle(mCollections.isEmpty()) } fun deleteSelected() { val toDelete = ArrayList() - mCollections.forEach { collection -> - if (mAdapters[collection.id]?.deleteSelected() == true) { + mCollections.forEach { (id, collection) -> + if (mAdapters[id]?.deleteSelected() == true) { val media = collection.media if (media.isEmpty()) { toDelete.add(collection.id) } else { - mSection[collection.id]?.setHeader(collection, media) + mSection[id]?.setHeader(collection, media) } } } @@ -207,12 +231,11 @@ class MainMediaFragment : Fragment() { val holder = mSection.remove(collectionId) (holder?.root?.parent as? ViewGroup)?.removeView(holder.root) - val idx = mCollections.indexOfFirst { it.id == collectionId } - - if (idx > -1 && idx < mCollections.size) { - val collection = mCollections.removeAt(idx) - - if (cleanup) collection.delete() + mCollections[collectionId]?.let { + mCollections.remove(collectionId) + if (cleanup) { + it.delete() + } } } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ProjectAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ProjectAdapter.kt index 35e1d99c..8906074f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ProjectAdapter.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ProjectAdapter.kt @@ -19,7 +19,7 @@ class ProjectAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : } val settingsIndex: Int - get() = projects.size + get() = max(1, projects.size) fun updateData(projects: List) { this.projects = projects diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/RestEndpointTask.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/RestEndpointTask.kt new file mode 100644 index 00000000..8f5dc16f --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/RestEndpointTask.kt @@ -0,0 +1,42 @@ +package net.opendasharchive.openarchive.features.main + +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import okhttp3.OkHttpClient +import okhttp3.Request +import java.lang.Exception +import java.net.InetSocketAddress +import java.net.Proxy + +class RestEndpointTask(private val callback: (String?) -> Unit) : Runnable { + private val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("localhost", 9050)) + + override fun run() { + val client = OkHttpClient.Builder() + .proxy(proxy) + .build() + + val request = Request.Builder() + .url("https://jsonplaceholder.typicode.com/todos/1") + .build() + + try { + val response = client.newCall(request).execute() + + val result = if (response.isSuccessful) { + response.body?.string() + } else { + null + } + + Handler(Looper.getMainLooper()).post { + callback(result) + } + } catch (e: Exception) { + Handler(Looper.getMainLooper()).post { + callback(null) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/SectionViewHolder.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/SectionViewHolder.kt index 47391c38..78402f26 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 @@ -37,7 +37,7 @@ data class SectionViewHolder( collection: Collection, media: List ) { - if (media.firstOrNull { it.isUploading } != null) + if (media.any { it.isUploading }) { timestamp.setText(R.string.uploading) @@ -52,6 +52,6 @@ data class SectionViewHolder( val uploadDate = collection.uploadDate - timestamp.text = if (uploadDate != null) mDf.format(uploadDate) else "" + timestamp.text = if (uploadDate != null) mDf.format(uploadDate) else "Ready to upload" } } \ 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 5d726a7a..2aab097b 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 @@ -34,6 +34,7 @@ import net.opendasharchive.openarchive.db.Project import net.opendasharchive.openarchive.util.Utility import net.opendasharchive.openarchive.util.extensions.makeSnackBar import org.witness.proofmode.crypto.HashUtils +//import org.witness.proofmode.crypto.HashUtils import java.io.File import java.util.Date 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 15578dde..a49b405e 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 @@ -12,6 +12,7 @@ import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.databinding.ActivityOnboarding23InstructionsBinding import net.opendasharchive.openarchive.features.core.BaseActivity +import net.opendasharchive.openarchive.util.Prefs class Onboarding23InstructionsActivity : BaseActivity() { @@ -117,6 +118,7 @@ class Onboarding23InstructionsActivity : BaseActivity() { } private fun done() { + Prefs.didCompleteOnboarding = true startActivity(Intent(this, SpaceSetupActivity::class.java)) } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt index a691a311..7080329e 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 @@ -8,9 +8,8 @@ import net.opendasharchive.openarchive.features.core.BaseActivity 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.dropbox.DropboxFragment import net.opendasharchive.openarchive.services.gdrive.GDriveFragment -import net.opendasharchive.openarchive.services.internetarchive.InternetArchiveFragment +import net.opendasharchive.openarchive.features.internetarchive.presentation.InternetArchiveFragment import net.opendasharchive.openarchive.services.internetarchive.Util import net.opendasharchive.openarchive.services.webdav.WebDavFragment @@ -31,7 +30,6 @@ class SpaceSetupActivity : BaseActivity() { initSpaceSetupFragmentBindings() initWebDavFragmentBindings() initSpaceSetupSuccessFragmentBindings() - initDropboxFragmentBindings() initInternetArchiveFragmentBindings() initGDriveFragmentBindings() } @@ -75,15 +73,6 @@ class SpaceSetupActivity : BaseActivity() { SpaceSetupFragment.RESULT_REQUEST_KEY, this ) { _, bundle -> when (bundle.getString(SpaceSetupFragment.RESULT_BUNDLE_KEY)) { - SpaceSetupFragment.RESULT_VAL_DROPBOX -> { - progress2() - supportFragmentManager - .beginTransaction() - .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left) - .replace(mBinding.spaceSetupFragment.id, DropboxFragment(), FRAGMENT_TAG) - .commit() - } - SpaceSetupFragment.RESULT_VAL_INTERNET_ARCHIVE -> { progress2() supportFragmentManager @@ -122,36 +111,6 @@ class SpaceSetupActivity : BaseActivity() { } } - private fun initDropboxFragmentBindings() { - supportFragmentManager.setFragmentResultListener( - DropboxFragment.RESP_CANCEL, - this - ) { _, _ -> - progress1() - supportFragmentManager - .beginTransaction() - .setCustomAnimations(R.anim.slide_in_left, R.anim.slide_out_right) - .replace(mBinding.spaceSetupFragment.id, SpaceSetupFragment(), FRAGMENT_TAG) - .commit() - } - - supportFragmentManager.setFragmentResultListener( - DropboxFragment.RESP_AUTHENTICATED, - this - ) { _, _ -> - progress3() - supportFragmentManager - .beginTransaction() - .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left) - .replace( - mBinding.spaceSetupFragment.id, - SpaceSetupSuccessFragment.newInstance(getString(R.string.you_have_successfully_connected_to_dropbox)), - FRAGMENT_TAG - ) - .commit() - } - } - private fun initInternetArchiveFragmentBindings() { supportFragmentManager.setFragmentResultListener( InternetArchiveFragment.RESP_SAVED, @@ -212,6 +171,7 @@ class SpaceSetupActivity : BaseActivity() { } } + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) @@ -241,4 +201,4 @@ class SpaceSetupActivity : BaseActivity() { Util.setBackgroundTint(mBinding.progressBlock.bar2, R.color.colorSpaceSetupProgressOn) Util.setBackgroundTint(mBinding.progressBlock.dot3, R.color.colorSpaceSetupProgressOn) } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt index bb1cdb20..a85ccc80 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,12 +6,10 @@ import android.view.MenuItem import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat -import info.guardianproject.netcipher.proxy.OrbotHelper import net.opendasharchive.openarchive.CleanInsightsManager import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.databinding.ActivitySettingsContainerBinding import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.util.AlertHelper import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Theme @@ -24,28 +22,28 @@ class GeneralSettingsActivity: BaseActivity() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.prefs_general, rootKey) - findPreference(Prefs.USE_TOR)?.setOnPreferenceChangeListener { _, newValue -> - val activity = activity ?: return@setOnPreferenceChangeListener true - - if (newValue as Boolean) { - if (!OrbotHelper.isOrbotInstalled(activity) && !OrbotHelper.isTorServicesInstalled(activity)) { - AlertHelper.show(activity, - R.string.prefs_install_tor_summary, - R.string.prefs_use_tor_title, - buttons = listOf( - AlertHelper.positiveButton(R.string.action_install) { _, _ -> - activity.startActivity( - OrbotHelper.getOrbotInstallIntent(activity)) - }, - AlertHelper.negativeButton(R.string.action_cancel) - )) - - return@setOnPreferenceChangeListener false - } - } - - true - } +// findPreference(Prefs.USE_TOR)?.setOnPreferenceChangeListener { _, newValue -> +// val activity = activity ?: return@setOnPreferenceChangeListener true +// +// if (newValue as Boolean) { +// if (!OrbotHelper.isOrbotInstalled(activity) && !OrbotHelper.isTorServicesInstalled(activity)) { +// AlertHelper.show(activity, +// R.string.prefs_install_tor_summary, +// R.string.prefs_use_tor_title, +// buttons = listOf( +// AlertHelper.positiveButton(R.string.action_install) { _, _ -> +// activity.startActivity( +// OrbotHelper.getOrbotInstallIntent(activity)) +// }, +// AlertHelper.negativeButton(R.string.action_cancel) +// )) +// +// return@setOnPreferenceChangeListener false +// } +// } +// +// true +// } findPreference("proof_mode")?.setOnPreferenceClickListener { startActivity(Intent(context, ProofModeSettingsActivity::class.java)) @@ -69,18 +67,18 @@ class GeneralSettingsActivity: BaseActivity() { true } - mCiConsentPref = findPreference("health_checks") - - mCiConsentPref?.setOnPreferenceChangeListener { _, newValue -> - if (newValue as? Boolean == false) { - CleanInsightsManager.deny() - } - else { - startActivity(Intent(context, ConsentActivity::class.java)) - } - - true - } +// mCiConsentPref = findPreference("health_checks") +// +// mCiConsentPref?.setOnPreferenceChangeListener { _, newValue -> +// if (newValue as? Boolean == false) { +// CleanInsightsManager.deny() +// } +// else { +// startActivity(Intent(context, ConsentActivity::class.java)) +// } +// +// true +// } } override fun onResume() { 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 8822b3a0..6b781930 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 @@ -9,12 +9,16 @@ import android.os.Bundle import android.provider.Settings import android.view.MenuItem import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import com.permissionx.guolindev.PermissionX +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.databinding.ActivitySettingsContainerBinding import net.opendasharchive.openarchive.features.core.BaseActivity @@ -31,6 +35,14 @@ class ProofModeSettingsActivity: BaseActivity() { class Fragment: PreferenceFragmentCompat() { + private val enrollBiometrics = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + findPreference(Prefs.USE_PROOFMODE_KEY_ENCRYPTION)?.let { + MainScope().launch { + enableProofModeKeyEncryption(it) + } + } + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.prefs_proof_mode, rootKey) @@ -67,8 +79,9 @@ class ProofModeSettingsActivity: BaseActivity() { val pkePreference = findPreference(Prefs.USE_PROOFMODE_KEY_ENCRYPTION) val activity = activity + val availability = Hbks.deviceAvailablity(requireContext()) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && activity != null && Hbks.deviceSecured(activity)) { + if (activity != null && availability !is Hbks.Availability.Unavailable) { pkePreference?.isSingleLineTitle = false pkePreference?.setTitle(when (Hbks.biometryType(activity)) { @@ -81,23 +94,10 @@ class ProofModeSettingsActivity: BaseActivity() { pkePreference?.setOnPreferenceChangeListener { _, newValue -> if (newValue as Boolean) { - val key = Hbks.loadKey() ?: Hbks.createKey() - - if (key != null && Prefs.proofModeEncryptedPassphrase == null) { - createPassphrase(key, activity) { - if (it != null) { - ProofModeHelper.removePgpKey(activity) - - // We need to kill the app and restart, - // since the ProofMode singleton loads the passphrase - // in its singleton constructor. Urgh. - ProofModeHelper.restartApp(activity) - } else { - Hbks.removeKey() - - pkePreference.isChecked = false - } - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && availability is Hbks.Availability.Enroll) { + enrollBiometrics.launch(Hbks.enrollIntent(availability.type)) + } else { + enableProofModeKeyEncryption(pkePreference) } } else { @@ -117,6 +117,34 @@ class ProofModeSettingsActivity: BaseActivity() { pkePreference?.isVisible = false } } + + private fun enableProofModeKeyEncryption(pkePreference: SwitchPreferenceCompat) { + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return + } + + val key = Hbks.loadKey() ?: Hbks.createKey() + + if (key != null && Prefs.proofModeEncryptedPassphrase == null) { + createPassphrase(key, activity) { + if (it != null) { + ProofModeHelper.removePgpKey(requireContext()) + + // We need to kill the app and restart, + // since the ProofMode singleton loads the passphrase + // in its singleton constructor. Urgh. + ProofModeHelper.restartApp(requireActivity()) + } else { + Hbks.removeKey() + + pkePreference.isChecked = false + } + } + } else { + // What?? shouldn't happen if enrolled with a PIN or Fingerprint + } + } } private lateinit var mBinding: ActivitySettingsContainerBinding 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 02ebd64d..10d63fa4 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 @@ -10,9 +10,8 @@ import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.databinding.FragmentSettingsBinding import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.services.dropbox.DropboxActivity +import net.opendasharchive.openarchive.features.internetarchive.presentation.InternetArchiveActivity import net.opendasharchive.openarchive.services.gdrive.GDriveActivity -import net.opendasharchive.openarchive.services.internetarchive.InternetArchiveActivity import net.opendasharchive.openarchive.services.webdav.WebDavActivity import net.opendasharchive.openarchive.util.extensions.Position import net.opendasharchive.openarchive.util.extensions.getVersionName @@ -90,12 +89,18 @@ class SettingsFragment : Fragment() { private fun updateSpace() { val context = context ?: return - val space = Space.current ?: return + val space = Space.current - mBinding.btSpace.text = space.friendlyName + if (space != null) { + mBinding.btSpace.text = space.friendlyName - mBinding.btSpace.setDrawable(space.getAvatar(context)?.scaled(32, context), - Position.Start, tint = false) + mBinding.btSpace.setDrawable( + space.getAvatar(context)?.scaled(24, context), + Position.Start, tint = true + ) + } else { + mBinding.btSpace.visibility = View.GONE + } } private fun startSpaceAuthActivity() { @@ -103,7 +108,6 @@ class SettingsFragment : Fragment() { val clazz = when (space.tType) { Space.Type.INTERNET_ARCHIVE -> InternetArchiveActivity::class.java - Space.Type.DROPBOX -> DropboxActivity::class.java Space.Type.GDRIVE -> GDriveActivity::class.java else -> WebDavActivity::class.java } 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 1f2bf28e..4ef288a7 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 @@ -1,5 +1,6 @@ package net.opendasharchive.openarchive.features.settings +import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -7,10 +8,9 @@ import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResult -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability import net.opendasharchive.openarchive.databinding.FragmentSpaceSetupBinding import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.main.MainActivity import net.opendasharchive.openarchive.util.extensions.hide class SpaceSetupFragment : Fragment() { @@ -27,45 +27,43 @@ class SpaceSetupFragment : Fragment() { setFragmentResult(RESULT_REQUEST_KEY, bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_WEBDAV)) } - if (Space.has(Space.Type.DROPBOX)) { - mBinding.dropbox.hide() - } else { - mBinding.dropbox.setOnClickListener { - setFragmentResult( - RESULT_REQUEST_KEY, - bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_DROPBOX) - ) - } - } +// if (Space.has(Space.Type.INTERNET_ARCHIVE)) { +// mBinding.internetArchive.hide() +// } else { +// mBinding.internetArchive.setOnClickListener { +// setFragmentResult( +// RESULT_REQUEST_KEY, +// bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_INTERNET_ARCHIVE) +// ) +// } +// } - if (Space.has(Space.Type.INTERNET_ARCHIVE)) { - mBinding.internetArchive.hide() - } else { - mBinding.internetArchive.setOnClickListener { - setFragmentResult( - RESULT_REQUEST_KEY, - bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_INTERNET_ARCHIVE) - ) - } - } +// if (Space.has(Space.Type.GDRIVE) || !playServicesAvailable()) { +// mBinding.gdrive.hide() +// } else { +// mBinding.gdrive.setOnClickListener { +// setFragmentResult( +// RESULT_REQUEST_KEY, +// bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_GDRIVE) +// ) +// } +// } - if (Space.has(Space.Type.GDRIVE) || !playServicesAvailable()) { - mBinding.gdrive.hide() - } else { - mBinding.gdrive.setOnClickListener { - setFragmentResult( - RESULT_REQUEST_KEY, - bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_GDRIVE) - ) - } - } +// mBinding.skipForNowButton.setOnClickListener { +// skipSpaceConfig() +// } return mBinding.root } + private fun skipSpaceConfig() { + startActivity(Intent(context, MainActivity::class.java)) + } + private fun playServicesAvailable(): Boolean { - return ConnectionResult.SUCCESS == GoogleApiAvailability.getInstance() - .isGooglePlayServicesAvailable(requireContext()) + return true +// return ConnectionResult.SUCCESS == GoogleApiAvailability.getInstance() +// .isGooglePlayServicesAvailable(requireContext()) } companion object { diff --git a/app/src/main/java/net/opendasharchive/openarchive/provider/TorStatusContentProvider.kt b/app/src/main/java/net/opendasharchive/openarchive/provider/TorStatusContentProvider.kt new file mode 100644 index 00000000..ae50e04d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/provider/TorStatusContentProvider.kt @@ -0,0 +1,84 @@ +package net.opendasharchive.openarchive.provider + +import android.content.ContentProvider +import android.content.ContentUris +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri + +class TorStatusContentProvider : ContentProvider() { + companion object { + private const val AUTHORITY = "org.opendasharchive.safe.provider.tor" + const val TABLE_NAME = "status" + const val CONTENT_URI = "content://$AUTHORITY/$TABLE_NAME" + } + + private lateinit var database: TorStatusDatabase + + override fun onCreate(): Boolean { + database = TorStatusDatabase.getInstance(context!!) + return true + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor { + val db = database.readableDatabase + val cursor = db.query( + TABLE_NAME, + projection, + selection, + selectionArgs, + null, + null, + sortOrder + ) + cursor.setNotificationUri(context?.contentResolver, uri) + return cursor + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + val db = database.writableDatabase + val rowId = db.insert(TABLE_NAME, null, values) + if (rowId != -1L) { + context?.contentResolver?.notifyChange(uri, null) + return ContentUris.withAppendedId(uri, rowId) + } + return null + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int { + val db = database.writableDatabase + val rowsUpdated = db.update(TABLE_NAME, values, selection, selectionArgs) + if (rowsUpdated > 0) { + context?.contentResolver?.notifyChange(uri, null) + } + return rowsUpdated + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + val db = database.writableDatabase + val rowsDeleted = db.delete(TABLE_NAME, selection, selectionArgs) + if (rowsDeleted > 0) { + context?.contentResolver?.notifyChange(uri, null) + } + return rowsDeleted + } + + override fun getType(uri: Uri): String? { + // Implement this method to return the MIME type of the data at the given URI + // For example: return "vnd.android.cursor.item/my_table" for a single item + // or "vnd.android.cursor.dir/my_table" for multiple items + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/provider/TorStatusDatabase.kt b/app/src/main/java/net/opendasharchive/openarchive/provider/TorStatusDatabase.kt new file mode 100644 index 00000000..97772e73 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/provider/TorStatusDatabase.kt @@ -0,0 +1,29 @@ +package net.opendasharchive.openarchive.provider + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class TorStatusDatabase private constructor(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { + + companion object { + private const val DATABASE_NAME = "status.db" + private const val DATABASE_VERSION = 1 + + @Volatile + private var instance: TorStatusDatabase? = null + + fun getInstance(context: Context): TorStatusDatabase { + return instance ?: synchronized(this) { + instance ?: TorStatusDatabase(context.applicationContext).also { instance = it } + } + } + } + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL("CREATE TABLE ${TorStatusContentProvider.TABLE_NAME} (id primary_key)") + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + } +} \ 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 95909af3..4d1f2f1e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -10,7 +10,6 @@ import com.google.gson.GsonBuilder import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.services.dropbox.DropboxConduit import net.opendasharchive.openarchive.services.gdrive.GDriveConduit import net.opendasharchive.openarchive.services.internetarchive.IaConduit import net.opendasharchive.openarchive.services.webdav.WebDavConduit @@ -37,7 +36,6 @@ abstract class Conduit( protected var mCancelled = false - /** * Gives a SiteController a chance to add metadata to the intent resulting from the ChooseAccounts process * that gets passed to each SiteController during publishing @@ -51,7 +49,6 @@ abstract class Conduit( mCancelled = true } - fun getProof(): Array { if (!Prefs.useProofMode) return emptyArray() @@ -91,11 +88,11 @@ abstract class Conduit( * build an embed tag, etc. for some sites this might be a URL */ fun jobSucceeded() { - mMedia.progress = 100 + mMedia.progress = mMedia.contentLength mMedia.sStatus = Media.Status.Uploaded mMedia.save() - BroadcastManager.postChange(mContext, mMedia.id) + BroadcastManager.postChange(mContext, mMedia.collectionId, mMedia.id) } fun jobFailed(exception: Throwable) { @@ -109,24 +106,12 @@ abstract class Conduit( Timber.d(exception) - BroadcastManager.postChange(mContext, mMedia.id) + BroadcastManager.postChange(mContext, mMedia.collectionId, mMedia.id) } - // track when the last progress broadcast was sent, timestamp - // we use this to limit the rate of sending out these broadcasts - private var lastProgressBroadcast = 0L - fun jobProgress(uploadedBytes: Long) { - // making sure we're not writing to the database more often than (1000/150=)~7 times a second. - // jobProgress is getting called up to several hundred times a second. - if (System.currentTimeMillis() > lastProgressBroadcast + 150) { - lastProgressBroadcast = System.currentTimeMillis() - - mMedia.progress = uploadedBytes - mMedia.save() - - BroadcastManager.postChange(mContext, mMedia.id) - } + mMedia.progress = uploadedBytes + BroadcastManager.postProgress(mContext, mMedia.collectionId, mMedia.id, uploadedBytes) } /** @@ -248,8 +233,6 @@ abstract class Conduit( Space.Type.WEBDAV -> WebDavConduit(media, context) - Space.Type.DROPBOX -> DropboxConduit(media, context) - Space.Type.GDRIVE -> GDriveConduit(media, context) else -> null diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt index 95802892..1dc4be86 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt @@ -2,9 +2,6 @@ package net.opendasharchive.openarchive.services import android.content.Context import android.content.Intent -import com.dropbox.core.DbxRequestConfig -import com.dropbox.core.http.OkHttp3Requestor -import com.dropbox.core.v2.DbxClientV2 import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import info.guardianproject.netcipher.client.StrongBuilder import info.guardianproject.netcipher.client.StrongBuilderBase @@ -35,9 +32,9 @@ class SaveClient(context: Context) : StrongBuilderBase okBuilder = OkHttpClient.Builder() .addInterceptor(cacheInterceptor) - .connectTimeout(20L, TimeUnit.SECONDS) - .writeTimeout(20L, TimeUnit.SECONDS) - .readTimeout(20L, TimeUnit.SECONDS) + .connectTimeout(40L, TimeUnit.SECONDS) + .writeTimeout(40L, TimeUnit.SECONDS) + .readTimeout(40L, TimeUnit.SECONDS) .retryOnConnectionFailure(false) .protocols(arrayListOf(Protocol.HTTP_1_1)) } @@ -141,16 +138,6 @@ class SaveClient(context: Context) : StrongBuilderBase } } - suspend fun getDropbox(context: Context, accessToken: String?): DbxClientV2 { - val client = get(context) - - val requestConfig = DbxRequestConfig.newBuilder("dbc") - .withHttpRequestor(OkHttp3Requestor(client)) - .build() - - return DbxClientV2(requestConfig, accessToken) - } - suspend fun getSardine(context: Context, space: Space): OkHttpSardine { val sardine = OkHttpSardine(get(context)) sardine.setCredentials(space.username, space.password) @@ -158,4 +145,4 @@ class SaveClient(context: Context) : StrongBuilderBase return sardine } } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/DropboxActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/DropboxActivity.kt deleted file mode 100644 index b8721e0d..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/DropboxActivity.kt +++ /dev/null @@ -1,58 +0,0 @@ -package net.opendasharchive.openarchive.services.dropbox - -import android.os.Bundle -import android.view.MenuItem -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityDropboxBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.util.AlertHelper - -class DropboxActivity: BaseActivity() { - - private lateinit var mBinding: ActivityDropboxBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - var space: Space? = null - - if (intent.hasExtra(EXTRA_DATA_SPACE)) { - space = Space.get(intent.getLongExtra(EXTRA_DATA_SPACE, -1L)) - } - - mBinding = ActivityDropboxBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - mBinding.btRemove.setOnClickListener { - if (space != null) removeSpace(space) - } - - setSupportActionBar(mBinding.toolbar) - supportActionBar?.title = getString(R.string.dropbox) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - mBinding.dropboxId.setText(space?.username ?: "") - } - - - // boilerplate to make back button in app bar work - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - return super.onOptionsItemSelected(item) - } - - - private fun removeSpace(space: Space) { - AlertHelper.show(this, R.string.are_you_sure_you_want_to_remove_this_server_from_the_app, R.string.remove_from_app, buttons = listOf( - AlertHelper.positiveButton(R.string.remove) { _, _ -> - space.delete() - - Space.navigate(this) - }, - AlertHelper.negativeButton())) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/DropboxConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/DropboxConduit.kt deleted file mode 100644 index bd1aac2f..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/DropboxConduit.kt +++ /dev/null @@ -1,225 +0,0 @@ -package net.opendasharchive.openarchive.services.dropbox - -import android.content.Context -import com.dropbox.core.NetworkIOException -import com.dropbox.core.RetryException -import com.dropbox.core.v2.DbxClientV2 -import com.dropbox.core.v2.files.CommitInfo -import com.dropbox.core.v2.files.CreateFolderErrorException -import com.dropbox.core.v2.files.FileMetadata -import com.dropbox.core.v2.files.UploadSessionAppendErrorException -import com.dropbox.core.v2.files.UploadSessionCursor -import com.dropbox.core.v2.files.UploadSessionFinishErrorException -import com.dropbox.core.v2.files.WriteMode -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.services.Conduit -import net.opendasharchive.openarchive.services.SaveClient - -class DropboxConduit(media: Media, context: Context) : Conduit(media, context) { - - companion object { - const val NAME = "Dropbox" - const val HOST = "dropbox.com" - const val MAX_RETRIES = 5 - } - - private lateinit var mClient: DbxClientV2 - - override suspend fun upload(): Boolean { - val accessToken = mMedia.space?.password ?: return false - val path = getPath() ?: return false - - mClient = SaveClient.getDropbox(mContext, accessToken) - - val fileName = getUploadFileName(mMedia) - - sanitize() - - var result: FileMetadata? = null - - try { - createFolders(null, path) - - uploadMetadata(path, fileName) - - if (mCancelled) throw Exception("Cancelled") - - val destination = construct(path, fileName) - - if (mMedia.contentLength > CHUNK_FILESIZE_THRESHOLD) { - result = uploadChunked(destination) - } else { - execute { - mMedia.file.inputStream().use { inputStream -> - result = mClient.files() - .uploadBuilder(destination) - .withMode(WriteMode.OVERWRITE) - .withClientModified(mMedia.createDate) - .uploadAndFinish(inputStream) - } - } - } - } catch (e: Throwable) { - jobFailed(e) - - return false - } - - if (result == null) { - jobFailed(Exception("Empty result")) - - return false - } - - mMedia.serverUrl = result!!.pathDisplay - jobSucceeded() - - return true - } - - override suspend fun createFolder(url: String) { - execute { - try { - mClient.files().createFolderV2(url) - } catch (e: CreateFolderErrorException) { - // Ignore. Already existing. - } - } - } - - private suspend fun uploadMetadata(path: List, fileName: String) { - val metadata = getMetadata() - - if (mCancelled) throw java.lang.Exception("Cancelled") - - execute { - metadata.byteInputStream().use { - mClient.files() - .uploadBuilder(construct(path, "$fileName.meta.json")) - .withMode(WriteMode.OVERWRITE) - .uploadAndFinish(it) - } - } - - for (file in getProof()) { - if (mCancelled) throw java.lang.Exception("Cancelled") - - execute { - file.inputStream().use { - mClient.files() - .uploadBuilder(construct(path, file.name)) - .withMode(WriteMode.OVERWRITE) - .uploadAndFinish(it) - } - } - } - } - - private suspend fun uploadChunked(destination: String): FileMetadata? { - var result: FileMetadata? = null - - mMedia.file.inputStream().use { inputStream -> - // Write first chunk, get upload session ID. - - val sessionId = mClient.files().uploadSessionStart() - .uploadAndFinish(inputStream, CHUNK_SIZE) - .sessionId - - var offset = CHUNK_SIZE - jobProgress(offset) - - // Write middle chunks. - while (mMedia.contentLength - offset > CHUNK_SIZE) { - execute(offset) { skip -> - if (skip != 0L) { - withContext(Dispatchers.IO) { - offset += inputStream.skip(skip) - } - } - - val cursor = UploadSessionCursor(sessionId, offset) - - mClient.files().uploadSessionAppendV2(cursor) - .uploadAndFinish(inputStream, CHUNK_SIZE) - } - - offset += CHUNK_SIZE - jobProgress(offset) - } - - // Write last chunk and make file available. - val info = CommitInfo.newBuilder(destination) - .withMode(WriteMode.OVERWRITE) - .withClientModified(mMedia.createDate) - .build() - - execute(offset) { skip -> - if (skip != 0L) { - withContext(Dispatchers.IO) { - offset += inputStream.skip(skip) - } - } - - val remaining = mMedia.contentLength - offset - - result = mClient.files() - .uploadSessionFinish(UploadSessionCursor(sessionId, offset), info) - .uploadAndFinish(inputStream, remaining) - } - } - - return result - } - - /** - * method for retrying the `body` code block based on drop box exceptions - */ - private suspend fun execute(offset: Long? = null, body: suspend (skip: Long) -> Unit) { - var skip = 0L - var retries = 0 - - while (true) { - try { - body(skip) - - break - } catch (e: RetryException) { - if (retries < MAX_RETRIES) { - delay(e.backoffMillis) - } else { - throw e - } - } catch (e: UploadSessionAppendErrorException) { - if (offset != null && retries < MAX_RETRIES && e.errorValue.isIncorrectOffset) { - skip = e.errorValue.incorrectOffsetValue.correctOffset - offset - } else { - throw e - } - } catch (e: UploadSessionFinishErrorException) { - if (offset != null && retries < MAX_RETRIES - && e.errorValue.isLookupFailed - && e.errorValue.lookupFailedValue.isIncorrectOffset - ) { - skip = - e.errorValue.lookupFailedValue.incorrectOffsetValue.correctOffset - offset - } else { - throw e - } - } catch (e: NetworkIOException) { - if (retries < MAX_RETRIES) { - // Ignore network problems. We try up to 5 times. - // When doing chunking, we just try to send the next chunk. - // Dropbox will tell us, if that next chunk's offset was wrong - // and we will send it one more time again with the fixed offset. - } else { - throw e - } - } - - retries++ - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/DropboxFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/DropboxFragment.kt deleted file mode 100644 index d7516310..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/DropboxFragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -package net.opendasharchive.openarchive.services.dropbox - -import android.os.Bundle -import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import com.dropbox.core.android.Auth -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.CleanInsightsManager -import net.opendasharchive.openarchive.databinding.FragmentDropboxBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.services.SaveClient - -class DropboxFragment : Fragment() { - - private lateinit var mBinding: FragmentDropboxBinding - private var mAwaitingAuth = false - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mBinding = FragmentDropboxBinding.inflate(inflater) - - mBinding.error.visibility = View.GONE - - mBinding.btBack.setOnClickListener { - // finish() - setFragmentResult(RESP_CANCEL, bundleOf()) - } - - mBinding.btAuthenticate.setOnClickListener { - mBinding.error.visibility = View.GONE - authenticate() - } - - return mBinding.root - } - - override fun onResume() { - super.onResume() - - if (!mAwaitingAuth) return - - val accessToken = Auth.getOAuth2Token() ?: return - - mAwaitingAuth = false - - CoroutineScope(Dispatchers.IO).launch { - try { - val client = SaveClient.getDropbox(requireContext(), accessToken) - - val username = try { - client.users()?.currentAccount?.email ?: Auth.getUid() - } catch (e: Exception) { - Auth.getUid() - } - - val space = Space(Space.Type.DROPBOX) - username?.let { space.username = it } - space.password = accessToken - space.save() - Space.current = space - - CleanInsightsManager.getConsent(requireActivity()) { - CleanInsightsManager.measureEvent("backend", "new", Space.Type.DROPBOX.friendlyName) - } - - MainScope().launch { - setFragmentResult(RESP_AUTHENTICATED, bundleOf()) - } - } catch (e: Exception) { - MainScope().launch { - mBinding.error.text = e.localizedMessage - mBinding.error.visibility = View.VISIBLE - } - } - } - } - - private fun authenticate() { - Auth.startOAuth2Authentication(requireContext(), "gd5sputfo57s1l1") - - mAwaitingAuth = true - } - - companion object { - const val RESP_CANCEL = "dropbox_fragment_resp_cancel" - const val RESP_AUTHENTICATED = "dropbox_fragment_resp_authenticated" - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/UriHelpers.kt b/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/UriHelpers.kt deleted file mode 100644 index 3c2584a4..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/dropbox/UriHelpers.kt +++ /dev/null @@ -1,121 +0,0 @@ -package net.opendasharchive.openarchive.services.dropbox - -import android.content.ContentUris -import android.content.Context -import android.database.Cursor -import android.net.Uri -import android.os.Environment -import android.provider.DocumentsContract -import android.provider.MediaStore -import java.io.File - -/** - * Utility functions to support Uri conversion and processing. - */ -object UriHelpers { - - /** - * Get the file path for a uri. This is a convoluted way to get the path for an Uri created using the - * StorageAccessFramework. This in no way is the official way to do this but there does not seem to be a better - * way to do this at this point. It is taken from https://github.com/iPaulPro/aFileChooser. - * - * @param context The context of the application - * @param uri The uri of the saved file - * @return The file with path pointing to the saved file. It can return null if we can't resolve the uri properly. - */ - - fun getFileForUri(context: Context, uri: Uri): File? { - var path: String? = null - // DocumentProvider - if (DocumentsContract.isDocumentUri(context, uri)) { - // ExternalStorageProvider - if (isExternalStorageDocument(uri)) { - val docId = DocumentsContract.getDocumentId(uri) - val split = docId.split(":").toTypedArray() - val type = split[0] - if ("primary".equals(type, ignoreCase = true)) { - path = "${Environment.getExternalStorageDirectory()}/${split[1]}" - } - } else if (isDownloadsDocument(uri)) { - // DownloadsProvider - val id = DocumentsContract.getDocumentId(uri) - val contentUri = ContentUris - .withAppendedId( - Uri.parse("content://downloads/public_downloads"), - java.lang.Long.valueOf(id) - ) - path = getDataColumn(context, contentUri, null, null) - } else if (isMediaDocument(uri)) { - // MediaProvider - val docId = DocumentsContract.getDocumentId(uri) - val split = docId.split(":").toTypedArray() - val contentUri = when (split[0]) { - "image" -> { - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } - "video" -> { - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } - "audio" -> { - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - } - else -> { - Uri.EMPTY - } - } - val selection = "_id=?" - val selectionArgs = arrayOf( - split[1] - ) - path = getDataColumn(context, contentUri, selection, selectionArgs) - } - } else if ("content".equals(uri.scheme, ignoreCase = true)) { - // MediaStore (and general) - path = getDataColumn(context, uri, null, null) - } else if ("file".equals(uri.scheme, ignoreCase = true)) { - // File - path = uri.path - } - return if (path != null) { - File(path) - } else null - } - - private fun getDataColumn( - context: Context, uri: Uri, selection: String?, - selectionArgs: Array? - ): String? { - var cursor: Cursor? = null - val column = "_data" - val projection = arrayOf( - column - ) - try { - cursor = context.contentResolver.query( - uri, projection, selection, selectionArgs, - null - ) - if (cursor != null && cursor.moveToFirst()) { - val columnIndex = cursor.getColumnIndexOrThrow(column) - return cursor.getString(columnIndex) - } - } finally { - cursor?.close() - } - return null - } - - - private fun isExternalStorageDocument(uri: Uri): Boolean { - return "com.android.externalstorage.documents" == uri.authority - } - - private fun isDownloadsDocument(uri: Uri): Boolean { - return "com.android.providers.downloads.documents" == uri.authority - } - - private fun isMediaDocument(uri: Uri): Boolean { - return "com.android.providers.media.documents" == uri.authority - } - -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveConduit.kt index b925b6ee..2420e134 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveConduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveConduit.kt @@ -266,4 +266,4 @@ class GDriveConduit(media: Media, context: Context) : Conduit(media, context) { Timber.e("gdrive upload of '$targetFileName' failed", e) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFragment.kt index 44a22239..4598d2fe 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 @@ -84,6 +84,7 @@ class GDriveFragment : Fragment() { } } + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) @@ -107,13 +108,13 @@ class GDriveFragment : Fragment() { space.save() Space.current = space - CleanInsightsManager.getConsent(requireActivity()) { - CleanInsightsManager.measureEvent( - "backend", - "new", - Space.Type.GDRIVE.friendlyName - ) - } +// CleanInsightsManager.getConsent(requireActivity()) { +// CleanInsightsManager.measureEvent( +// "backend", +// "new", +// Space.Type.GDRIVE.friendlyName +// ) +// } MainScope().launch { setFragmentResult(RESP_AUTHENTICATED, bundleOf()) 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 1b3322d6..6c7ddda2 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 @@ -2,14 +2,15 @@ package net.opendasharchive.openarchive.services.internetarchive import android.content.Context import android.net.Uri -import com.google.gson.Gson +import com.google.gson.GsonBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.services.Conduit import net.opendasharchive.openarchive.services.SaveClient import okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okio.BufferedSink import java.io.File import java.io.IOException @@ -26,36 +27,47 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { private fun getSlug(title: String): String { return title.replace("[^A-Za-z\\d]".toRegex(), "-") } + + val textMediaType = "texts".toMediaTypeOrNull() + + private val gson = GsonBuilder().excludeFieldsWithoutExposeAnnotation().create() } override suspend fun upload(): Boolean { sanitize() try { - val mediaUri = mMedia.originalFilePath val mimeType = mMedia.mimeType - // TODO this should make sure we aren't accidentally using one of archive.org's metadata fields by accident - val slug = getSlug(mMedia.title) - var basePath = "$slug-${Util.RandomString(4).nextString()}" - val url = "$ARCHIVE_API_ENDPOINT/$basePath/" + getUploadFileName(mMedia, true) - val requestBody = getRequestBody(mMedia, mediaUri, mimeType.toMediaTypeOrNull(), basePath) + val client = SaveClient.get(mContext) - put(url, requestBody, mainHeader()) + val fileName = getUploadFileName(mMedia, true) + val metaJson = gson.toJson(mMedia) +// val proof = getProof() + + if (mMedia.serverUrl.isBlank()) { + // TODO this should make sure we aren't accidentally using one of archive.org's metadata fields by accident + val slug = getSlug(mMedia.title) + val newIdentifier = "$slug-${Util.RandomString(4).nextString()}" + // create an identifier for the upload + mMedia.serverUrl = newIdentifier + } - /// Upload metadata - basePath = "$slug-${Util.RandomString(4).nextString()}" + // upload content synchronously for progress + client.uploadContent(fileName, mimeType) - uploadMetaData(Gson().toJson(mMedia), basePath, getUploadFileName(mMedia, true)) + // upload metadata and proofs async, and report failures + client.uploadMetaData(metaJson, fileName) /// Upload ProofMode metadata, if enabled and successfully created. - for (file in getProof()) { - uploadProofFiles(file, basePath) - } +// for (file in proof) { +// client.uploadProofFiles(file) +// } + + jobSucceeded() return true - } - catch (e: Exception) { + } catch (e: Throwable) { jobFailed(e) } @@ -66,91 +78,69 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { // Ignored. Not used here. } - @Throws(IOException::class) - private suspend fun uploadMetaData(content: String, basePath: String, fileName: String) { - val requestBody = object : RequestBody() { - override fun contentType(): MediaType? { - return "texts".toMediaTypeOrNull() - } + private suspend fun OkHttpClient.uploadContent(fileName: String, mimeType: String) { + val mediaUri = mMedia.originalFilePath - override fun writeTo(sink: BufferedSink) { - sink.writeString(content, Charsets.UTF_8) - } - } + val url = "${ARCHIVE_API_ENDPOINT}/${mMedia.serverUrl}/$fileName" - put( - "$ARCHIVE_API_ENDPOINT/$basePath/$fileName.meta.json", - requestBody, - metadataHeader() + val requestBody = RequestBodyUtil.create( + mContext.contentResolver, + Uri.parse(mediaUri), + mMedia.contentLength, + mimeType.toMediaTypeOrNull(), + createListener(cancellable = { !mCancelled }, onProgress = { + jobProgress(it) + }) ) + + val request = Request.Builder() + .url(url) + .put(requestBody) + .headers(mainHeader()) + .build() + + execute(request) } - /// upload proof mode @Throws(IOException::class) - private suspend fun uploadProofFiles(uploadFile: File, basePath: String) { - val requestBody = getRequestBodyMetaData( - uploadFile, - Uri.fromFile(uploadFile).toString(), - "texts".toMediaTypeOrNull() + private fun OkHttpClient.uploadMetaData(content: String, fileName: String) { + val requestBody = RequestBodyUtil.create( + textMediaType, + content.byteInputStream(), + content.length.toLong(), + createListener(cancellable = { !mCancelled }) ) - put("$ARCHIVE_API_ENDPOINT/$basePath/${uploadFile.name}", - requestBody, - metadataHeader()) - } + val url = "${ARCHIVE_API_ENDPOINT}/${mMedia.serverUrl}/$fileName.meta.json" - private fun getRequestBody(media: Media, mediaUri: String?, mediaType: MediaType?, basePath: String): RequestBody { - return RequestBodyUtil.create( - mContext.contentResolver, - Uri.parse(mediaUri), - media.contentLength, - mediaType, - object : RequestListener { - var lastBytes: Long = 0 - override fun transferred(bytes: Long) { - if (bytes > lastBytes) { - jobProgress(bytes) - lastBytes = bytes - } - } - - override fun continueUpload(): Boolean { - return !mCancelled - } + val request = Request.Builder() + .url(url) + .put(requestBody) + .headers(metadataHeader()) + .build() - override fun transferComplete() { - val finalPath = ARCHIVE_DETAILS_ENDPOINT + basePath - media.serverUrl = finalPath - jobSucceeded() - } - }) + enqueue(request) } - /// request body for meta data - private fun getRequestBodyMetaData(media: File, mediaUri: String, mediaType: MediaType?): RequestBody { - return RequestBodyUtil.create( + /// upload proof mode + @Throws(IOException::class) + private fun OkHttpClient.uploadProofFiles(uploadFile: File) { + val requestBody = RequestBodyUtil.create( mContext.contentResolver, - Uri.parse(mediaUri), - media.length(), - mediaType, - object : RequestListener { - var lastBytes: Long = 0 - - override fun transferred(bytes: Long) { - if (bytes > lastBytes) { - jobProgress(bytes) - lastBytes = bytes - } - } + Uri.fromFile(uploadFile), + uploadFile.length(), + textMediaType, createListener(cancellable = { !mCancelled }) + ) - override fun continueUpload(): Boolean { - return !mCancelled - } + val url = "$ARCHIVE_API_ENDPOINT/${mMedia.serverUrl}/${uploadFile.name}" - override fun transferComplete() { - jobSucceeded() - } - }) + val request = Request.Builder() + .url(url) + .put(requestBody) + .headers(metadataHeader()) + .build() + + enqueue(request) } private fun mainHeader(): Headers { @@ -167,6 +157,10 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { builder.add("x-archive-meta-author", author) } + if (mMedia.contentLength > 0) { + builder.add("x-archive-size-hint", mMedia.contentLength.toString()) + } + val collection = when { mMedia.mimeType.startsWith("video") -> "opensource_movies" mMedia.mimeType.startsWith("audio") -> "opensource_audio" @@ -219,7 +213,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") // 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") @@ -227,33 +221,29 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { } @Throws(Exception::class) - private suspend fun put(url: String, requestBody: RequestBody, headers: Headers) { - val request = Request.Builder() - .url(url) - .put(requestBody) - .headers(headers) - .build() + private suspend fun OkHttpClient.execute(request: Request) = withContext(Dispatchers.IO) { + val result = newCall(request) + .execute() - execute(request) + if (result.isSuccessful.not()) { + throw RuntimeException("${result.code}: ${result.message}") + } } @Throws(Exception::class) - private suspend fun execute(request: Request) { - SaveClient.get(mContext) - .newCall(request) + private fun OkHttpClient.enqueue(request: Request) { + newCall(request) .enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { jobFailed(e) } override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - jobSucceeded() - } - else { - jobFailed(Exception("${response.code} ${response.message}")) + if (!response.isSuccessful) { + jobFailed(Exception("${response.code}: ${response.message}")) } } + }) } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowFragment.kt deleted file mode 100644 index 215202e6..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowFragment.kt +++ /dev/null @@ -1,99 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.Lifecycle -import androidx.viewpager2.adapter.FragmentStateAdapter -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentIaLearnHowBinding - -class IaLearnHowFragment : BottomSheetDialogFragment() { - private lateinit var mBinding: FragmentIaLearnHowBinding - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - - // make sure this bottom sheet is expanded on start - dialog.setOnShowListener { - val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) - bottomSheet?.let { - BottomSheetBehavior.from(it).state = BottomSheetBehavior.STATE_EXPANDED - } - } - return dialog - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - // Inflate the layout for this fragment - mBinding = FragmentIaLearnHowBinding.inflate(inflater) - - mBinding.viewPager.adapter = LearnHowAdapter( - requireActivity().supportFragmentManager, - requireActivity().lifecycle - ) - mBinding.dotsIndicator.attachTo(mBinding.viewPager) - - mBinding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - if (mBinding.viewPager.currentItem + 1 < mBinding.viewPager.adapter!!.itemCount) { - mBinding.nextButton.setText(R.string.next) - } else { - mBinding.nextButton.setText(R.string.done) - } - } - }) - - mBinding.nextButton.setOnClickListener { - if (mBinding.viewPager.currentItem + 1 < mBinding.viewPager.adapter!!.itemCount) { - mBinding.viewPager.currentItem++ - } else { - dismiss() - } - } - - return mBinding.root - } - - class LearnHowAdapter( - fragmentManager: FragmentManager, - lifecycle: Lifecycle - ) : FragmentStateAdapter(fragmentManager, lifecycle) { - - override fun getItemCount(): Int { - return 3 - } - - override fun createFragment(position: Int): Fragment { - when (position) { - 0 -> return IaLearnHowStepFragment.newInstance( - R.string.ia_learn_how_summary_step_1, - R.drawable.ia_learn_how_illustration1 - ) - - 1 -> return IaLearnHowStepFragment.newInstance( - R.string.ia_learn_how_summary_step_2, - R.drawable.ia_learn_how_illustration2 - ) - - 2 -> return IaLearnHowStepFragment.newInstance( - R.string.ia_learn_how_summary_step_3, - R.drawable.ia_learn_how_illustration3 - ) - } - throw IndexOutOfBoundsException() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowStepFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowStepFragment.kt deleted file mode 100644 index 4e981194..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowStepFragment.kt +++ /dev/null @@ -1,43 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.fragment.app.Fragment -import net.opendasharchive.openarchive.databinding.FragmentIaLearnHowStepBinding - -class IaLearnHowStepFragment : Fragment() { - - private lateinit var mBinding: FragmentIaLearnHowStepBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mBinding = FragmentIaLearnHowStepBinding.inflate(inflater) - - arguments?.let { - mBinding.summary.text = getString(it.getInt(ARG_SUMMARY_STRING_RES)) - mBinding.illustration.setImageResource(it.getInt(ARG_ILLUSTRATION_DRAWABLE_RES)) - } - - return mBinding.root - } - - companion object { - const val ARG_SUMMARY_STRING_RES = "summary" - const val ARG_ILLUSTRATION_DRAWABLE_RES = "illustration" - - fun newInstance(@StringRes summary: Int, @DrawableRes illustration: Int): IaLearnHowStepFragment { - return IaLearnHowStepFragment().apply { - arguments = Bundle().apply { - putInt(ARG_SUMMARY_STRING_RES, summary) - putInt(ARG_ILLUSTRATION_DRAWABLE_RES, illustration) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaScrapeActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaScrapeActivity.kt deleted file mode 100644 index 3716cddb..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaScrapeActivity.kt +++ /dev/null @@ -1,211 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.annotation.SuppressLint -import android.content.DialogInterface -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.webkit.JavascriptInterface -import android.webkit.WebView -import android.webkit.WebViewClient -import android.widget.Toast -import info.guardianproject.netcipher.webkit.WebkitProxy -import kotlinx.coroutines.* -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityIaScrapeBinding -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.services.SaveClient -import net.opendasharchive.openarchive.services.internetarchive.Util.clearWebviewAndCookies -import net.opendasharchive.openarchive.util.AlertHelper -import net.opendasharchive.openarchive.util.extensions.show -import timber.log.Timber -import java.net.InetSocketAddress -import java.net.Proxy -import java.util.regex.Pattern - -class IaScrapeActivity : BaseActivity() { - - private lateinit var mBinding: ActivityIaScrapeBinding - - private var mAccessResult = RESULT_CANCELED - private var mAccessKey: String? = null - private var mSecretKey: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivityIaScrapeBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setSupportActionBar(mBinding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - val doRegister = intent.getBooleanExtra("register", false) - - login(if (doRegister) { ARCHIVE_CREATE_ACCOUNT_URL } else { ARCHIVE_LOGIN_URL }) - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_refresh, menu) - - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - - true - } - - R.id.action_refresh -> { - mBinding.webView.reload() - - true - } - - else -> super.onOptionsItemSelected(item) - } - } - - @SuppressLint("SetJavaScriptEnabled") - private fun login(currentURL: String) { - mBinding.webView.settings.javaScriptEnabled = true - mBinding.webView.addJavascriptInterface(JSInterface(), "htmlout") - mBinding.webView.show() - - mBinding.webView.webViewClient = object : WebViewClient() { - - @Deprecated("Deprecated in Java") - override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { - - // If logged in, redirect to credentials. - if (url == ARCHIVE_LOGGED_IN_URL) { - view.loadUrl(ARCHIVE_CREDENTIALS_URL) - return true - } - - return false - } - - override fun onPageFinished(view: WebView, url: String) { - super.onPageFinished(view, url) - - //if credentials page, inject JS for scraping. - when (url) { - ARCHIVE_CREDENTIALS_URL -> { - sIsLoginScreen = true - val jsCmd = StringBuffer() - jsCmd.append("javascript:(function(){") - jsCmd.append("window.htmlout.processHTML(''+document.getElementsByTagName('html')[0].innerHTML+'');") - jsCmd.append("document.getElementById('confirm').checked=true;") - jsCmd.append("document.getElementById('generateNewKeys').click();") - jsCmd.append("})();") - mBinding.webView.loadUrl(jsCmd.toString()) - } - ARCHIVE_CREATE_ACCOUNT_URL -> { - sIsLoginScreen = false - } - IaConduit.ARCHIVE_BASE_URL -> { - view.loadUrl(ARCHIVE_CREDENTIALS_URL) - } - } - } - } - - CoroutineScope(Dispatchers.IO).launch { - try { - val client = SaveClient.get(this@IaScrapeActivity) - - if (client.proxy?.type() == Proxy.Type.HTTP || client.proxy?.type() == Proxy.Type.SOCKS) { - val address = client.proxy?.address() as? InetSocketAddress - - if (address != null) { - WebkitProxy.setProxy(applicationContext,address.hostString, address.port) - } - } - - MainScope().launch { - mBinding.webView.loadUrl(currentURL) - } - } - catch (e: Exception) { - MainScope().launch { - Toast.makeText(this@IaScrapeActivity, e.localizedMessage, Toast.LENGTH_LONG).show() - - finish() - } - } - } - } - - private fun parseArchiveCredentials(rawHtml: String) { - try { - val pattern = Pattern.compile("
(.+?)
") - val matcher = pattern.matcher(rawHtml) - - if (matcher.find()) { - mAccessKey = matcher.group(1)?.split(":".toRegex())?.get(1)?.trim { it <= ' ' } - } - - if (matcher.find()) { - mSecretKey = matcher.group(1)?.split(":".toRegex())?.get(1)?.trim { it <= ' ' } - } - } - catch (e: Exception) { - Timber.d(e, "Unable to get site S3 credentials.") - } - } - - internal inner class JSInterface { - @JavascriptInterface - fun processHTML(html: String?) { - if (null == html) return - - if (sIsLoginScreen) { - parseArchiveCredentials(html) - if (mAccessKey != null && mSecretKey != null) { - mAccessResult = RESULT_OK - finish() - } - } - else if (html.contains("Verification Email Sent")) { - showAccountCreatedDialog { _, _ -> finish() } - } - } - } - - private fun showAccountCreatedDialog(positiveBtnClickListener: DialogInterface.OnClickListener) { - AlertHelper.show(this, R.string.archive_message, R.string.archive_title, buttons = listOf( - AlertHelper.positiveButton { dialog, which -> - positiveBtnClickListener.onClick(dialog, which) - })) - } - - override fun finish() { - Timber.d("finish()") - - val data = Intent() - data.putExtra(EXTRAS_KEY_USERNAME, mAccessKey) - data.putExtra(EXTRAS_KEY_CREDENTIALS, mSecretKey) - setResult(mAccessResult, data) - - super.finish() - - clearWebviewAndCookies(mBinding.webView) - } - - companion object { - private const val ARCHIVE_CREATE_ACCOUNT_URL = "https://archive.org/account/login.createaccount.php" - private const val ARCHIVE_LOGIN_URL = "https://archive.org/account/login.php" - private const val ARCHIVE_LOGGED_IN_URL = "https://archive.org/index.php" - private const val ARCHIVE_CREDENTIALS_URL = "https://archive.org/account/s3.php" - - const val EXTRAS_KEY_USERNAME = "username" - const val EXTRAS_KEY_CREDENTIALS = "credentials" - - private var sIsLoginScreen = false - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveActivity.kt deleted file mode 100644 index 057f6289..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveActivity.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.content.Intent -import android.os.Bundle -import android.view.* -import androidx.fragment.app.commit -import com.google.android.material.snackbar.Snackbar -import net.opendasharchive.openarchive.databinding.ActivityInternetArchiveBinding -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 InternetArchiveActivity : BaseActivity() { - - private var mSpaceId by Delegates.notNull() - private lateinit var mSpace: Space - private lateinit var mBinding: ActivityInternetArchiveBinding - private var mSnackbar: Snackbar? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivityInternetArchiveBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setSupportActionBar(mBinding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - mSpaceId = intent.getLongExtra(EXTRA_DATA_SPACE, InternetArchiveFragment.ARG_VAL_NEW_SPACE) - - if (mSpaceId != InternetArchiveFragment.ARG_VAL_NEW_SPACE) { - supportFragmentManager.commit { - replace(mBinding.internetArchiveFragment.id, InternetArchiveFragment.newInstance(mSpaceId)) - } - } - - supportFragmentManager.setFragmentResultListener(InternetArchiveFragment.RESP_SAVED, this) { _, _ -> - finishAffinity() - startActivity(Intent(this, MainActivity::class.java)) - } - supportFragmentManager.setFragmentResultListener(InternetArchiveFragment.RESP_DELETED, this) { _, _ -> - Space.navigate(this) - } - } - - 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/internetarchive/InternetArchiveFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveFragment.kt deleted file mode 100644 index fad7a484..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveFragment.kt +++ /dev/null @@ -1,392 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.content.Intent -import android.os.Bundle -import android.view.KeyEvent -import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.widget.TextView -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.CleanInsightsManager -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentInternetArchiveBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.services.SaveClient -import net.opendasharchive.openarchive.util.AlertHelper -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.extensions.Position -import net.opendasharchive.openarchive.util.extensions.makeSnackBar -import net.opendasharchive.openarchive.util.extensions.setDrawable -import okhttp3.Call -import okhttp3.Callback -import okhttp3.Request -import okhttp3.Response -import org.xmlpull.v1.XmlPullParser -import org.xmlpull.v1.XmlPullParserException -import org.xmlpull.v1.XmlPullParserFactory -import java.io.IOException -import java.io.InputStream -import kotlin.coroutines.suspendCoroutine - -class InternetArchiveFragment : Fragment() { - - private lateinit var mSnackbar: Snackbar - private lateinit var mSpace: Space - private var mSpaceId: Long = ARG_VAL_NEW_SPACE - private lateinit var mBinding: FragmentInternetArchiveBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mBinding = FragmentInternetArchiveBinding.inflate(inflater) - - mSpaceId = arguments?.getLong(ARG_SPACE) ?: ARG_VAL_NEW_SPACE - - if (ARG_VAL_NEW_SPACE != mSpaceId) { - mSpace = Space.get(mSpaceId) ?: Space(Space.Type.INTERNET_ARCHIVE) - - mBinding.header.visibility = View.GONE - - mBinding.accessKey.isEnabled = false - mBinding.secretKey.isEnabled = false - - mBinding.btAcquireKeys.isEnabled = false - - mBinding.btRemove.setDrawable(R.drawable.ic_delete, Position.Start, 0.5) - mBinding.btRemove.visibility = View.VISIBLE - mBinding.btRemove.setOnClickListener { - removeProject() - } - - mBinding.buttonBar.visibility = View.GONE - } - else { - mSpace = Space(Space.Type.INTERNET_ARCHIVE) - } - - mBinding.accessKey.setText(mSpace.username) - mBinding.secretKey.setText(mSpace.password) - - mBinding.secretKey.setOnEditorActionListener { _: TextView?, id: Int, _: KeyEvent? -> - if (id == EditorInfo.IME_ACTION_DONE || id == EditorInfo.IME_NULL) { - attemptLogin() - return@setOnEditorActionListener true - } - false - } - - mBinding.btAcquireKeys.setOnClickListener { - acquireKeys() - } - - mBinding.btLearnHow.setOnClickListener { - Prefs.iaHintShown = false - showFirstTimeIa() - } - - mBinding.btBack.setOnClickListener { - setFragmentResult(RESP_CANCEL, bundleOf()) - } - - mBinding.btNext.setOnClickListener { - attemptLogin() - } - - showFirstTimeIa() - - return mBinding.root - } - - private val mAcquireKeysResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode != AppCompatActivity.RESULT_OK) { - return@registerForActivityResult - } - - val username = it.data?.getStringExtra(IaScrapeActivity.EXTRAS_KEY_USERNAME) - val credentials = it.data?.getStringExtra(IaScrapeActivity.EXTRAS_KEY_CREDENTIALS) - - mBinding.accessKey.setText(username) - mBinding.secretKey.setText(credentials) - } - - private fun acquireKeys() { - mAcquireKeysResultLauncher.launch(Intent(requireContext(), IaScrapeActivity::class.java)) - } - - /** - * Attempts to sign in or register the account specified by the login form. - * If there are form errors (invalid email, missing fields, etc.), the - * errors are presented and no actual login attempt is made. - */ - private fun attemptLogin() { - // Store values at the time of the login attempt. - mSpace.username = mBinding.accessKey.text.toString() - mSpace.password = mBinding.secretKey.text.toString() - var focusView: View? = null - - // Check for a valid password, if the user entered one. - if (mSpace.password.isEmpty()) { - mBinding.secretKey.error = getString(R.string.error_field_required) - focusView = mBinding.secretKey - } - - // Check for a valid password, if the user entered one. - if (mSpace.username.isEmpty()) { - mBinding.accessKey.error = getString(R.string.error_field_required) - focusView = mBinding.accessKey - } - - if (focusView != null) { - // There was an error; don't attempt login and focus the first form field with an error. - focusView.requestFocus() - Toast.makeText(requireContext(), getString(R.string.IA_login_error), Toast.LENGTH_SHORT).show() - - return - } - - // Show a progress spinner, and kick off a background task to - // perform the user login attempt. - mSnackbar = mBinding.root.makeSnackBar(getString(R.string.login_activity_logging_message)) - mSnackbar.show() - - CoroutineScope(Dispatchers.IO).launch { - try { - testConnection() - - mSpace.save() - - Space.current = mSpace - - CleanInsightsManager.getConsent(requireActivity()) { - CleanInsightsManager.measureEvent("backend", "new", Space.Type.INTERNET_ARCHIVE.friendlyName) - } - - mSnackbar.dismiss() - - setFragmentResult(RESP_SAVED, bundleOf()) - } - catch (exception: IOException) { - if (exception.message?.startsWith("401") == true) { - showError(getString(R.string.error_incorrect_username_or_password), true) - } else { - showError(exception.localizedMessage ?: getString(R.string.error)) - } - } - } - } - - /** - * Unfortunately, this test actually only tests if the `access key` is correct. - * We can provide any `secret key` to the IA's S3 API. - * - * I couldn't find a test which proofs the latter, too, short of `PUT`ing an asset on their - * server. Which is a really bad idea, considering that we cannot `DELETE` the created bucket again. - */ - private suspend fun testConnection() { - val url = mSpace.hostUrl ?: throw IOException("400 Bad Request") - - val client = SaveClient.get(requireContext()) - - val request = Request.Builder() - .url(url) - .method("GET", null) - .addHeader("Authorization", "LOW ${mSpace.username}:${mSpace.password}") - .build() - - return suspendCoroutine { - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - it.resumeWith(Result.failure(e)) - } - - override fun onResponse(call: Call, response: Response) { - val code = response.code - val message = response.message - - val username = getUsername(response.body?.byteStream()) - - response.close() - - if (code != 200 && code != 204) { - return it.resumeWith(Result.failure(IOException("$code $message"))) - } - - if (username == null) { - return it.resumeWith(Result.failure(IOException("401 Unauthorized"))) - } - - mSpace.displayname = username - - it.resumeWith(Result.success(Unit)) - } - }) - } - } - - /** - * Parses the usernome out of an XML document which starts like this: - * - * ``` - * - * - * OpaqueIDStringGoesHere - * Readable ID Goes Here - * - * - * ``` - * - * The username is expected in the first `DisplayName` tag in the first `Owner` tag in the - * first `ListAllMyBucketsResult` tag. - * - * A username of `"Readable ID Goes Here".lowercase()` is considered not to be a username. - * (That's what the Internet Archive S3 API should return, if authorization was unsuccessful.) - */ - private fun getUsername(body: InputStream?): String? { - if (body == null) return null - - try { - val xpp = XmlPullParserFactory.newInstance().newPullParser() - xpp.setInput(body, null) - - var eventType = xpp.eventType - - var container = false - var owner = false - var displayName = false - - while (eventType != XmlPullParser.END_DOCUMENT) { - when (eventType) { - XmlPullParser.START_TAG -> { - when (xpp.name) { - "ListAllMyBucketsResult" -> { - container = true - } - "Owner" -> { - if (container) owner = true - } - "DisplayName" -> { - if (container && owner) displayName = true - } - } - } - XmlPullParser.END_TAG -> { - when (xpp.name) { - "ListAllMyBucketsResult" -> { - // Almost done anyway. - return null - } - "Owner" -> { - // It should be the first "Owner" element. - // If that went by without a "DisplayName" element, stop it. - if (container) return null - } - "DisplayName" -> { - // If the first "DisplayName" element in the first "Owner" - // element doesn't have a name, stop it. - if (container && owner) return null - } - } - } - XmlPullParser.TEXT -> { - if (container && owner && displayName) { - val username = xpp.text.trim() - - // If the access key wasn't correct, a dummy username is displayed. Ignore. - if (username.isBlank() ) { - return null - } - - // according to brenton@archive.org: - // > I just confirmed with our engineer that that response is a correct, - // > non-error response if you haven’t uploaded any items yet. It’s - // > strange text, but it’s not an error. So, his suggestion is to try - // > uploading something! - if ( username.lowercase() == "Readable ID Goes Here".lowercase() ) { - return getString(R.string.new_user) - } - - // Yay! Found a username! - return username - } - } - } - - eventType = xpp.next() - } - } - catch (e: XmlPullParserException) { - // ignore - } - catch (e: IOException) { - // ignore - } - - return null - } - - private fun showError(text: CharSequence, onForm: Boolean = false) { - requireActivity().runOnUiThread { - mSnackbar.dismiss() - - if (onForm) { - mBinding.secretKey.error = text - mBinding.secretKey.requestFocus() - } - else { - mSnackbar = mBinding.root.makeSnackBar(text, Snackbar.LENGTH_LONG) - mSnackbar.show() - - mBinding.accessKey.requestFocus() - } - } - } - - 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) { _, _ -> - mSpace.delete() - setFragmentResult(RESP_DELETED, bundleOf()) - }, - AlertHelper.negativeButton())) - } - - private fun showFirstTimeIa() { - if (Prefs.iaHintShown) return - - val f = IaLearnHowFragment() - f.show(requireActivity().supportFragmentManager, f.tag) - - Prefs.iaHintShown = true - } - - companion object { - const val ARG_SPACE = "space" - const val ARG_VAL_NEW_SPACE = -1L - - const val RESP_SAVED = "ia_fragment_resp_saved" - const val RESP_DELETED = "ia_dav_fragment_resp_deleted" - const val RESP_CANCEL = "ia_fragment_resp_cancel" - - @JvmStatic - fun newInstance(spaceId: Long) = InternetArchiveFragment().apply { - arguments = Bundle().apply { - putLong(ARG_SPACE, spaceId) - } - } - - @JvmStatic - fun newInstance() = newInstance(ARG_VAL_NEW_SPACE) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt index 352200d2..ddab48a9 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt @@ -1,30 +1,38 @@ package net.opendasharchive.openarchive.services.internetarchive -import okio.source -import okhttp3.internal.closeQuietly -import okhttp3.RequestBody -import kotlin.Throws -import okio.BufferedSink import android.content.ContentResolver import android.net.Uri import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.internal.closeQuietly +import okio.BufferedSink import okio.Source +import okio.source import timber.log.Timber import java.io.* +fun createListener(cancellable: () -> Boolean, onProgress: (Long) -> Unit = {}, onComplete: () -> Unit = {}) = object : RequestListener { + override fun transferred(bytes: Long) = onProgress(bytes) + + override fun continueUpload() = cancellable() + + override fun transferComplete() = onComplete() +} + /** * Created by n8fr8 on 12/29/17. */ object RequestBodyUtil { - fun create(mediaType: MediaType?, inputStream: InputStream): RequestBody { + + fun create(mediaType: MediaType?, inputStream: InputStream, contentLength: Long? = null, + listener: RequestListener?): RequestBody { return object : RequestBody() { - override fun contentType(): MediaType? { - return mediaType - } + override fun contentType() = mediaType override fun contentLength(): Long { return try { - inputStream.available().toLong() + contentLength ?: inputStream.available().toLong() } catch (e: IOException) { Timber.i("BodyRequestUtil couldn't get contentLength, returning 0 instead", e) 0 @@ -36,7 +44,7 @@ object RequestBodyUtil { var source: Source? = null try { source = inputStream.source() - sink.writeAll(source) + sink.writeAll(source, listener) } finally { source!!.closeQuietly() } @@ -66,41 +74,42 @@ object RequestBodyUtil { } } - override fun contentType(): MediaType? { - return mediaType - } + override fun contentType() = mediaType - override fun contentLength(): Long { - return contentLength - } + override fun contentLength() = contentLength @Synchronized @Throws(IOException::class) override fun writeTo(sink: BufferedSink) { init() - val source = inputStream!!.source() - if (mListener == null) { - sink.writeAll(source) - } else { - try { - var total: Long = 0 - var read: Long - while (source.read(sink.buffer, SEGMENT_SIZE.toLong()).also { - read = it - } != -1L && mListener != null && mListener!!.continueUpload()) { - total += read - if (mListener != null) mListener!!.transferred(total) - sink.flush() - } - mListener!!.transferComplete() - } finally { - source.closeQuietly() - } + var source: Source? = null + try { + source = inputStream!!.source() + sink.writeAll(source, listener) + } finally { + source?.closeQuietly() } } } } + fun BufferedSink.writeAll(source: Source, listener: RequestListener?) { + if (listener == null) { + writeAll(source) + } else { + var total: Long = 0 + var read: Long + while (source.read(buffer, SEGMENT_SIZE.toLong()).also { + read = it + } != -1L && listener.continueUpload()) { + total += read + listener.transferred(total) + flush() + } + listener.transferComplete() + } + } + fun create(fileSource: File, mediaType: MediaType?, listener: RequestListener?): RequestBody { return object : RequestBody() { var inputStream: InputStream? = null @@ -112,13 +121,9 @@ object RequestBodyUtil { } } - override fun contentType(): MediaType? { - return mediaType - } + override fun contentType() = mediaType - override fun contentLength(): Long { - return fileSource.length() - } + override fun contentLength() = fileSource.length() @Synchronized @Throws(IOException::class) @@ -145,4 +150,4 @@ object RequestBodyUtil { } } } -} \ 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 df0a0567..c824d2f4 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 @@ -37,7 +37,11 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { return false } - if (space.useChunking && mMedia.contentLength > CHUNK_FILESIZE_THRESHOLD) { +// if (space.useChunking && mMedia.contentLength > CHUNK_FILESIZE_THRESHOLD) { +// return uploadChunked(base, path, fileName) +// } + + if (mMedia.contentLength > CHUNK_FILESIZE_THRESHOLD) { return uploadChunked(base, path, fileName) } @@ -181,12 +185,12 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { "text/plain", null) /// Upload ProofMode metadata, if enabled and successfully created. - for (file in getProof()) { - if (mCancelled) throw Exception("Cancelled") - - mClient.put( - construct(base, path, file.name), file, "text/plain", - false, null) - } +// for (file in getProof()) { +// if (mCancelled) throw Exception("Cancelled") +// +// mClient.put( +// construct(base, path, file.name), file, "text/plain", +// false, null) +// } } } \ No newline at end of file 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 0e3d586d..8404a7b7 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 @@ -67,11 +67,11 @@ class WebDavFragment : Fragment() { mBinding.username.setText(mSpace.username) mBinding.password.setText(mSpace.password) - mBinding.swChunking.isChecked = mSpace.useChunking - mBinding.swChunking.setOnCheckedChangeListener { _, useChunking -> - mSpace.useChunking = useChunking - mSpace.save() - } +// mBinding.swChunking.isChecked = mSpace.useChunking +// mBinding.swChunking.setOnCheckedChangeListener { _, useChunking -> +// mSpace.useChunking = useChunking +// mSpace.save() +// } mBinding.name.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { @@ -168,7 +168,7 @@ class WebDavFragment : Fragment() { mSpace.username = mBinding.username.text?.toString() ?: "" mSpace.password = mBinding.password.text?.toString() ?: "" - mSpace.useChunking = mBinding.swChunking.isChecked +// mSpace.useChunking = mBinding.swChunking.isChecked if (mSpace.host.isEmpty()) { mBinding.server.error = getString(R.string.error_field_required) @@ -205,9 +205,9 @@ class WebDavFragment : Fragment() { mSpace.save() Space.current = mSpace - CleanInsightsManager.getConsent(requireActivity()) { - CleanInsightsManager.measureEvent("backend", "new", Space.Type.WEBDAV.friendlyName) - } +// CleanInsightsManager.getConsent(requireActivity()) { +// CleanInsightsManager.measureEvent("backend", "new", Space.Type.WEBDAV.friendlyName) +// } setFragmentResult(RESP_SAVED, bundleOf()) } catch (exception: IOException) { diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/BroadcastManager.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/BroadcastManager.kt index 7d3ec687..4b078e9f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/BroadcastManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/BroadcastManager.kt @@ -8,18 +8,30 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager object BroadcastManager { - enum class Action(val id: String, var mediaId: Long = -1) { + enum class Action(val id: String, var collectionId: Long = -1, var mediaId: Long = -1, var progress: Long = -1) { Change("media_change_intent"), Delete("media_delete_intent") } private const val MEDIA_ID = "media_id" + private const val COLLECTION_ID = "collection_id" + private const val MEDIA_PROGRESS = "media_progress" - fun postChange(context: Context, mediaId: Long) { + fun postChange(context: Context, collectionId: Long, mediaId: Long) { val i = Intent(Action.Change.id) i.putExtra(MEDIA_ID, mediaId) + i.putExtra(COLLECTION_ID, collectionId) - LocalBroadcastManager.getInstance(context).sendBroadcast(i) + LocalBroadcastManager.getInstance(context).sendBroadcastSync(i) + } + + fun postProgress(context: Context, collectionId: Long, mediaId: Long, progress: Long) { + val i = Intent(Action.Change.id) + i.putExtra(MEDIA_ID, mediaId) + i.putExtra(COLLECTION_ID, collectionId) + i.putExtra(MEDIA_PROGRESS, progress) + + LocalBroadcastManager.getInstance(context).sendBroadcastSync(i) } fun postDelete(context: Context, mediaId: Long) { @@ -30,8 +42,10 @@ object BroadcastManager { } fun getAction(intent: Intent): Action? { - val action = Action.values().firstOrNull { it.id == intent.action } + val action = Action.entries.firstOrNull { it.id == intent.action } action?.mediaId = intent.getLongExtra(MEDIA_ID, -1) + action?.collectionId = intent.getLongExtra(COLLECTION_ID, -1) + action?.progress = intent.getLongExtra(MEDIA_PROGRESS, -1) return action } @@ -47,4 +61,4 @@ object BroadcastManager { fun unregister(context: Context, receiver: BroadcastReceiver) { LocalBroadcastManager.getInstance(context).unregisterReceiver(receiver) } -} \ No newline at end of file +} 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 72474e0c..bc464488 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt @@ -20,6 +20,39 @@ class UploadManagerActivity : BaseActivity() { var mFrag: UploadManagerFragment? = null private var mMenuEdit: MenuItem? = null + private val mMessageReceiver: BroadcastReceiver = object : BroadcastReceiver() { + private val handler = Handler(Looper.getMainLooper()) + + override fun onReceive(context: Context, intent: Intent) { + val action = BroadcastManager.getAction(intent) + val mediaId = action?.mediaId ?: return + + if (mediaId > -1) { + val media = Media.get(mediaId) + + if (action == BroadcastManager.Action.Delete || media?.sStatus == Media.Status.Uploaded) { + handler.post { mFrag?.removeItem(mediaId) } + } + else { + handler.post { mFrag?.updateItem(mediaId) } + } + +// if (media?.sStatus == Media.Status.Error) { +// CleanInsightsManager.getConsent(this@UploadManagerActivity) { +// // TODO: Record metadata. See iOS implementation. +// CleanInsightsManager.measureEvent("upload", "upload_failed") +// } +// } + } + + handler.post { + updateTitle() + } + } + } + + private var mEditMode = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,37 +80,6 @@ class UploadManagerActivity : BaseActivity() { BroadcastManager.unregister(this, mMessageReceiver) } - private val mMessageReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val action = BroadcastManager.getAction(intent) - val mediaId = action?.mediaId ?: return - - if (mediaId > -1) { - val media = Media.get(mediaId) - - if (action == BroadcastManager.Action.Delete || media?.sStatus == Media.Status.Uploaded) { - mFrag?.removeItem(mediaId) - } - else { - 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(Looper.getMainLooper()).post { - updateTitle() - } - } - } - - private var mEditMode = false - private fun toggleEditMode() { mEditMode = !mEditMode mFrag?.setEditMode(mEditMode) 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 ac9e8037..cef0814b 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerFragment.kt @@ -26,7 +26,6 @@ open class UploadManagerFragment : Fragment() { private lateinit var mBinding: FragmentUploadManagerBinding - private lateinit var mItemTouchHelper: ItemTouchHelper override fun onCreateView( @@ -92,7 +91,7 @@ open class UploadManagerFragment : Fragment() { } open fun updateItem(mediaId: Long) { - mediaAdapter?.updateItem(mediaId) + mediaAdapter?.updateItem(mediaId, -1) } open fun removeItem(mediaId: Long) { diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt index 9b0cca8b..bbdb9d37 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt @@ -11,12 +11,14 @@ import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build -import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.work.Configuration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import net.opendasharchive.openarchive.CleanInsightsManager import net.opendasharchive.openarchive.R @@ -28,6 +30,39 @@ import timber.log.Timber import java.io.IOException import java.util.* +//class StartTor(val appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { +// +// override fun doWork(): Result { +// Timber.d("StartTor") +// bindService(Intent(appContext, TorService::class.java), object : ServiceConnection { +// override fun onServiceConnected(name: ComponentName, service: IBinder) { +// val torService: TorService = (service as TorService.LocalBinder).service +// +// while (torService.torControlConnection == null) { +// try { +// Timber.d("Sleeping") +// Thread.sleep(500) +// } catch (e: InterruptedException) { +// e.printStackTrace() +// } +// } +// +//// Toast.makeText( +//// this@MainActivity, +//// "Got Tor control connection", +//// Toast.LENGTH_LONG +// } +//// ).show() +// +// override fun onServiceDisconnected(name: ComponentName) { +// // Things... +// } +// }, BIND_AUTO_CREATE) +// +// return Result.success() +// } +//} + class UploadService : JobService() { companion object { @@ -37,19 +72,23 @@ class UploadService : JobService() { fun startUploadService(activity: Activity) { val jobScheduler = ContextCompat.getSystemService(activity, JobScheduler::class.java) ?: return + var jobBuilder = JobInfo.Builder( MY_BACKGROUND_JOB, ComponentName(activity, UploadService::class.java) ).setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { jobBuilder = jobBuilder.setUserInitiated(true) } + jobScheduler.schedule(jobBuilder.build()) } fun stopUploadService(context: Context) { val jobScheduler = ContextCompat.getSystemService(context, JobScheduler::class.java) ?: return + jobScheduler.cancel(MY_BACKGROUND_JOB) } } @@ -57,15 +96,41 @@ class UploadService : JobService() { private var mRunning = false private var mKeepUploading = true private val mConduits = ArrayList() + private lateinit var notification: Notification + +// private val constraints = Constraints.Builder() override fun onCreate() { super.onCreate() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) createNotificationChannel() + createNotificationChannel() + notification = prepNotification() Configuration.Builder().setJobSchedulerJobIdRange(0, Integer.MAX_VALUE).build() + + with (NotificationManagerCompat.from(this)) { + try { + notify(23, notification) + } catch(e: SecurityException) { + Timber.d(e) + } + } + +// val contentUri = Uri.parse("content://org.opendasharchive.safe.provider.tor/status") +// constraints.addContentUriTrigger(contentUri, true) +// +// val myConstraints = constraints.build() +// +// val workRequest = OneTimeWorkRequestBuilder() +// .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) +// .setConstraints(myConstraints) +// .build() +// +// WorkManager.getInstance(this).enqueue(workRequest) } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + override fun onStartJob(params: JobParameters): Boolean { - CoroutineScope(Dispatchers.IO).launch { + scope.launch { upload { jobFinished(params, false) } @@ -87,12 +152,14 @@ class UploadService : JobService() { mKeepUploading = false for (conduit in mConduits) conduit.cancel() mConduits.clear() - + scope.cancel() return true } private suspend fun upload(completed: () -> Unit) { - if (mRunning) return completed() + if (mRunning) { + return completed() + } mRunning = true @@ -103,47 +170,47 @@ class UploadService : JobService() { } // Get all media items that are set into queued state. - var results = emptyList() + val results = Media.getByStatus( + listOf(Media.Status.Queued, Media.Status.Uploading), + Media.ORDER_PRIORITY + ).toMutableList() while (mKeepUploading && - Media.getByStatus( - listOf(Media.Status.Queued, Media.Status.Uploading), - Media.ORDER_PRIORITY - ) - .also { results = it } - .isNotEmpty() + results.isNotEmpty() ) { val datePublish = Date() - for (media in results) { - if (media.sStatus != Media.Status.Uploading) { - media.uploadDate = datePublish - media.progress = 0 // Should we reset this? - media.sStatus = Media.Status.Uploading - media.statusMessage = "" - } + val media = results.removeFirst() - media.licenseUrl = media.project?.licenseUrl + if (media.sStatus != Media.Status.Uploading) { + media.uploadDate = datePublish + media.progress = 0 // Should we reset this? + media.sStatus = Media.Status.Uploading + media.statusMessage = "" + } + + media.licenseUrl = media.project?.licenseUrl - val collection = media.collection + val collection = media.collection - if (collection?.uploadDate == null) { - collection?.uploadDate = datePublish - collection?.save() - } + if (collection?.uploadDate == null) { + collection?.uploadDate = datePublish + collection?.save() + } - try { - upload(media) - } catch (ioe: IOException) { - Timber.d(ioe) + try { + upload(media) + } catch (ioe: IOException) { + Timber.d(ioe) - media.statusMessage = "error in uploading media: " + ioe.message - media.sStatus = Media.Status.Error - media.save() - } + media.statusMessage = "error in uploading media: " + ioe.message + media.sStatus = Media.Status.Error + media.save() - if (!mKeepUploading) break // Time to end this. + BroadcastManager.postChange(applicationContext, media.collectionId, media.id) } + + if (!mKeepUploading) break // Time to end this. } mRunning = false @@ -152,18 +219,21 @@ class UploadService : JobService() { @Throws(IOException::class) private suspend fun upload(media: Media): Boolean { + + val conduit = Conduit.get(media, this) ?: return false + media.sStatus = Media.Status.Uploading media.save() - BroadcastManager.postChange(this, media.id) - - val conduit = Conduit.get(media, this) - ?: return false + BroadcastManager.postChange(this, media.collectionId, media.id) CleanInsightsManager.measureEvent("upload", "try_upload", media.space?.tType?.friendlyName) mConduits.add(conduit) - conduit.upload() - mConduits.remove(conduit) + + scope.launch { + conduit.upload() + mConduits.remove(conduit) + } return true } @@ -176,8 +246,13 @@ class UploadService : JobService() { if (isNetworkAvailable(requireUnmetered)) return true - val type = - if (requireUnmetered) JobInfo.NETWORK_TYPE_UNMETERED else JobInfo.NETWORK_TYPE_ANY + if (Prefs.useTor && isTorAvailable()) return true + + val type = if (requireUnmetered) { + JobInfo.NETWORK_TYPE_UNMETERED + } else { + JobInfo.NETWORK_TYPE_ANY + } // Try again when there is a network. val job = JobInfo.Builder( @@ -193,44 +268,36 @@ class UploadService : JobService() { return false } - private fun isNetworkAvailable(requireUnmetered: Boolean): Boolean { - val cm = - getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val cap = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false + private fun isTorAvailable(): Boolean { + return false + } - when { - cap.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> { - return true - } + private fun isNetworkAvailable(requireUnmetered: Boolean): Boolean { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false - cap.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { - return !requireUnmetered - } + val cap = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false - cap.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> { - return true - } + when { + cap.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> { + return true } - return false - } else { - @Suppress("DEPRECATION") - val info = cm.activeNetworkInfo + cap.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { + return !requireUnmetered + } - @Suppress("DEPRECATION") - return info?.isConnected == true && (!requireUnmetered - || info.type == ConnectivityManager.TYPE_WIFI - || info.type == ConnectivityManager.TYPE_ETHERNET) + cap.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> { + return true + } } + + return false } - @RequiresApi(api = Build.VERSION_CODES.O) private fun createNotificationChannel() { val channel = NotificationChannel( NOTIFICATION_CHANNEL_ID, getString(R.string.uploads), - NotificationManager.IMPORTANCE_LOW + NotificationManager.IMPORTANCE_DEFAULT ) channel.description = getString(R.string.uploads_notification_descriptions) @@ -239,18 +306,16 @@ class UploadService : JobService() { channel.setShowBadge(false) channel.lockscreenVisibility = Notification.VISIBILITY_SECRET - (getSystemService(NOTIFICATION_SERVICE) as? NotificationManager) - ?.createNotificationChannel(channel) + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(channel) } private fun prepNotification(): Notification { - val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_IMMUTABLE - } else 0 - val pendingIntent = PendingIntent.getActivity( this, 0, - Intent(this, MainActivity::class.java), flag + Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/DriveServiceHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/util/DriveServiceHelper.kt new file mode 100644 index 00000000..cd2ad0f5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/util/DriveServiceHelper.kt @@ -0,0 +1,56 @@ +package net.opendasharchive.openarchive.util + +import com.google.api.client.extensions.android.http.AndroidHttp +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.http.FileContent +import com.google.api.client.json.jackson2.JacksonFactory +import com.google.api.services.drive.Drive +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +object DriveServiceHelper { + const val APPLICATION_NAME = "save" + + private fun getDriveService(credential: GoogleAccountCredential): Drive { + return Drive.Builder( + AndroidHttp.newCompatibleTransport(), + JacksonFactory.getDefaultInstance(), + credential + ) + .setApplicationName(APPLICATION_NAME) + .build() + } + + suspend fun createFolder(folderName: String, parentFolderId: String?, credential: GoogleAccountCredential) { + withContext(Dispatchers.IO) { + val driveService = getDriveService(credential) + val fileMetadata = com.google.api.services.drive.model.File() + fileMetadata.name = folderName + fileMetadata.mimeType = "application/vnd.google-apps.folder" + + if (parentFolderId != null) { + fileMetadata.parents = listOf(parentFolderId) + } + + driveService.files().create(fileMetadata).execute() + } + } + + suspend fun uploadFile(filePath: String, mimeType: String, parentFolder: String?, credential: GoogleAccountCredential) { + withContext(Dispatchers.IO) { + val driveService = getDriveService(credential) + val file = File(filePath) + val fileMetadata = com.google.api.services.drive.model.File() + fileMetadata.name = file.name + fileMetadata.mimeType = mimeType + + if (parentFolder != null) { + fileMetadata.parents = listOf(parentFolder) + } + + val mediaContent = FileContent(mimeType, file) + driveService.files().create(fileMetadata, mediaContent).execute() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/Hbks.kt b/app/src/main/java/net/opendasharchive/openarchive/util/Hbks.kt index 76eb73c2..855d47dc 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/Hbks.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/Hbks.kt @@ -1,7 +1,10 @@ package net.opendasharchive.openarchive.util import android.content.Context +import android.content.Intent import android.os.Build +import android.provider.Settings.ACTION_BIOMETRIC_ENROLL +import android.provider.Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.security.keystore.UserNotAuthenticatedException @@ -29,8 +32,16 @@ object Hbks { fun canAuthenticate(manager: BiometricManager): Boolean { return manager.canAuthenticate(value) == BiometricManager.BIOMETRIC_SUCCESS } - } + fun canEnroll(manager: BiometricManager): Boolean { + return manager.canAuthenticate(value) == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED + } + } + sealed interface Availability { + data class Available(val type: BiometryType) : Availability + @RequiresApi(Build.VERSION_CODES.R) data class Enroll(val type: BiometryType): Availability + data object Unavailable : Availability + } private const val alias = "save-main-key" private const val type = "AndroidKeyStore" @@ -232,8 +243,41 @@ object Hbks { return output } - fun deviceSecured(context: Context): Boolean { - return biometryType(context) != BiometryType.None + fun deviceAvailablity(context: Context): Availability { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return Availability.Unavailable + } + + val manager = BiometricManager.from(context) + + for (type in arrayOf(BiometryType.Both, BiometryType.StrongBiometry, BiometryType.DeviceCredential)) { + if (type.canAuthenticate(manager)) { + return Availability.Available(type) + } + } + + // enrolling only available in version greater than R + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + for (type in arrayOf( + BiometryType.Both, + BiometryType.StrongBiometry, + BiometryType.DeviceCredential + )) { + if (type.canEnroll(manager)) { + return Availability.Enroll(type) + } + } + } + + return Availability.Unavailable + } + + @RequiresApi(Build.VERSION_CODES.R) + fun enrollIntent(type: BiometryType) = Intent(ACTION_BIOMETRIC_ENROLL).apply { + putExtra( + EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED, + type.value + ) } fun biometryType(context: Context): BiometryType { diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/Prefs.kt b/app/src/main/java/net/opendasharchive/openarchive/util/Prefs.kt index dca24b17..a60d3dcf 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/Prefs.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/Prefs.kt @@ -9,15 +9,15 @@ import org.witness.proofmode.ProofMode import org.witness.proofmode.ProofModeConstants object Prefs { - + private const val DID_COMPLETE_ONBOARDING = "did_complete_onboarding" private const val UPLOAD_WIFI_ONLY = "upload_wifi_only" private const val NEARBY_USE_BLUETOOTH = "nearby_use_bluetooth" private const val NEARBY_USE_WIFI = "nearby_use_wifi" - const val USE_TOR = "use_tor" + private const val USE_TOR = "use_tor" const val PROHIBIT_SCREENSHOTS = "prohibit_screenshots" const val USE_PROOFMODE = "use_proofmode" const val USE_PROOFMODE_KEY_ENCRYPTION = "proofmode_key_encryption" - private const val USE_NEXTCLOUD_CHUNKING = "upload_nextcloud_chunks" + // private const val USE_NEXTCLOUD_CHUNKING = "upload_nextcloud_chunks" const val THEME = "theme" private const val CURRENT_SPACE_ID = "current_space" private const val FLAG_HINT_SHOWN = "ft.flag" @@ -39,8 +39,14 @@ object Prefs { prefs?.edit()?.commit() } - val useNextcloudChunking: Boolean - get() = prefs?.getBoolean(USE_NEXTCLOUD_CHUNKING, false) ?: false +// val useNextcloudChunking: Boolean +// get() = prefs?.getBoolean(USE_NEXTCLOUD_CHUNKING, false) ?: false + + var didCompleteOnboarding: Boolean + get() = prefs?.getBoolean(DID_COMPLETE_ONBOARDING, false) ?: false + set(value) { + prefs?.edit()?.putBoolean(DID_COMPLETE_ONBOARDING, value)?.apply() + } var uploadWifiOnly: Boolean get() = prefs?.getBoolean(UPLOAD_WIFI_ONLY, false) ?: false diff --git a/app/src/main/res/drawable-hdpi/baseline_folder_white_18.png b/app/src/main/res/drawable-hdpi/baseline_folder_white_18.png new file mode 100644 index 00000000..20b6e3f6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/baseline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-hdpi/baseline_folder_white_20.png b/app/src/main/res/drawable-hdpi/baseline_folder_white_20.png new file mode 100644 index 00000000..55872c86 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/baseline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-hdpi/baseline_folder_white_24.png b/app/src/main/res/drawable-hdpi/baseline_folder_white_24.png new file mode 100644 index 00000000..eded8f91 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/baseline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-hdpi/baseline_folder_white_36.png b/app/src/main/res/drawable-hdpi/baseline_folder_white_36.png new file mode 100644 index 00000000..2a23ff5c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/baseline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-hdpi/baseline_folder_white_48.png b/app/src/main/res/drawable-hdpi/baseline_folder_white_48.png new file mode 100644 index 00000000..3cd47bc4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/baseline_folder_white_48.png differ diff --git a/app/src/main/res/drawable-hdpi/outline_folder_white_18.png b/app/src/main/res/drawable-hdpi/outline_folder_white_18.png new file mode 100644 index 00000000..1b1983b4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/outline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-hdpi/outline_folder_white_20.png b/app/src/main/res/drawable-hdpi/outline_folder_white_20.png new file mode 100644 index 00000000..ac8ee270 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/outline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-hdpi/outline_folder_white_24.png b/app/src/main/res/drawable-hdpi/outline_folder_white_24.png new file mode 100644 index 00000000..c6ff09f7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/outline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-hdpi/outline_folder_white_36.png b/app/src/main/res/drawable-hdpi/outline_folder_white_36.png new file mode 100644 index 00000000..5776666c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/outline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-hdpi/outline_folder_white_48.png b/app/src/main/res/drawable-hdpi/outline_folder_white_48.png new file mode 100644 index 00000000..0177e23b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/outline_folder_white_48.png differ diff --git a/app/src/main/res/drawable-mdpi/baseline_folder_white_18.png b/app/src/main/res/drawable-mdpi/baseline_folder_white_18.png new file mode 100644 index 00000000..fca07cff Binary files /dev/null and b/app/src/main/res/drawable-mdpi/baseline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-mdpi/baseline_folder_white_20.png b/app/src/main/res/drawable-mdpi/baseline_folder_white_20.png new file mode 100644 index 00000000..20af3d1e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/baseline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-mdpi/baseline_folder_white_24.png b/app/src/main/res/drawable-mdpi/baseline_folder_white_24.png new file mode 100644 index 00000000..b080fec9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/baseline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-mdpi/baseline_folder_white_36.png b/app/src/main/res/drawable-mdpi/baseline_folder_white_36.png new file mode 100644 index 00000000..eded8f91 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/baseline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-mdpi/baseline_folder_white_48.png b/app/src/main/res/drawable-mdpi/baseline_folder_white_48.png new file mode 100644 index 00000000..e6ae3610 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/baseline_folder_white_48.png differ diff --git a/app/src/main/res/drawable-mdpi/outline_folder_white_18.png b/app/src/main/res/drawable-mdpi/outline_folder_white_18.png new file mode 100644 index 00000000..000c1679 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/outline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-mdpi/outline_folder_white_20.png b/app/src/main/res/drawable-mdpi/outline_folder_white_20.png new file mode 100644 index 00000000..505a6fcb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/outline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-mdpi/outline_folder_white_24.png b/app/src/main/res/drawable-mdpi/outline_folder_white_24.png new file mode 100644 index 00000000..6b7736bd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/outline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-mdpi/outline_folder_white_36.png b/app/src/main/res/drawable-mdpi/outline_folder_white_36.png new file mode 100644 index 00000000..c6ff09f7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/outline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-mdpi/outline_folder_white_48.png b/app/src/main/res/drawable-mdpi/outline_folder_white_48.png new file mode 100644 index 00000000..5048b6b0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/outline_folder_white_48.png differ diff --git a/app/src/main/res/drawable-night/ic_internet_archive.xml b/app/src/main/res/drawable-night/ic_internet_archive.xml new file mode 100644 index 00000000..9c5f98df --- /dev/null +++ b/app/src/main/res/drawable-night/ic_internet_archive.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_private_server.xml b/app/src/main/res/drawable-night/ic_private_server.xml new file mode 100644 index 00000000..b042d84f --- /dev/null +++ b/app/src/main/res/drawable-night/ic_private_server.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-night/logo_gdrive_outline.xml b/app/src/main/res/drawable-night/logo_gdrive_outline.xml new file mode 100644 index 00000000..106b1d64 --- /dev/null +++ b/app/src/main/res/drawable-night/logo_gdrive_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-xhdpi/baseline_folder_white_18.png b/app/src/main/res/drawable-xhdpi/baseline_folder_white_18.png new file mode 100644 index 00000000..eded8f91 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/baseline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-xhdpi/baseline_folder_white_20.png b/app/src/main/res/drawable-xhdpi/baseline_folder_white_20.png new file mode 100644 index 00000000..1eadfc38 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/baseline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-xhdpi/baseline_folder_white_24.png b/app/src/main/res/drawable-xhdpi/baseline_folder_white_24.png new file mode 100644 index 00000000..e6ae3610 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/baseline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-xhdpi/baseline_folder_white_36.png b/app/src/main/res/drawable-xhdpi/baseline_folder_white_36.png new file mode 100644 index 00000000..3cd47bc4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/baseline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-xhdpi/baseline_folder_white_48.png b/app/src/main/res/drawable-xhdpi/baseline_folder_white_48.png new file mode 100644 index 00000000..882240f2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/baseline_folder_white_48.png differ diff --git a/app/src/main/res/drawable-xhdpi/outline_folder_white_18.png b/app/src/main/res/drawable-xhdpi/outline_folder_white_18.png new file mode 100644 index 00000000..c6ff09f7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/outline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-xhdpi/outline_folder_white_20.png b/app/src/main/res/drawable-xhdpi/outline_folder_white_20.png new file mode 100644 index 00000000..006230ee Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/outline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-xhdpi/outline_folder_white_24.png b/app/src/main/res/drawable-xhdpi/outline_folder_white_24.png new file mode 100644 index 00000000..5048b6b0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/outline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-xhdpi/outline_folder_white_36.png b/app/src/main/res/drawable-xhdpi/outline_folder_white_36.png new file mode 100644 index 00000000..0177e23b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/outline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-xhdpi/outline_folder_white_48.png b/app/src/main/res/drawable-xhdpi/outline_folder_white_48.png new file mode 100644 index 00000000..c85c6e3e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/outline_folder_white_48.png differ diff --git a/app/src/main/res/drawable-xxhdpi/baseline_folder_white_18.png b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_18.png new file mode 100644 index 00000000..2a23ff5c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-xxhdpi/baseline_folder_white_20.png b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_20.png new file mode 100644 index 00000000..83364cdc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-xxhdpi/baseline_folder_white_24.png b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_24.png new file mode 100644 index 00000000..3cd47bc4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/baseline_folder_white_36.png b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_36.png new file mode 100644 index 00000000..dd9924f1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-xxhdpi/baseline_folder_white_48.png b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_48.png new file mode 100644 index 00000000..3d52079c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/baseline_folder_white_48.png differ diff --git a/app/src/main/res/drawable-xxhdpi/outline_folder_white_18.png b/app/src/main/res/drawable-xxhdpi/outline_folder_white_18.png new file mode 100644 index 00000000..5776666c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/outline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-xxhdpi/outline_folder_white_20.png b/app/src/main/res/drawable-xxhdpi/outline_folder_white_20.png new file mode 100644 index 00000000..6c00a1b7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/outline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-xxhdpi/outline_folder_white_24.png b/app/src/main/res/drawable-xxhdpi/outline_folder_white_24.png new file mode 100644 index 00000000..0177e23b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/outline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/outline_folder_white_36.png b/app/src/main/res/drawable-xxhdpi/outline_folder_white_36.png new file mode 100644 index 00000000..2bdd7ba0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/outline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-xxhdpi/outline_folder_white_48.png b/app/src/main/res/drawable-xxhdpi/outline_folder_white_48.png new file mode 100644 index 00000000..40eae0a3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/outline_folder_white_48.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_18.png b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_18.png new file mode 100644 index 00000000..3cd47bc4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_20.png b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_20.png new file mode 100644 index 00000000..88726c33 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_24.png b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_24.png new file mode 100644 index 00000000..882240f2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_36.png b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_36.png new file mode 100644 index 00000000..3d52079c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_48.png b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_48.png new file mode 100644 index 00000000..fd852aa8 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/baseline_folder_white_48.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/outline_folder_white_18.png b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_18.png new file mode 100644 index 00000000..0177e23b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_18.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/outline_folder_white_20.png b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_20.png new file mode 100644 index 00000000..462f614a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_20.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/outline_folder_white_24.png b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_24.png new file mode 100644 index 00000000..c85c6e3e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_24.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/outline_folder_white_36.png b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_36.png new file mode 100644 index 00000000..40eae0a3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_36.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/outline_folder_white_48.png b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_48.png new file mode 100644 index 00000000..03fea9ce Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/outline_folder_white_48.png differ diff --git a/app/src/main/res/drawable/baseline_folder_18.xml b/app/src/main/res/drawable/baseline_folder_18.xml new file mode 100644 index 00000000..714cc3d9 --- /dev/null +++ b/app/src/main/res/drawable/baseline_folder_18.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_folder_24.xml b/app/src/main/res/drawable/baseline_folder_24.xml new file mode 100644 index 00000000..dc6b0802 --- /dev/null +++ b/app/src/main/res/drawable/baseline_folder_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/bottom_nav_background.xml b/app/src/main/res/drawable/bottom_nav_background.xml index cc9b4895..a9012069 100644 --- a/app/src/main/res/drawable/bottom_nav_background.xml +++ b/app/src/main/res/drawable/bottom_nav_background.xml @@ -3,7 +3,7 @@ - + diff --git a/app/src/main/res/drawable/button.xml b/app/src/main/res/drawable/button.xml new file mode 100644 index 00000000..ff4bc721 --- /dev/null +++ b/app/src/main/res/drawable/button.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/button_outlined.xml b/app/src/main/res/drawable/button_outlined.xml new file mode 100644 index 00000000..e7baf2a1 --- /dev/null +++ b/app/src/main/res/drawable/button_outlined.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_internet_archive.xml b/app/src/main/res/drawable/ic_internet_archive.xml index 640e7e86..08339450 100644 --- a/app/src/main/res/drawable/ic_internet_archive.xml +++ b/app/src/main/res/drawable/ic_internet_archive.xml @@ -6,6 +6,6 @@ diff --git a/app/src/main/res/drawable/ic_private_server.xml b/app/src/main/res/drawable/ic_private_server.xml index cfd34ca5..791c4ab8 100644 --- a/app/src/main/res/drawable/ic_private_server.xml +++ b/app/src/main/res/drawable/ic_private_server.xml @@ -6,6 +6,6 @@ diff --git a/app/src/main/res/drawable/logo_gdrive_outline.xml b/app/src/main/res/drawable/logo_gdrive_outline.xml new file mode 100644 index 00000000..1aba8441 --- /dev/null +++ b/app/src/main/res/drawable/logo_gdrive_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/outline_perm_media_24.xml b/app/src/main/res/drawable/outline_perm_media_24.xml new file mode 100644 index 00000000..acc0ce87 --- /dev/null +++ b/app/src/main/res/drawable/outline_perm_media_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/perm_media_24px.xml b/app/src/main/res/drawable/perm_media_24px.xml new file mode 100644 index 00000000..62ffdddb --- /dev/null +++ b/app/src/main/res/drawable/perm_media_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/save_list_item_spacing_small.xml b/app/src/main/res/drawable/save_list_item_spacing_small.xml new file mode 100644 index 00000000..1c78cd75 --- /dev/null +++ b/app/src/main/res/drawable/save_list_item_spacing_small.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/welcome_arrow.xml b/app/src/main/res/drawable/welcome_arrow.xml new file mode 100644 index 00000000..a1a3c193 --- /dev/null +++ b/app/src/main/res/drawable/welcome_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout-sw480dp/activity_onboarding23.xml b/app/src/main/res/layout-sw480dp/activity_onboarding23.xml new file mode 100644 index 00000000..fe88a434 --- /dev/null +++ b/app/src/main/res/layout-sw480dp/activity_onboarding23.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_dropbox.xml b/app/src/main/res/layout/activity_dropbox.xml deleted file mode 100644 index 27e1efb3..00000000 --- a/app/src/main/res/layout/activity_dropbox.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_ia_scrape.xml b/app/src/main/res/layout/activity_ia_scrape.xml deleted file mode 100644 index 0d2b3196..00000000 --- a/app/src/main/res/layout/activity_ia_scrape.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_internet_archive.xml b/app/src/main/res/layout/activity_internet_archive.xml deleted file mode 100644 index 490be10c..00000000 --- a/app/src/main/res/layout/activity_internet_archive.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 5fce1099..14271c49 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -13,14 +13,12 @@ + android:layout_height="wrap_content"> + android:layout_height="?attr/actionBarSize"> + tools:text="Folder Name" /> - + + + + + + + + + + + + + @@ -108,6 +105,7 @@ android:layout_width="0dp" android:layout_height="0dp" android:layout_marginBottom="2dp" + android:layout_marginTop="2dp" app:layout_constraintBottom_toTopOf="@id/bottom_bar" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -140,10 +138,10 @@ android:backgroundTint="@color/colorBottomNavbar" android:paddingTop="0dp" android:paddingBottom="0dp" - app:icon="@drawable/ic_home" + app:icon="@drawable/outline_perm_media_24" app:iconGravity="textStart" app:iconPadding="0dp" - app:iconSize="24dp" + app:iconSize="36dp" app:iconTint="@color/colorOnBottomNavbar" app:rippleColor="@color/c23_light_grey" /> @@ -153,7 +151,6 @@ android:layout_height="wrap_content" android:layout_below="@id/my_media_button" android:layout_centerHorizontal="true" - android:layout_marginTop="-4dp" android:clickable="true" android:focusable="false" android:padding="0dp" @@ -205,7 +202,7 @@ app:icon="@drawable/ic_settings" app:iconGravity="textStart" app:iconPadding="0dp" - app:iconSize="24dp" + app:iconSize="36dp" app:iconTint="@color/colorOnBottomNavbar" app:rippleColor="@color/c23_light_grey" /> @@ -215,7 +212,6 @@ android:layout_height="wrap_content" android:layout_below="@id/settings_button" android:layout_centerHorizontal="true" - android:layout_marginTop="-4dp" android:clickable="true" android:focusable="false" android:padding="0dp" @@ -232,73 +228,106 @@ - - + android:clipChildren="true" + android:fitsSystemWindows="true"> + android:animateLayoutChanges="false" + android:backgroundTint="@color/background"> - + app:layout_constraintTop_toBottomOf="@id/current_space_name" /> - - - - - + app:layout_constraintTop_toBottomOf="@id/navigation_drawer_header" /> - + app:layout_constraintTop_toTopOf="parent"> + + + + + + + + app:layout_constraintStart_toStartOf="parent" + app:strokeColor="@color/c23_teal_80" + app:strokeWidth="3dp" /> diff --git a/app/src/main/res/layout/activity_onboarding23.xml b/app/src/main/res/layout/activity_onboarding23.xml index fe88a434..f1792b06 100644 --- a/app/src/main/res/layout/activity_onboarding23.xml +++ b/app/src/main/res/layout/activity_onboarding23.xml @@ -43,9 +43,10 @@ android:id="@+id/get_started" android:layout_width="match_parent" android:layout_height="wrap_content" + android:gravity="center" android:clickable="true" android:focusable="true" - android:orientation="horizontal" + android:orientation="vertical" android:paddingLeft="20dp" android:paddingRight="20dp" android:paddingBottom="10dp"> @@ -61,9 +62,9 @@ diff --git a/app/src/main/res/layout/bottom_bar.xml b/app/src/main/res/layout/bottom_bar.xml new file mode 100644 index 00000000..f11218b9 --- /dev/null +++ b/app/src/main/res/layout/bottom_bar.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_ia_learn_how.xml b/app/src/main/res/layout/fragment_ia_learn_how.xml deleted file mode 100644 index c9ae0e82..00000000 --- a/app/src/main/res/layout/fragment_ia_learn_how.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_ia_learn_how_step.xml b/app/src/main/res/layout/fragment_ia_learn_how_step.xml deleted file mode 100644 index bfb6e69f..00000000 --- a/app/src/main/res/layout/fragment_ia_learn_how_step.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_internet_archive.xml b/app/src/main/res/layout/fragment_internet_archive.xml deleted file mode 100644 index 6ffdbf4f..00000000 --- a/app/src/main/res/layout/fragment_internet_archive.xml +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -