From fc3d95b0da44024a72c1e45c65cf1ff332b4860a Mon Sep 17 00:00:00 2001 From: Ryan Jennings Date: Thu, 8 May 2025 11:20:36 -0700 Subject: [PATCH 01/15] chore: update gradle for latest IDE --- app/build.gradle | 8 ++--- build.gradle | 38 +----------------------- gradle/wrapper/gradle-wrapper.properties | 4 +-- settings.gradle | 11 +++++++ 4 files changed, 18 insertions(+), 43 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6b8548eb..1b9986c9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,7 +19,7 @@ android { targetSdkVersion 34 versionCode 30001 versionName '0.7.4' - archivesBaseName = "Save-$versionName" + //archivesBaseName = "Save-$versionName" multiDexEnabled true vectorDrawables.useSupportLibrary = true testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' @@ -110,11 +110,11 @@ dependencies { 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" + implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1' implementation "com.facebook.fresco:fresco:2.6.0" implementation "com.squareup.picasso:picasso:2.5.2" - implementation "com.amulyakhare:com.amulyakhare.textdrawable:1.0.1" + implementation "com.github.amulyakhare:TextDrawable:master" implementation "com.github.abdularis:circularimageview:1.4" implementation "org.cleaninsights.sdk:clean-insights-sdk:2.8.0" @@ -156,7 +156,7 @@ dependencies { 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:tor-android:0.4.8.11' implementation 'info.guardianproject:jtorctl:0.4.5.7' // New Play libraries diff --git a/build.gradle b/build.gradle index d360e5c6..52e188cf 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,6 @@ buildscript { repositories { google() mavenCentral() - maven { url "https://plugins.gradle.org/m2/" content { @@ -49,7 +48,7 @@ buildscript { } } dependencies { - classpath 'com.android.tools.build:gradle:8.3.2' + classpath 'com.android.tools.build:gradle:8.9.2' classpath "com.neenbedankt.gradle.plugins:android-apt:1.8" classpath "com.testdroid:gradle:2.63.1" classpath "gradle.plugin.com.browserstack.gradle:browserstack-gradle-plugin:2.3.1" @@ -57,47 +56,12 @@ buildscript { } } - configurations.configureEach { resolutionStrategy { force "com.android.support:support-v4:$versions.support_v4" } } - -allprojects { - repositories { - google() - mavenCentral() - - maven { - url 'https://raw.githubusercontent.com/guardianproject/gpmaven/master' - content { - includeModule 'org.proofmode', 'android-libproofmode' - } - } - - maven { - url 'https://jitpack.io' - content { - includeModule 'com.github.esafirm', 'android-image-picker' - includeModule 'com.github.derlio', 'audio-waveform' - includeModule 'com.github.abdularis', 'circularimageview' - includeModule 'com.github.guardianproject', 'sardine-android' - } - } - - maven { - url 'https://jcenter.bintray.com' - content { - includeModule 'com.amulyakhare', 'com.amulyakhare.textdrawable' - includeModule 'com.github.stfalcon', 'frescoimageviewer' - includeModule 'me.relex', 'photodraweeview' - } - } - } -} - tasks.register('clean', Delete) { delete rootProject.getLayout().getBuildDirectory() } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5a016c74..c3dc709a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Mar 12 00:09:08 EET 2021 +#Sun May 04 14:14:03 PDT 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip diff --git a/settings.gradle b/settings.gradle index e7b4def4..301d3525 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,12 @@ include ':app' + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { url 'https://jitpack.io' } + maven { + url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") + } + } +} \ No newline at end of file From 07ce958285e814634b53d63a699de6a7b2d7a059 Mon Sep 17 00:00:00 2001 From: Ryan Jennings Date: Thu, 8 May 2025 11:21:13 -0700 Subject: [PATCH 02/15] feat: enable internal tor daemon --- .circleci/config.yml | 4 +- app/src/main/AndroidManifest.xml | 6 + .../opendasharchive/openarchive/SaveApp.kt | 39 ++++-- .../openarchive/core/di/CoreModule.kt | 4 +- .../features/media/ReviewActivity.kt | 20 ++- .../settings/GeneralSettingsActivity.kt | 47 +++---- .../features/settings/SettingsFragment.kt | 1 - .../openarchive/services/SaveClient.kt | 27 ++-- .../openarchive/services/tor/Module.kt | 9 ++ .../services/tor/TorForegroundService.kt | 116 +++++++++++++++++ .../openarchive/services/tor/TorRepository.kt | 35 +++++ .../openarchive/services/tor/TorStatus.kt | 15 +++ .../openarchive/services/tor/TorViewModel.kt | 122 ++++++++++++++++++ .../opendasharchive/openarchive/util/Prefs.kt | 2 +- app/src/main/res/drawable-hdpi/ic_tor.png | Bin 0 -> 4610 bytes app/src/main/res/drawable-mdpi/ic_tor.png | Bin 0 -> 3786 bytes app/src/main/res/drawable-xhdpi/ic_tor.png | Bin 0 -> 5572 bytes app/src/main/res/drawable-xxhdpi/ic_tor.png | Bin 0 -> 7697 bytes app/src/main/res/drawable-xxxhdpi/ic_tor.png | Bin 0 -> 9801 bytes app/src/main/res/xml/prefs_general.xml | 28 ++-- 20 files changed, 409 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/tor/Module.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/tor/TorRepository.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt create mode 100644 app/src/main/res/drawable-hdpi/ic_tor.png create mode 100644 app/src/main/res/drawable-mdpi/ic_tor.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_tor.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_tor.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_tor.png diff --git a/.circleci/config.yml b/.circleci/config.yml index d9867073..42c583e1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ jobs: steps: - checkout - restore_cache: - key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} + key: jars-{{ checksum "build.gradle.kts" }}-{{ checksum "app/build.gradle.kts" }} # - run: # name: Chmod permissions #if permission for Gradlew Dependencies fail, use this. # command: sudo chmod +x ./gradlew @@ -22,7 +22,7 @@ jobs: - save_cache: paths: - ~/.gradle - key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} + key: jars-{{ checksum "build.gradle.kts" }}-{{ checksum "app/build.gradle.kts" }} - run: name: Run Tests command: ./gradlew lint test diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bc5fe7df..717e21c2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -217,6 +217,12 @@ android:foregroundServiceType="dataSync" android:exported="false" /> + + diff --git a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt index 404f1a86..becdba05 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt @@ -1,5 +1,7 @@ package net.opendasharchive.openarchive +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context import com.facebook.drawee.backends.pipeline.Fresco import com.facebook.imagepipeline.core.ImagePipelineConfig @@ -8,14 +10,20 @@ import com.orm.SugarApp import info.guardianproject.netcipher.proxy.OrbotHelper import net.opendasharchive.openarchive.core.di.coreModule import net.opendasharchive.openarchive.core.di.featuresModule +import net.opendasharchive.openarchive.services.tor.TOR_SERVICE_CHANNEL +import net.opendasharchive.openarchive.services.tor.TorViewModel import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Theme import org.koin.android.ext.koin.androidContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.koin.core.context.startKoin import timber.log.Timber -class SaveApp : SugarApp() { +class SaveApp : SugarApp(), KoinComponent { + + private val torViewModel: TorViewModel by inject() override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) @@ -38,12 +46,17 @@ class SaveApp : SugarApp() { Fresco.initialize(this, config) Prefs.load(this) - if (Prefs.useTor) initNetCipher() + if (Prefs.useTor) { + OrbotHelper.get(this).init() + initTor() + } Theme.set(Prefs.theme) CleanInsightsManager.init(this) + createTorNotificationChannel() + // enable timber logging library for debug builds if (BuildConfig.DEBUG){ Timber.plant(Timber.DebugTree()) @@ -51,14 +64,22 @@ class SaveApp : SugarApp() { } } - private fun initNetCipher() { - Timber.d( "Initializing NetCipher client") - val oh = OrbotHelper.get(this) + private fun initTor() { + Timber.d( "Initializing internal tor client") + torViewModel.updateTorServiceState() + } - if (BuildConfig.DEBUG) { - oh.skipOrbotValidation() - } -// oh.init() + private fun createTorNotificationChannel() { + val name = "Tor Service" + val descriptionText = "Keeps the Tor service running" + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(TOR_SERVICE_CHANNEL, name, importance).apply { + description = descriptionText + } + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) } + } diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt index a9d91456..6dd921a5 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt @@ -1,7 +1,9 @@ package net.opendasharchive.openarchive.core.di +import net.opendasharchive.openarchive.services.tor.TorRepository +import net.opendasharchive.openarchive.services.tor.torModule import org.koin.dsl.module val coreModule = module { - + includes(torModule) } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewActivity.kt index 1c1f5245..b38860c3 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/ReviewActivity.kt @@ -2,6 +2,7 @@ package net.opendasharchive.openarchive.features.media import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import android.text.Editable import android.text.TextWatcher @@ -12,7 +13,7 @@ import android.widget.ImageView import com.bumptech.glide.Glide import com.github.derlio.waveform.SimpleWaveformView import com.squareup.picasso.Picasso -import com.stfalcon.frescoimageviewer.ImageViewer +import com.stfalcon.imageviewer.StfalconImageViewer import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.databinding.ActivityReviewBinding import net.opendasharchive.openarchive.db.Media @@ -25,6 +26,7 @@ import net.opendasharchive.openarchive.util.extensions.hide import net.opendasharchive.openarchive.util.extensions.show import net.opendasharchive.openarchive.util.extensions.toggle import java.text.NumberFormat +import kotlin.arrayOf import kotlin.math.max import kotlin.math.min @@ -60,6 +62,12 @@ class ReviewActivity : BaseActivity(), View.OnClickListener { private val mMedia get() = mStore.getOrNull(mIndex) + private val mPicasso by lazy { + Picasso.Builder(this) + .addRequestHandler(VideoRequestHandler(this)) + .build() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -168,9 +176,9 @@ class ReviewActivity : BaseActivity(), View.OnClickListener { when (view) { mBinding.waveform, mBinding.image -> { if (mMedia?.mimeType?.startsWith("image") == true) { - ImageViewer.Builder(this, listOf(mMedia?.fileUri)) - .setStartPosition(0) - .show() + StfalconImageViewer.Builder(this, listOf(mMedia?.fileUri)) { view, image -> + mPicasso.load(image).into(view) + }.show() } } mBinding.btFlag -> { @@ -311,9 +319,7 @@ class ReviewActivity : BaseActivity(), View.OnClickListener { .into(imageView) } else if (media?.mimeType?.startsWith("video") == true) { - Picasso.Builder(this) - .addRequestHandler(VideoRequestHandler(this)) - .build() + mPicasso .load(VideoRequestHandler.SCHEME_VIDEO + ":" + media.originalFilePath) ?.fit() ?.centerCrop() 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 a85ccc80..89f03dc7 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 @@ -3,51 +3,52 @@ package net.opendasharchive.openarchive.features.settings import android.content.Intent import android.os.Bundle import android.view.MenuItem +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat +import kotlinx.coroutines.launch import net.opendasharchive.openarchive.CleanInsightsManager import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult import net.opendasharchive.openarchive.databinding.ActivitySettingsContainerBinding import net.opendasharchive.openarchive.features.core.BaseActivity +import net.opendasharchive.openarchive.services.SaveClient +import net.opendasharchive.openarchive.services.tor.TorViewModel import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Theme +import okhttp3.Request +import org.koin.androidx.viewmodel.ext.android.viewModel +import kotlin.getValue class GeneralSettingsActivity: BaseActivity() { class Fragment: PreferenceFragmentCompat() { + private val torViewModel: TorViewModel by viewModel() + private var mCiConsentPref: SwitchPreferenceCompat? = null + 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 -> + torViewModel.toggleTorServiceState() + true + } + + this.lifecycleScope.launch { + torViewModel.torStatus.collect { torStatus -> + findPreference("tor_status")?.setSummary( + torStatus.toString().lowercase() + ) + } + } + findPreference("proof_mode")?.setOnPreferenceClickListener { startActivity(Intent(context, ProofModeSettingsActivity::class.java)) - true } 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 10d63fa4..4561eb22 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 @@ -25,7 +25,6 @@ class SettingsFragment : Fragment() { private lateinit var mBinding: FragmentSettingsBinding - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? 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 1dc4be86..84323cba 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt @@ -8,6 +8,8 @@ import info.guardianproject.netcipher.client.StrongBuilderBase import info.guardianproject.netcipher.proxy.OrbotHelper import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.services.tor.ITorRepository +import net.opendasharchive.openarchive.services.tor.TorStatus import net.opendasharchive.openarchive.services.webdav.BasicAuthInterceptor import net.opendasharchive.openarchive.util.Prefs import okhttp3.Interceptor @@ -15,14 +17,19 @@ import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request import okhttp3.internal.platform.Platform +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.net.InetSocketAddress +import java.net.Proxy import java.util.concurrent.TimeUnit import kotlin.coroutines.suspendCoroutine -class SaveClient(context: Context) : StrongBuilderBase(context) { +class SaveClient(context: Context) : StrongBuilderBase(context), KoinComponent { class OrbotException(message: String): Exception(message) private var okBuilder: OkHttpClient.Builder + private val torRepo: ITorRepository by inject() init { val cacheInterceptor = Interceptor { chain -> @@ -37,6 +44,9 @@ class SaveClient(context: Context) : StrongBuilderBase .readTimeout(40L, TimeUnit.SECONDS) .retryOnConnectionFailure(false) .protocols(arrayListOf(Protocol.HTTP_1_1)) + + if (Prefs.useTor) + okBuilder = okBuilder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", torRepo.httpTunnelPort))) } /** @@ -124,17 +134,12 @@ class SaveClient(context: Context) : StrongBuilderBase } } - if (Prefs.useTor) { - if (!OrbotHelper.requestStartTor(context)) { - callback.onInvalid() - } - else { - strongBuilder.build(callback) - } - } - else { - callback.onConnected(strongBuilder.build(Intent())) + /*if (Prefs.useTor && strongBuilder.torRepo.torStatus.value != TorStatus.CONNECTED) { + strongBuilder.build(callback) } + else {*/ + callback.onConnected(strongBuilder.build(Intent())) + //} } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/Module.kt new file mode 100644 index 00000000..d768395c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/Module.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.services.tor + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +internal val torModule = module { + single { TorRepository() } + viewModel { TorViewModel(get(), get()) } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt new file mode 100644 index 00000000..e4b62bea --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt @@ -0,0 +1,116 @@ +package net.opendasharchive.openarchive.services.tor + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.features.main.MainActivity +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.torproject.jni.TorService +import timber.log.Timber + +private const val TOR_SERVICE_ID = 2602 +internal const val TOR_SERVICE_CHANNEL = "tor_service_channel" + +class TorForegroundService : TorService(), KoinComponent { + + private val torRepo: ITorRepository by inject() + + inner class TorServiceBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Timber.d("intent = $intent") + when (intent.action) { + ACTION_ERROR -> { + val errorText = intent.getStringExtra(Intent.EXTRA_TEXT) + Timber.d("error = $errorText") + torRepo.updateTorStatus(TorStatus.ERROR) + } + + ACTION_STATUS -> { + val status = intent.getStringExtra(EXTRA_STATUS) + Timber.d("Tor status = $status") + + when (status) { + STATUS_ON -> { + updateNotification("Connected") + torRepo.updateTorStatus(TorStatus.CONNECTED) + } + + STATUS_OFF -> { + torRepo.updateTorStatus(TorStatus.DISCONNECTED) + } + + STATUS_STOPPING -> { + torRepo.updateTorStatus(TorStatus.DISCONNECTING) + } + + STATUS_STARTING -> { + torRepo.updateTorStatus(TorStatus.CONNECTING) + } + + else -> Timber.d("Got rogue action: ${intent.action}") + } + } + } + } + } + + private var receiver = TorServiceBroadcastReceiver() + + private val notificationManager by lazy { + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + registerBroadcastRecivers(receiver) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(TOR_SERVICE_ID, createNotification("Tor is starting"), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + + torRepo.updatePorts(this.httpTunnelPort, this.socksPort) + + return START_STICKY + } + + private fun createNotification(text: String): Notification { + val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent -> + PendingIntent.getActivity( + this, + 0, + notificationIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + return NotificationCompat.Builder(this, TOR_SERVICE_CHANNEL) + .setContentTitle("Tor Service") + .setContentText(text) + .setSmallIcon(R.drawable.ic_tor) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + } + + private fun registerBroadcastRecivers(receiver: BroadcastReceiver) { + LocalBroadcastManager.getInstance(applicationContext) + .registerReceiver(receiver, IntentFilter(ACTION_STATUS)) + } + + fun updateNotification(status: String) { + val notification = createNotification(status) + notificationManager.notify(TOR_SERVICE_ID, notification) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorRepository.kt new file mode 100644 index 00000000..90b5024b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorRepository.kt @@ -0,0 +1,35 @@ +package net.opendasharchive.openarchive.services.tor + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult +import net.opendasharchive.openarchive.services.SaveClient +import okhttp3.Request + +interface ITorRepository { + val torStatus: StateFlow + fun updateTorStatus(status: TorStatus) + fun updatePorts(http: Int, socks: Int) + val httpTunnelPort: Int + val socksPort: Int +} + +class TorRepository() : ITorRepository { + private val _torStatus = MutableStateFlow(TorStatus.DISCONNECTED) + override val torStatus: StateFlow = _torStatus.asStateFlow() + private var _httpTunnelPort = 8118 + private var _socksPort = 9050 + + override val httpTunnelPort = _httpTunnelPort + override val socksPort = _socksPort + + override fun updateTorStatus(status: TorStatus) { + _torStatus.value = status + } + + override fun updatePorts(http: Int, socks: Int) { + _httpTunnelPort = http + _socksPort = socks + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt new file mode 100644 index 00000000..f6359220 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt @@ -0,0 +1,15 @@ +package net.opendasharchive.openarchive.services.tor + +enum class TorStatus { + DISCONNECTED, + CONNECTING, + CONNECTED, + VERIFIED, + DISCONNECTING, + ERROR; +} + +data class CheckTorResponse( + val IsTor: Boolean, + val IP: String, +) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt new file mode 100644 index 00000000..cdd46191 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt @@ -0,0 +1,122 @@ +package net.opendasharchive.openarchive.services.tor + +import android.app.Application +import android.content.Intent +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.util.Log +import androidx.core.content.ContentProviderCompat.requireContext +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult +import net.opendasharchive.openarchive.services.SaveClient +import net.opendasharchive.openarchive.util.Prefs +import okhttp3.Request +import timber.log.Timber +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket + +class TorViewModel( + private val application: Application, + private val torRepository: ITorRepository +) : AndroidViewModel(application) { + + val torStatus: StateFlow = torRepository.torStatus + + init { + viewModelScope.launch { + torStatus.collect { status -> + if (status == TorStatus.CONNECTED) { + verifyTorStatus() + //verifyTorStatusWithAPI() + } + } + } + } + + private fun verifyTorStatus() { + val thread = HandlerThread("VerifyThread").apply { start() } + Handler(thread.looper).post { + val check = canConnectToSocket(torRepository.httpTunnelPort) && + isServerSocketInUse(torRepository.httpTunnelPort) + if (check) { + torRepository.updateTorStatus(TorStatus.VERIFIED) + } + } + } + + private fun canConnectToSocket(port: Int): Boolean { + try { + val socket = Socket(); + socket.connect(InetSocketAddress("localhost", port), 120); + socket.close(); + return true + } catch (e: Exception) { + return false + } + } + + fun isServerSocketInUse( port: Int): Boolean { + try { + ServerSocket(port).close(); + return false + } catch (e: Exception) { + // Could not connect. + return true + } + } + + // API is broken on backend: https://forum.torproject.org/t/is-https-check-torproject-org-api-ip-broken/11377 + private suspend fun verifyTorStatusWithAPI() { + SaveClient.get(application).enqueueResult( + Request.Builder() + .url("https://check.torproject.org/api/ip") + .method("GET", null) + .build() + ) { response -> + try { + val check = Gson().fromJson(response.body?.string(), CheckTorResponse::class.java) + if (check.IsTor) { + torRepository.updateTorStatus(TorStatus.VERIFIED) + } + Timber.tag("TorViewModel").d("Verified Tor: ${check.IsTor}") + Result.success(response.isSuccessful) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + fun toggleTorServiceState() { + if (!Prefs.useTor) { + startTor() + } else { + stopTor() + } + } + + fun updateTorServiceState() { + if (Prefs.useTor) { + startTor() + } else { + stopTor() + } + } + + private fun startTor() { + Intent(getApplication(), TorForegroundService::class.java).also { intent -> + getApplication().startForegroundService(intent) + } + } + + private fun stopTor() { + Intent(getApplication(), TorForegroundService::class.java).also { intent -> + getApplication().stopService(intent) + } + } +} \ No newline at end of file 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 a60d3dcf..9c6e536c 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/Prefs.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/Prefs.kt @@ -13,7 +13,7 @@ object Prefs { 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" - private const val USE_TOR = "use_tor" + 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" diff --git a/app/src/main/res/drawable-hdpi/ic_tor.png b/app/src/main/res/drawable-hdpi/ic_tor.png new file mode 100644 index 0000000000000000000000000000000000000000..ec84b56f99b25e28bfc63af5ec8ad94be781486d GIT binary patch literal 4610 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_tor.png b/app/src/main/res/drawable-mdpi/ic_tor.png new file mode 100644 index 0000000000000000000000000000000000000000..f994d702e99e567ad1e0415c99c468ff4df4696f GIT binary patch literal 3786 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 AkpKVy literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_tor.png b/app/src/main/res/drawable-xhdpi/ic_tor.png new file mode 100644 index 0000000000000000000000000000000000000000..6a357c62d2e311c663314e0ee147fff5a05de53b GIT binary patch literal 5572 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 LNkvXXu0mjfpJKxK literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_tor.png b/app/src/main/res/drawable-xxxhdpi/ic_tor.png new file mode 100644 index 0000000000000000000000000000000000000000..4742d7193d1d0db1dff5450dfa97e601c7abb42a GIT binary patch literal 9801 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 - + - - - - - - - - - + + + From 14e9afda120612244d17b1d62aca64d0b549928a Mon Sep 17 00:00:00 2001 From: Ryan Jennings Date: Sun, 11 May 2025 03:12:01 -0700 Subject: [PATCH 03/15] feat: orbot proxy enhancements - imported strong ok http client from netcipher due to R.class conflicts with dependencies - Refactored SaveClient to set/unset orbot proxy - added dependency injection for saveclient and tor status repository - update general settings to include tor connection status with some fakery for user experience when orbot is flaky - refactored GDrive implementation to use basic API with SaveClient - added a broadcast reciever for Orbot status, updates the tor repository --- app/build.gradle | 86 +++---- app/src/main/AndroidManifest.xml | 11 +- .../client/StrongOkHttpClientBuilder.java | 116 +++++++++ .../openarchive/CleanInsightsManager.kt | 1 - .../openarchive/FolderAdapter.kt | 6 +- .../opendasharchive/openarchive/SaveApp.kt | 11 +- .../openarchive/core/di/CoreModule.kt | 5 +- .../infrastructure/client/ClientResult.kt | 9 +- .../features/folders/BrowseFoldersActivity.kt | 2 +- .../folders/BrowseFoldersViewModel.kt | 19 +- .../datasource/InternetArchiveRemoteSource.kt | 17 +- .../settings/GeneralSettingsActivity.kt | 34 ++- .../openarchive/services/Module.kt | 12 + .../openarchive/services/SaveClient.kt | 163 +++++-------- .../services/gdrive/GDriveClient.kt | 110 +++++++++ .../services/gdrive/GDriveConduit.kt | 224 +++++------------- .../openarchive/services/gdrive/GDriveFile.kt | 13 + .../services/gdrive/GDriveRepository.kt | 31 +++ .../openarchive/services/gdrive/Module.kt | 22 ++ .../services/gdrive/ProgressRequestBody.kt | 37 +++ .../services/internetarchive/IaConduit.kt | 45 +--- .../openarchive/services/tor/Module.kt | 5 +- .../services/tor/OrbotStatusReceiver.kt | 27 +++ .../services/tor/TorForegroundService.kt | 116 --------- .../openarchive/services/tor/TorRepository.kt | 16 -- .../openarchive/services/tor/TorStatus.kt | 12 +- .../openarchive/services/tor/TorViewModel.kt | 121 +++------- .../services/webdav/BasicAuthInterceptor.kt | 22 -- .../services/webdav/WebDavConduit.kt | 36 +-- .../services/webdav/WebDavFragment.kt | 7 +- .../openarchive/upload/UploadService.kt | 64 +---- .../util/extensions/PackageManager.kt | 4 +- app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/prefs_general.xml | 133 ++++++----- build.gradle | 3 +- 35 files changed, 735 insertions(+), 806 deletions(-) create mode 100644 app/src/main/java/info/guardianproject/netcipher/client/StrongOkHttpClientBuilder.java create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/Module.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveClient.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFile.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveRepository.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/gdrive/Module.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/gdrive/ProgressRequestBody.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/tor/OrbotStatusReceiver.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/webdav/BasicAuthInterceptor.kt diff --git a/app/build.gradle b/app/build.gradle index 1b9986c9..8505bb1e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' android { compileOptions { @@ -11,15 +12,13 @@ android { signingConfigs { } - compileSdk 34 -// buildToolsVersion '34.0.0' + compileSdk 35 defaultConfig { applicationId "net.opendasharchive.openarchive" minSdkVersion 29 targetSdkVersion 34 versionCode 30001 versionName '0.7.4' - //archivesBaseName = "Save-$versionName" multiDexEnabled true vectorDrawables.useSupportLibrary = true testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' @@ -28,9 +27,12 @@ android { flavorDimensions += "free" buildTypes { release { - minifyEnabled false + minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } + debug { + minifyEnabled false + } } packagingOptions { resources { @@ -67,29 +69,28 @@ android { dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.23" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" + implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1" -// implementation "androidx.core:core-ktx:1.13.1" - implementation "androidx.appcompat:appcompat:1.6.1" + implementation "androidx.appcompat:appcompat:1.7.0" implementation 'androidx.biometric:biometric:1.1.0' - implementation "androidx.constraintlayout:constraintlayout:2.1.4" - implementation "androidx.coordinatorlayout:coordinatorlayout:1.2.0" + implementation "androidx.constraintlayout:constraintlayout:2.2.1" + implementation "androidx.coordinatorlayout:coordinatorlayout:1.3.0" implementation "androidx.legacy:legacy-support-v4:1.0.0" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.9.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0" implementation 'androidx.preference:preference-ktx:1.2.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 "androidx.work:work-runtime:2.10.1" + implementation "androidx.work:work-runtime-ktx:2.10.1" + implementation "androidx.work:work-testing:2.10.1" + + implementation "androidx.compose.ui:ui:1.8.1" + implementation "androidx.compose.material3:material3:1.3.2" + implementation 'androidx.compose.foundation:foundation:1.8.1' + implementation "androidx.compose.ui:ui-tooling-preview:1.8.1" + implementation "androidx.activity:activity-compose:1.10.1" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0" implementation "io.insert-koin:koin-core:3.5.3" implementation "io.insert-koin:koin-android:3.5.3" @@ -97,14 +98,14 @@ dependencies { implementation "com.github.satyan:sugar:1.5" - implementation "com.google.code.gson:gson:2.10.1" + implementation "com.google.code.gson:gson:2.11.0" 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.12.0" + implementation "androidx.compose.material:material-icons-extended:1.7.8" implementation "com.github.bumptech.glide:glide:4.16.0" annotationProcessor "com.github.bumptech.glide:compiler:4.16.0" @@ -118,7 +119,6 @@ dependencies { implementation "com.github.abdularis:circularimageview:1.4" implementation "org.cleaninsights.sdk:clean-insights-sdk:2.8.0" - implementation "info.guardianproject.netcipher:netcipher:2.2.0-alpha" //from here: https://github.com/guardianproject/proofmode implementation("org.proofmode:android-libproofmode:1.0.26") { @@ -137,7 +137,7 @@ dependencies { exclude group: "com.squareup.okio", module: "okio" } - implementation "com.google.guava:guava:31.0.1-jre" + implementation "com.google.guava:guava:32.1.2-jre" implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' implementation 'org.bouncycastle:bcpkix-jdk15to18:1.72' @@ -150,24 +150,23 @@ dependencies { implementation 'com.jakewharton.timber:timber:5.0.1' // 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.android.gms:play-services-auth:21.3.0' + implementation 'com.google.http-client:google-http-client-gson:1.42.3' implementation 'com.google.api-client:google-api-client-android:1.26.0' implementation 'com.google.apis:google-api-services-drive:v3-rev136-1.25.0' // Tor - implementation 'info.guardianproject:tor-android:0.4.8.11' - implementation 'info.guardianproject:jtorctl:0.4.5.7' + implementation "info.guardianproject.netcipher:netcipher:2.2.0-alpha" // 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:asset-delivery:2.3.0' + implementation 'com.google.android.play:asset-delivery-ktx:2.3.0' 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:review:2.0.2' + implementation 'com.google.android.play:review-ktx:2.0.2' implementation 'com.google.android.play:app-update:2.1.0' implementation 'com.google.android.play:app-update-ktx:2.1.0' @@ -175,18 +174,9 @@ dependencies { // Tests testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.7.3' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test:runner:1.6.2' } - configurations { all*.exclude group: 'com.google.guava', module: 'listenablefuture' -} - -/** - testdroid {username '$bbusername' - password '$bbpassword' - deviceGroup 'gpdevices' - mode "FULL_RUN" - projectName "OASave"}**/ - +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 717e21c2..4cd02980 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -217,11 +217,12 @@ android:foregroundServiceType="dataSync" android:exported="false" /> - + + + + + diff --git a/app/src/main/java/info/guardianproject/netcipher/client/StrongOkHttpClientBuilder.java b/app/src/main/java/info/guardianproject/netcipher/client/StrongOkHttpClientBuilder.java new file mode 100644 index 00000000..dea2fe0b --- /dev/null +++ b/app/src/main/java/info/guardianproject/netcipher/client/StrongOkHttpClientBuilder.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-2016 Nathan Freitas + * Copyright 2015 str4d + * Portions Copyright (c) 2016 CommonsWare, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package info.guardianproject.netcipher.client; + +import android.content.Context; +import android.content.Intent; +import javax.net.ssl.SSLSocketFactory; + +import okhttp3.OkHttpClient; +import okhttp3.Request; + +/** + * Creates an OkHttpClient using NetCipher configuration. Use + * build() if you have no other OkHttpClient configuration + * that you need to perform. Or, use applyTo() to augment an + * existing OkHttpClient.Builder with NetCipher. + */ +public class StrongOkHttpClientBuilder extends + StrongBuilderBase { + /** + * Creates a StrongOkHttpClientBuilder using the strongest set + * of options for security. Use this if the strongest set of + * options is what you want; otherwise, create a + * builder via the constructor and configure it as you see fit. + * + * @param context any Context will do + * @return a configured StrongOkHttpClientBuilder + * @throws Exception + */ + static public StrongOkHttpClientBuilder forMaxSecurity(Context context) + throws Exception { + return(new StrongOkHttpClientBuilder(context) + .withBestProxy()); + } + + /** + * Creates a builder instance. + * + * @param context any Context will do; builder will hold onto + * Application context + */ + public StrongOkHttpClientBuilder(Context context) { + super(context); + } + + /** + * Copy constructor. + * + * @param original builder to clone + */ + public StrongOkHttpClientBuilder(StrongOkHttpClientBuilder original) { + super(original); + } + + /** + * OkHttp3 does not support SOCKS proxies: + * https://github.com/square/okhttp/issues/2315 + * + * @return false + */ + @Override + public boolean supportsSocksProxy() { + return(false); + } + + /** + * {@inheritDoc} + */ + @Override + public OkHttpClient build(Intent status) { + return(applyTo(new OkHttpClient.Builder(), status).build()); + } + + /** + * Adds NetCipher configuration to an existing OkHttpClient.Builder, + * in case you have additional configuration that you wish to + * perform. + * + * @param builder a new or partially-configured OkHttpClient.Builder + * @return the same builder + */ + public OkHttpClient.Builder applyTo(OkHttpClient.Builder builder, Intent status) { + SSLSocketFactory factory=buildSocketFactory(); + + if (factory!=null) { + builder.sslSocketFactory(factory); + } + + return(builder + .proxy(buildProxy(status))); + } + + @Override + protected String get(Intent status, OkHttpClient connection, + String url) throws Exception { + Request request=new Request.Builder().url(TOR_CHECK_URL).build(); + + return(connection.newCall(request).execute().body().string()); + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt b/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt index 06231c52..40899a6f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt @@ -6,7 +6,6 @@ import android.content.Intent import net.opendasharchive.openarchive.features.settings.ConsentActivity import org.cleaninsights.sdk.* -@Suppress("unused") object CleanInsightsManager { private const val CI_CAMPAIGN = "main" diff --git a/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt index da8062a1..f9bde193 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt @@ -90,14 +90,10 @@ class FolderAdapter(listener: FolderAdapterListener?) : ListAdapter? + private val mListener: WeakReference? = WeakReference(listener) private var mLastSelected: Project? = null - init { - mListener = WeakReference(listener) - } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return ViewHolder(RvSimpleRowBinding.inflate(LayoutInflater.from(parent.context), parent, false)) diff --git a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt index becdba05..ca2f1bfc 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt @@ -10,7 +10,6 @@ import com.orm.SugarApp import info.guardianproject.netcipher.proxy.OrbotHelper import net.opendasharchive.openarchive.core.di.coreModule import net.opendasharchive.openarchive.core.di.featuresModule -import net.opendasharchive.openarchive.services.tor.TOR_SERVICE_CHANNEL import net.opendasharchive.openarchive.services.tor.TorViewModel import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Theme @@ -23,8 +22,6 @@ import timber.log.Timber class SaveApp : SugarApp(), KoinComponent { - private val torViewModel: TorViewModel by inject() - override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) } @@ -47,7 +44,6 @@ class SaveApp : SugarApp(), KoinComponent { Prefs.load(this) if (Prefs.useTor) { - OrbotHelper.get(this).init() initTor() } @@ -66,7 +62,10 @@ class SaveApp : SugarApp(), KoinComponent { private fun initTor() { Timber.d( "Initializing internal tor client") - torViewModel.updateTorServiceState() + OrbotHelper.get(this).apply { + init() + requestStart(this@SaveApp) + } } @@ -74,7 +73,7 @@ class SaveApp : SugarApp(), KoinComponent { val name = "Tor Service" val descriptionText = "Keeps the Tor service running" val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(TOR_SERVICE_CHANNEL, name, importance).apply { + val channel = NotificationChannel("torService", name, importance).apply { description = descriptionText } val notificationManager: NotificationManager = diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt index 6dd921a5..11be4104 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt @@ -1,9 +1,8 @@ package net.opendasharchive.openarchive.core.di -import net.opendasharchive.openarchive.services.tor.TorRepository -import net.opendasharchive.openarchive.services.tor.torModule +import net.opendasharchive.openarchive.services.servicesModule import org.koin.dsl.module val coreModule = module { - includes(torModule) + includes(servicesModule) } diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/infrastructure/client/ClientResult.kt b/app/src/main/java/net/opendasharchive/openarchive/core/infrastructure/client/ClientResult.kt index 1ad6c532..5b82be12 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/infrastructure/client/ClientResult.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/infrastructure/client/ClientResult.kt @@ -10,17 +10,16 @@ import java.io.IOException import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -suspend fun OkHttpClient.enqueueResult( - request: Request, - onResume: (Response) -> T -) = suspendCancellableCoroutine { continuation -> +suspend fun OkHttpClient.enqueueResult( + request: Request +): Result = suspendCancellableCoroutine { continuation -> newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { continuation.resumeWithException(e) } override fun onResponse(call: Call, response: Response) { - continuation.resume(onResume(response)) + continuation.resume(Result.success(response)) } }) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt index 3de2283a..c2887743 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt @@ -37,7 +37,7 @@ class BrowseFoldersActivity : BaseActivity() { mBinding.rvFolderList.layoutManager = LinearLayoutManager(this) val space = Space.current - if (space != null) mViewModel.getFiles(this, space) + if (space != null) mViewModel.getFiles(space) mViewModel.folders.observe(this) { mBinding.projectsEmpty.toggle(it.isEmpty()) 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 1ec74006..6fa75134 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 @@ -11,6 +11,7 @@ import kotlinx.coroutines.withContext import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.services.SaveClient import net.opendasharchive.openarchive.services.gdrive.GDriveConduit +import org.koin.java.KoinJavaComponent.inject import timber.log.Timber import java.io.IOException import java.util.Date @@ -21,21 +22,25 @@ class BrowseFoldersViewModel : ViewModel() { private val mFolders = MutableLiveData>() + private val client: SaveClient by inject(SaveClient::class.java) + + private val drive: GDriveConduit by inject(GDriveConduit::class.java) + val folders: LiveData> get() = mFolders val progressBarFlag = MutableLiveData(false) - fun getFiles(context: Context, space: Space) { + fun getFiles(space: Space) { viewModelScope.launch { progressBarFlag.value = true try { val value = withContext(Dispatchers.IO) { when (space.tType) { - Space.Type.WEBDAV -> getWebDavFolders(context, space) + Space.Type.WEBDAV -> getWebDavFolders(space) - Space.Type.GDRIVE -> getGDriveFolders(context, space) + Space.Type.GDRIVE -> getGDriveFolders() else -> emptyList() } @@ -55,10 +60,10 @@ class BrowseFoldersViewModel : ViewModel() { } @Throws(IOException::class) - private suspend fun getWebDavFolders(context: Context, space: Space): List { + private fun getWebDavFolders(space: Space): List { val root = space.hostUrl?.encodedPath - return SaveClient.getSardine(context, space).list(space.host)?.mapNotNull { + return client.webdav(space).list(space.host)?.mapNotNull { if (it?.isDirectory == true && it.path != root) { Folder(it.name, it.modified ?: Date()) } @@ -68,7 +73,7 @@ class BrowseFoldersViewModel : ViewModel() { } ?: emptyList() } - private fun getGDriveFolders(context: Context, space: Space): List { - return GDriveConduit.listFoldersInRoot(GDriveConduit.getDrive(context)) + private suspend fun getGDriveFolders(): List { + return drive.listFoldersInRoot() } } \ 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 index 3e1bb47e..1ffb77eb 100644 --- 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 @@ -1,7 +1,5 @@ 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 @@ -14,11 +12,11 @@ import okhttp3.Request private const val LOGIN_URI = "https://archive.org/services/xauthn?op=login" class InternetArchiveRemoteSource( - private val context: Context, + private val client: SaveClient, private val gson: InternetArchiveGson ) { suspend fun login(request: InternetArchiveLoginRequest): Result = - SaveClient.get(context).enqueueResult( + client.enqueue( Request.Builder() .url(LOGIN_URI) .post( @@ -27,22 +25,21 @@ class InternetArchiveRemoteSource( .add("password", request.password).build() ) .build() - ) { response -> - val data = gson.fromJson( + ).map { response -> + gson.fromJson( response.body?.string(), InternetArchiveLoginResponse::class.java ) - Result.success(data) } suspend fun testConnection(auth: InternetArchive.Auth): Result = - SaveClient.get(context).enqueueResult( + client.enqueue( Request.Builder() .url(ARCHIVE_API_ENDPOINT) .method("GET", null) .addHeader("Authorization", "LOW ${auth.access}:${auth.secret}") .build() - ) { response -> - Result.success(response.isSuccessful) + ).map { response -> + response.isSuccessful } } 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 89f03dc7..67ee99a9 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 @@ -7,6 +7,7 @@ import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat +import info.guardianproject.netcipher.proxy.OrbotHelper import kotlinx.coroutines.launch import net.opendasharchive.openarchive.CleanInsightsManager import net.opendasharchive.openarchive.R @@ -14,6 +15,7 @@ import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult import net.opendasharchive.openarchive.databinding.ActivitySettingsContainerBinding import net.opendasharchive.openarchive.features.core.BaseActivity import net.opendasharchive.openarchive.services.SaveClient +import net.opendasharchive.openarchive.services.tor.TorStatus import net.opendasharchive.openarchive.services.tor.TorViewModel import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Theme @@ -23,26 +25,42 @@ import kotlin.getValue class GeneralSettingsActivity: BaseActivity() { + private val torViewModel: TorViewModel by viewModel() + + override fun onResume() { + super.onResume() + torViewModel.requestTorStatus() + } + class Fragment: PreferenceFragmentCompat() { private val torViewModel: TorViewModel by viewModel() - private var mCiConsentPref: SwitchPreferenceCompat? = null + private var hasToggled = false + private var mCiConsentPref: SwitchPreferenceCompat? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.prefs_general, rootKey) - findPreference(Prefs.USE_TOR)?.setOnPreferenceChangeListener { _, newValue -> - torViewModel.toggleTorServiceState() - true - } + val torStatusPref = findPreference("tor_status") + val useTorPref = findPreference(Prefs.USE_TOR) + + useTorPref?.setOnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + torViewModel.toggleTorServiceState(requireActivity(), enabled) + val status = if (enabled) TorStatus.CONNECTING else TorStatus.DISCONNECTED + torStatusPref?.summary = status.name.lowercase() + hasToggled = true + true + } this.lifecycleScope.launch { torViewModel.torStatus.collect { torStatus -> - findPreference("tor_status")?.setSummary( - torStatus.toString().lowercase() - ) + if (!hasToggled) { + torStatusPref?.summary = torStatus.name.lowercase() + useTorPref?.isChecked = torStatus == TorStatus.CONNECTED + } } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Module.kt new file mode 100644 index 00000000..9a1a7b43 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Module.kt @@ -0,0 +1,12 @@ +package net.opendasharchive.openarchive.services + +import net.opendasharchive.openarchive.services.gdrive.gdriveModule +import net.opendasharchive.openarchive.services.tor.torModule +import org.koin.dsl.module + +internal val servicesModule = module { + + single { SaveClient(get()) } + + includes(torModule, gdriveModule) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt index 84323cba..142ffbc8 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt @@ -3,41 +3,51 @@ package net.opendasharchive.openarchive.services import android.content.Context import android.content.Intent import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine -import info.guardianproject.netcipher.client.StrongBuilder -import info.guardianproject.netcipher.client.StrongBuilderBase +import info.guardianproject.netcipher.client.StrongOkHttpClientBuilder import info.guardianproject.netcipher.proxy.OrbotHelper -import net.opendasharchive.openarchive.R +import info.guardianproject.netcipher.proxy.OrbotHelper.SimpleStatusCallback +import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.services.tor.ITorRepository -import net.opendasharchive.openarchive.services.tor.TorStatus -import net.opendasharchive.openarchive.services.webdav.BasicAuthInterceptor -import net.opendasharchive.openarchive.util.Prefs +import okhttp3.Call import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request -import okhttp3.internal.platform.Platform +import okhttp3.Response import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import java.net.InetSocketAddress -import java.net.Proxy +import timber.log.Timber import java.util.concurrent.TimeUnit -import kotlin.coroutines.suspendCoroutine +import java.util.logging.Level +import java.util.logging.Logger -class SaveClient(context: Context) : StrongBuilderBase(context), KoinComponent { - - class OrbotException(message: String): Exception(message) +class SaveClient(private val context: Context) : SimpleStatusCallback(), KoinComponent, Call.Factory { private var okBuilder: OkHttpClient.Builder - private val torRepo: ITorRepository by inject() + private val strongBuilder: StrongOkHttpClientBuilder + + var proxyHttpPort: Int = -1 + private set + + var proxySocksPort: Int = -1 + private set init { + Logger.getLogger(OkHttpClient::class.java.name).setLevel(Level.FINE) + okBuilder = setup() + strongBuilder = StrongOkHttpClientBuilder.forMaxSecurity(context) + OrbotHelper.get(context).apply { + addStatusCallback(this@SaveClient) + init() + } + } + + private fun setup(): OkHttpClient.Builder { val cacheInterceptor = Interceptor { chain -> val request = chain.request().newBuilder().addHeader("Connection", "close").build() chain.proceed(request) } - okBuilder = OkHttpClient.Builder() + var builder = OkHttpClient.Builder() .addInterceptor(cacheInterceptor) .connectTimeout(40L, TimeUnit.SECONDS) .writeTimeout(40L, TimeUnit.SECONDS) @@ -45,109 +55,46 @@ class SaveClient(context: Context) : StrongBuilderBase .retryOnConnectionFailure(false) .protocols(arrayListOf(Protocol.HTTP_1_1)) - if (Prefs.useTor) - okBuilder = okBuilder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", torRepo.httpTunnelPort))) + return builder } - /** - * OkHttp3 [does not support SOCKS proxies.](https://github.com/square/okhttp/issues/2315) - * - * @return false - */ - override fun supportsSocksProxy(): Boolean { - return false - } + override fun onEnabled(statusIntent: Intent?) { + OrbotHelper.get(context).removeStatusCallback(this) - /** - * {@inheritDoc} - */ - override fun build(status: Intent): OkHttpClient { - if (!status.hasExtra(OrbotHelper.EXTRA_STATUS)) { - status.putExtra(OrbotHelper.EXTRA_STATUS, OrbotHelper.STATUS_OFF) + try { + strongBuilder.applyTo(okBuilder, statusIntent) + proxyHttpPort = strongBuilder.getHttpPort(statusIntent) + proxySocksPort = strongBuilder.getSocksPort(statusIntent) + } catch (e: Exception) { + Timber.e(e, "Error setting up OkHttp client") } - - return applyTo(okBuilder, status).build() } - /** - * Adds NetCipher configuration to an existing OkHttpClient.Builder, - * in case you have additional configuration that you wish to - * perform. - * - * @param builder a new or partially-configured OkHttpClient.Builder - * @return the same builder - */ - private fun applyTo(builder: OkHttpClient.Builder, status: Intent?): OkHttpClient.Builder { - val factory = buildSocketFactory() - - if (factory != null) { - val trustManager = Platform.get().trustManager(factory) - - if (trustManager != null) { - builder.sslSocketFactory(factory, trustManager) - } - } - - return builder - .proxy(buildProxy(status)) + override fun onNotYetInstalled() { + OrbotHelper.get(context).removeStatusCallback(this) + okBuilder = okBuilder.proxy(null) } - @Throws(Exception::class) - override fun get(status: Intent, connection: OkHttpClient, url: String): String? { - val request: Request = Request.Builder().url(TOR_CHECK_URL).build() + override fun onStatusTimeout() { + OrbotHelper.get(context).removeStatusCallback(this) + okBuilder = okBuilder.proxy(null) + } - return connection.newCall(request).execute().body?.string() + override fun newCall(request: Request): Call { + return okBuilder.build().newCall(request) } - companion object { - suspend fun get(context: Context, user: String = "", password: String = ""): OkHttpClient { - - val strongBuilder = SaveClient(context) - - if (user.isNotEmpty() || password.isNotEmpty()) { - strongBuilder.okBuilder.addInterceptor(BasicAuthInterceptor(user, password)) - } - - return suspendCoroutine { - val callback = object : StrongBuilder.Callback { - override fun onConnected(connection: OkHttpClient?) { - val result = if (connection != null) { - Result.success(connection) - } - else { - Result.failure(OrbotException(context.getString(R.string.tor_connection_exception))) - } - - it.resumeWith(result) - } - - override fun onConnectionException(e: java.lang.Exception?) { - it.resumeWith(Result.failure(e ?: OrbotException(context.getString(R.string.tor_connection_exception)))) - } - - override fun onTimeout() { - it.resumeWith(Result.failure(OrbotException(context.getString(R.string.tor_connection_timeout)))) - } - - override fun onInvalid() { - it.resumeWith(Result.failure(OrbotException(context.getString(R.string.tor_connection_invalid)))) - } - } - - /*if (Prefs.useTor && strongBuilder.torRepo.torStatus.value != TorStatus.CONNECTED) { - strongBuilder.build(callback) - } - else {*/ - callback.onConnected(strongBuilder.build(Intent())) - //} - } - } + suspend fun enqueue(request: Request): Result { + return okBuilder.build().enqueueResult(request) + } - suspend fun getSardine(context: Context, space: Space): OkHttpSardine { - val sardine = OkHttpSardine(get(context)) - sardine.setCredentials(space.username, space.password) + fun execute(request: Request): Response { + return okBuilder.build().newCall(request).execute() + } - return sardine - } + fun webdav(space: Space): OkHttpSardine { + val sardine = OkHttpSardine(okBuilder.build()) + sardine.setCredentials(space.username, space.password) + return sardine } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveClient.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveClient.kt new file mode 100644 index 00000000..e94f547c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveClient.kt @@ -0,0 +1,110 @@ +package net.opendasharchive.openarchive.services.gdrive + +import android.content.Context +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.http.InputStreamContent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult +import net.opendasharchive.openarchive.services.SaveClient +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.File +import java.io.IOException +import java.io.InputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +private const val BASE_API = "https://www.googleapis.com/drive/v3/files" + +class GDriveClient(private val client: SaveClient, private val credential: GoogleAccountCredential) { + suspend fun getAccessToken(credential: GoogleAccountCredential): String { + return withContext(Dispatchers.IO) { + credential.token + } + } + + suspend fun newFolder(name: String, parent: String? = null): Result { + val jsonBody = """ +{ + "name": "$name", + "mimeType": "application/vnd.google-apps.folder" + ${if (parent != null) ", \"parents\": [\"$parent\"]" else ""} +} +""".trimIndent() + + val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) + + val token = getAccessToken(credential) + + val request = Request.Builder() + .url(BASE_API) + .addHeader("Authorization", "Bearer $token") + .post(requestBody) + .build() + + return client.enqueue(request).mapCatching { response -> + response.body?.string() ?: throw IOException("No response body") + } + } + + suspend fun uploadFile(context: Context, file: File): Result { + val token = getAccessToken(credential) + val requestBody = file.asRequestBody("image/jpeg".toMediaType()) + + val request = Request.Builder() + .url("${BASE_API}?uploadType=media") + .addHeader("Authorization", "Bearer $token") + .post(requestBody) + .build() + + return client.enqueue(request).mapCatching { response -> + response.body?.string() ?: throw IOException("No response body") + } + } + + suspend fun listFolders(parents: String? = null, pageSize: Int = 1000, pageToken: String? = null): Result { + val token = getAccessToken(credential) + val request = Request.Builder() + .url("$BASE_API?q=mimeType='application/vnd.google-apps.folder'${if (parents != null) " and '$parents' in parents" else ""} and trashed=false&pageSize=$pageSize&fields=files(id,name,modifiedTime),nextPageToken${if (pageToken != null) "&pageToken=$pageToken" else ""}") + .addHeader("Authorization", "Bearer $token") + .build() + + return client.enqueue(request).mapCatching { response -> + response.body?.string() ?: throw IOException("No response body") + } + } + + suspend fun upload(file: GDriveFile, content: InputStreamContent, onProgress: ProgressListener): Result { + val requestBody = ProgressRequestBody(content, "application/octet-stream") { bytesWritten, contentLength -> + val progress = (bytesWritten.toFloat() / contentLength.toFloat()) * 100 + onProgress.onProgressUpdate(bytesWritten, progress.toInt()) + } + + val multipartBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", file.name, requestBody) + .build() + + val token = getAccessToken(credential) + + val request = Request.Builder() + .url("$BASE_API?uploadType=multipart") + .header("Authorization", "Bearer $token") + .post(multipartBody) + .build() + + return client.enqueue(request).mapCatching { response -> + response.body?.string() ?: throw IOException("No response body") + } + } + +} \ 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 2420e134..f32558ad 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 @@ -3,47 +3,19 @@ package net.opendasharchive.openarchive.services.gdrive import android.content.Context -import androidx.core.content.ContextCompat import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.common.Scopes import com.google.android.gms.common.api.Scope -import com.google.api.client.extensions.android.http.AndroidHttp -import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential -import com.google.api.client.googleapis.media.MediaHttpUploader -import com.google.api.client.http.HttpTransport import com.google.api.client.http.InputStreamContent -import com.google.api.client.http.apache.ApacheHttpTransport -import com.google.api.client.json.gson.GsonFactory -import com.google.api.services.drive.Drive import com.google.api.services.drive.DriveScopes -import com.google.api.services.drive.model.File -import info.guardianproject.netcipher.proxy.OrbotHelper -import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.features.folders.BrowseFoldersViewModel import net.opendasharchive.openarchive.services.Conduit -import net.opendasharchive.openarchive.util.Prefs -import org.apache.http.conn.ClientConnectionManager -import org.apache.http.conn.params.ConnManagerParams -import org.apache.http.conn.params.ConnPerRouteBean -import org.apache.http.conn.scheme.PlainSocketFactory -import org.apache.http.conn.scheme.Scheme -import org.apache.http.conn.scheme.SchemeRegistry -import org.apache.http.conn.ssl.SSLSocketFactory -import org.apache.http.impl.client.DefaultHttpClient -import org.apache.http.impl.client.DefaultHttpRequestRetryHandler -import org.apache.http.impl.conn.ProxySelectorRoutePlanner -import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager -import org.apache.http.params.BasicHttpParams -import org.apache.http.params.HttpConnectionParams +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import timber.log.Timber -import java.io.IOException +import java.io.File import java.io.InputStream -import java.net.InetSocketAddress -import java.net.Proxy -import java.net.ProxySelector -import java.net.SocketAddress -import java.net.URI import java.util.Date /** @@ -61,13 +33,14 @@ import java.util.Date * Another important resource is this official guide on authenticating an Android app with Google: * https://developers.google.com/identity/sign-in/android/start-integrating */ -class GDriveConduit(media: Media, context: Context) : Conduit(media, context) { +class GDriveConduit(media: Media, context: Context) : Conduit(media, context), KoinComponent { - private var mDrive: Drive = getDrive(mContext) + private val drive: GDriveRepository by inject() companion object { + const val NAME = "Google Drive" - var SCOPES = + val SCOPES = arrayOf(Scope(DriveScopes.DRIVE_FILE), Scope(Scopes.EMAIL)) fun permissionsGranted(context: Context): Boolean { @@ -77,123 +50,44 @@ class GDriveConduit(media: Media, context: Context) : Conduit(media, context) { *SCOPES ) } + } - fun getDrive(context: Context): Drive { - val credential = - GoogleAccountCredential.usingOAuth2( - context, - setOf(DriveScopes.DRIVE_FILE, Scopes.EMAIL) - ) - credential.selectedAccount = GoogleSignIn.getLastSignedInAccount(context)?.account - - // in case we need to debug authentication: - // Timber.v("GDriveConduit.getDrive(): credential $credential") - // Timber.v("GDriveConduit.getDrive(): credential.selectedAccount ${credential.selectedAccount}") - // Timber.v("GDriveConduit.getDrive(): credential.selectedAccount.name ${credential.selectedAccount?.name}") - - val transport: HttpTransport = if (Prefs.useTor) { - // initialization code copied from: ApacheHttpTransport.newDefaultHttpParams() - // This is the simplest solution I could come up with for actually sending traffic - // to GDrive through Tor. Note that all calls to deprecated functions are copied - // from the only known to work version of GDrive API. - val params = BasicHttpParams() - HttpConnectionParams.setStaleCheckingEnabled(params, false) - HttpConnectionParams.setSocketBufferSize(params, 8192) - ConnManagerParams.setMaxTotalConnections(params, 200) - ConnManagerParams.setMaxConnectionsPerRoute(params, ConnPerRouteBean(20)) - val registry = SchemeRegistry() - registry.register(Scheme("http", PlainSocketFactory.getSocketFactory(), 80)) - registry.register(Scheme("https", SSLSocketFactory.getSocketFactory(), 443)) - val connectionManager: ClientConnectionManager = - ThreadSafeClientConnManager(params, registry) - val defaultHttpClient = DefaultHttpClient(connectionManager, params) - defaultHttpClient.httpRequestRetryHandler = DefaultHttpRequestRetryHandler(0, false) - val proxySelector = object : ProxySelector() { - override fun select(uri: URI?): MutableList { - return mutableListOf( - // tried SOCKS here, but in my tests when specifying SOCKS, the uploads - // seamed to bypass proxy settings altogether and connect directly instead - Proxy( - Proxy.Type.HTTP, - InetSocketAddress( - OrbotHelper.DEFAULT_PROXY_HOST, - OrbotHelper.DEFAULT_PROXY_HTTP_PORT - ) - ) - ) - } - - override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { - Timber.e("proxy connection Failed ($uri, $sa)", ioe) - } - } - defaultHttpClient.routePlanner = ProxySelectorRoutePlanner( - registry, - proxySelector - ) - - ApacheHttpTransport(defaultHttpClient) - } else { - AndroidHttp.newCompatibleTransport() - } - - return Drive.Builder(transport, GsonFactory(), credential) - .setApplicationName(ContextCompat.getString(context, R.string.app_name)).build() - } - - private fun createFolder(gdrive: Drive, folderName: String, parent: File?): File { - val parentId: String = parent?.id ?: "root" - val folders = - gdrive.files().list().setPageSize(1) - .setQ("mimeType='application/vnd.google-apps.folder' and name = '$folderName' and trashed = false and '$parentId' in parents") - .setFields("files(id, name)").execute() - - if (folders.files.isNotEmpty()) { - // folder exists, return it now - return folders.files.first() - } - - // create new folder - val folderMeta = File() - folderMeta.name = folderName - folderMeta.parents = listOf(parentId) - folderMeta.mimeType = "application/vnd.google-apps.folder" + private suspend fun getOrCreateFolder(folderName: String, parent: GDriveFile? = null): GDriveFile { + val folder = drive.folders(pageSize = 1).getOrNull() - // return newly created folders - return gdrive.files().create(folderMeta).setFields("id").execute() + if (folder == null || folder.files.isEmpty()) { + return drive.newFolder(folderName, parent?.id).getOrThrow() } + return folder.files.first() + } - fun createFolders(mDrive: Drive, destinationPath: List): File { - var parentFolder: File? = null - for (pathElement in destinationPath) { - parentFolder = createFolder(mDrive, pathElement, parentFolder) - } - if (parentFolder == null) { - throw Exception("could not create folders $destinationPath") - } - return parentFolder + suspend fun createFolders(destinationPath: List): GDriveFileList = try { + var parentFolder: GDriveFile? = null + val result = mutableListOf() + for (pathElement in destinationPath) { + parentFolder = getOrCreateFolder(pathElement, parentFolder) + result.add(parentFolder) } + GDriveFileList(result) + } catch (e: Exception) { + throw e + } - fun listFoldersInRoot(gdrive: Drive): List { - val result = ArrayList() - try { - var pageToken: String? = null - do { - val folders = - gdrive.files().list().setPageSize(1000).setPageToken(pageToken) - .setQ("mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed = false") - .setFields("nextPageToken, files(id, name, createdTime)").execute() - for (f in folders.files) { - val date = Date(f.createdTime.value) - result.add(BrowseFoldersViewModel.Folder(f.name, date)) - } - pageToken = folders.nextPageToken - } while (pageToken != null) - } catch (e: java.lang.IllegalArgumentException) { - Timber.e(e) - } - return result + suspend fun listFoldersInRoot(): List { + val result = ArrayList() + try { + var pageToken: String? = null + do { + val folders = drive.folders("root", 1000, pageToken).getOrThrow() + folders.files.forEach { + result.add(BrowseFoldersViewModel.Folder(it.name, Date(it.modifiedTime))) + } + pageToken = folders.nextPageToken + } while (pageToken != null) + } catch (e: IllegalArgumentException) { + Timber.e(e) } + return result } override suspend fun upload(): Boolean { @@ -202,7 +96,7 @@ class GDriveConduit(media: Media, context: Context) : Conduit(media, context) { sanitize() try { - val folder = createFolders(mDrive, destinationPath) + val folder = createFolders(destinationPath).files.last() uploadMetadata(folder, destinationFileName) if (mCancelled) throw Exception("Cancelled") uploadFile(mMedia.file, folder, destinationFileName) @@ -220,7 +114,7 @@ class GDriveConduit(media: Media, context: Context) : Conduit(media, context) { throw NotImplementedError("the createFolder calls defined in Conduit don't map to GDrive API. use GDriveConduit.createFolder instead") } - private fun uploadMetadata(parent: File, fileName: String) { + private suspend fun uploadMetadata(parent: GDriveFile, fileName: String) { val metadataFileName = "$fileName.meta.json" if (mCancelled) throw java.lang.Exception("Cancelled") @@ -234,36 +128,24 @@ class GDriveConduit(media: Media, context: Context) : Conduit(media, context) { } } - private fun uploadFile( - sourceFile: java.io.File, - parentFolder: File, + private suspend fun uploadFile( + sourceFile: File, + parentFolder: GDriveFile, targetFileName: String, - ) { - uploadFile(sourceFile.inputStream(), parentFolder, targetFileName) - } + ) = uploadFile(sourceFile.inputStream(), parentFolder, targetFileName) - private fun uploadFile( + private suspend fun uploadFile( inputStream: InputStream, - parentFolder: File, + parentFolder: GDriveFile, targetFileName: String, - ) { + ) = try { - val fMeta = File() - fMeta.name = targetFileName - fMeta.parents = listOf(parentFolder.id) - val request = - mDrive.files().create(fMeta, InputStreamContent(null, inputStream)) - request.mediaHttpUploader.isDirectUploadEnabled = false - request.mediaHttpUploader.chunkSize = - 262144 // magic minimum chunk-size number (smaller number will cause exception) - request.mediaHttpUploader.setProgressListener { - if (it.uploadState == MediaHttpUploader.UploadState.MEDIA_IN_PROGRESS) { - jobProgress(it.numBytesUploaded) - } - } - val response = request.execute() + val fMeta = GDriveFile(name = targetFileName, parents = listOf(parentFolder.id!!)) + drive.upload(fMeta, InputStreamContent(null, inputStream)) { bytesWritten, percent -> + jobProgress(bytesWritten) + }.getOrThrow() } catch (e: Exception) { - Timber.e("gdrive upload of '$targetFileName' failed", e) + Timber.e(e, "gdrive upload of '$targetFileName' failed") + throw e } - } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFile.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFile.kt new file mode 100644 index 00000000..dbb0f077 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveFile.kt @@ -0,0 +1,13 @@ +package net.opendasharchive.openarchive.services.gdrive + +data class GDriveFile( + val id: String? = null, + val name: String, + val modifiedTime: String? = null, + val parents: List = emptyList() +) + +data class GDriveFileList( + val files: List, + val nextPageToken: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveRepository.kt new file mode 100644 index 00000000..55bec417 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveRepository.kt @@ -0,0 +1,31 @@ +package net.opendasharchive.openarchive.services.gdrive + +import com.google.api.client.http.InputStreamContent +import com.google.gson.Gson + +class GDriveRepository(private val client: GDriveClient, private val gson: Gson = Gson()) { + + suspend fun newFolder(name: String, parent: String? = null): Result = try { + client.newFolder(name, parent).map { + gson.fromJson(it, GDriveFile::class.java) + } + } catch (e: Exception) { + Result.failure(e) + } + + suspend fun folders(parents: String? = null, pageSize: Int = 1000, pageToken: String? = null): Result = try { + client.listFolders(parents, pageSize, pageToken).map { + gson.fromJson(it, GDriveFileList::class.java) + } + } catch (e: Exception) { + Result.failure(e) + } + + suspend fun upload(file: GDriveFile, content: InputStreamContent, onProgress: ProgressListener): Result = try { + client.upload(file, content, onProgress).map { + gson.fromJson(it, GDriveFile::class.java) + } + } catch (e: Exception) { + Result.failure(e) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/Module.kt new file mode 100644 index 00000000..da0386fe --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/Module.kt @@ -0,0 +1,22 @@ + +package net.opendasharchive.openarchive.services.gdrive + +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.common.Scopes +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.services.drive.DriveScopes +import org.koin.dsl.module + +internal val gdriveModule = module { + factory { + GDriveClient( + get(), GoogleAccountCredential.usingOAuth2( + get(), + setOf(DriveScopes.DRIVE_FILE, Scopes.EMAIL) + ).apply { + selectedAccount = GoogleSignIn.getLastSignedInAccount(context)?.account + }) + } + single { GDriveRepository(get()) } + factory { params -> GDriveConduit(params.get(), get()) } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/ProgressRequestBody.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/ProgressRequestBody.kt new file mode 100644 index 00000000..7fbe6c9d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/ProgressRequestBody.kt @@ -0,0 +1,37 @@ +package net.opendasharchive.openarchive.services.gdrive + +import com.google.api.client.http.InputStreamContent +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okio.* +import java.io.File +import java.io.InputStream + +fun interface ProgressListener { + fun onProgressUpdate(uploadedBytes: Long, percent: Int) +} + +class ProgressRequestBody( + private val content: InputStreamContent, + private val contentType: String, + private val chunkSize: Int = 262144, + private val listener: (bytesWritten: Long, contentLength: Long) -> Unit +) : RequestBody() { + + override fun contentType(): MediaType? = content.type.toMediaTypeOrNull() + + override fun contentLength(): Long = content.length + + override fun writeTo(sink: BufferedSink) { + val buffer = ByteArray(8192) + var bytesRead: Int + var totalBytesWritten = 0L + + while (content.inputStream.read(buffer).also { bytesRead = it } != -1) { + sink.write(buffer, 0, bytesRead) + totalBytesWritten += bytesRead + listener(totalBytesWritten, contentLength()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt index 6c7ddda2..9504be87 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 @@ -3,19 +3,21 @@ package net.opendasharchive.openarchive.services.internetarchive import android.content.Context import android.net.Uri import com.google.gson.GsonBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.services.Conduit import net.opendasharchive.openarchive.services.SaveClient import okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import java.io.File import java.io.IOException +import androidx.core.net.toUri -class IaConduit(media: Media, context: Context) : Conduit(media, context) { +class IaConduit(media: Media, context: Context) : Conduit(media, context), KoinComponent { + private val client: SaveClient by inject() companion object { const val ARCHIVE_BASE_URL = "https://archive.org/" @@ -39,8 +41,6 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { try { val mimeType = mMedia.mimeType - val client = SaveClient.get(mContext) - val fileName = getUploadFileName(mMedia, true) val metaJson = gson.toJson(mMedia) // val proof = getProof() @@ -78,14 +78,14 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { // Ignored. Not used here. } - private suspend fun OkHttpClient.uploadContent(fileName: String, mimeType: String) { + private fun SaveClient.uploadContent(fileName: String, mimeType: String) { val mediaUri = mMedia.originalFilePath val url = "${ARCHIVE_API_ENDPOINT}/${mMedia.serverUrl}/$fileName" val requestBody = RequestBodyUtil.create( mContext.contentResolver, - Uri.parse(mediaUri), + mediaUri.toUri(), mMedia.contentLength, mimeType.toMediaTypeOrNull(), createListener(cancellable = { !mCancelled }, onProgress = { @@ -103,7 +103,7 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { } @Throws(IOException::class) - private fun OkHttpClient.uploadMetaData(content: String, fileName: String) { + private suspend fun SaveClient.uploadMetaData(content: String, fileName: String) { val requestBody = RequestBodyUtil.create( textMediaType, content.byteInputStream(), @@ -124,7 +124,7 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { /// upload proof mode @Throws(IOException::class) - private fun OkHttpClient.uploadProofFiles(uploadFile: File) { + private suspend fun SaveClient.uploadProofFiles(uploadFile: File) { val requestBody = RequestBodyUtil.create( mContext.contentResolver, Uri.fromFile(uploadFile), @@ -219,31 +219,4 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { .add("x-archive-meta-collection", "opensource") .build() } - - @Throws(Exception::class) - private suspend fun OkHttpClient.execute(request: Request) = withContext(Dispatchers.IO) { - val result = newCall(request) - .execute() - - if (result.isSuccessful.not()) { - throw RuntimeException("${result.code}: ${result.message}") - } - } - - @Throws(Exception::class) - private fun OkHttpClient.enqueue(request: Request) { - newCall(request) - .enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - jobFailed(e) - } - - override fun onResponse(call: Call, response: Response) { - if (!response.isSuccessful) { - jobFailed(Exception("${response.code}: ${response.message}")) - } - } - - }) - } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/Module.kt index d768395c..4c987952 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/tor/Module.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/Module.kt @@ -1,9 +1,10 @@ package net.opendasharchive.openarchive.services.tor import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.qualifier.named import org.koin.dsl.module internal val torModule = module { - single { TorRepository() } - viewModel { TorViewModel(get(), get()) } + single(named("tor")) { TorRepository() } + viewModel { TorViewModel(get(), get(named("tor"))) } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/OrbotStatusReceiver.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/OrbotStatusReceiver.kt new file mode 100644 index 00000000..aac84aef --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/OrbotStatusReceiver.kt @@ -0,0 +1,27 @@ +package net.opendasharchive.openarchive.services.tor; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import info.guardianproject.netcipher.proxy.OrbotHelper +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.qualifier.named +import timber.log.Timber + +class OrbotStatusReceiver : BroadcastReceiver(), KoinComponent { + private val torRepository: ITorRepository by inject(named("tor")) + + override fun onReceive( context: Context, intent: Intent) { + if (OrbotHelper.ACTION_STATUS == intent.action) { + val status = intent.getStringExtra(OrbotHelper.EXTRA_STATUS); + when (status) { + OrbotHelper.STATUS_ON -> torRepository.updateTorStatus(TorStatus.CONNECTED) + OrbotHelper.STATUS_OFF -> torRepository.updateTorStatus(TorStatus.DISCONNECTED) + OrbotHelper.STATUS_STARTING -> torRepository.updateTorStatus(TorStatus.CONNECTING) + OrbotHelper.STATUS_STOPPING -> torRepository.updateTorStatus(TorStatus.DISCONNECTING) + else -> Timber.e("Unknown Tor status: $status") + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt deleted file mode 100644 index e4b62bea..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorForegroundService.kt +++ /dev/null @@ -1,116 +0,0 @@ -package net.opendasharchive.openarchive.services.tor - -import android.app.Notification -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ServiceInfo -import android.os.IBinder -import androidx.core.app.NotificationCompat -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.features.main.MainActivity -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.torproject.jni.TorService -import timber.log.Timber - -private const val TOR_SERVICE_ID = 2602 -internal const val TOR_SERVICE_CHANNEL = "tor_service_channel" - -class TorForegroundService : TorService(), KoinComponent { - - private val torRepo: ITorRepository by inject() - - inner class TorServiceBroadcastReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - Timber.d("intent = $intent") - when (intent.action) { - ACTION_ERROR -> { - val errorText = intent.getStringExtra(Intent.EXTRA_TEXT) - Timber.d("error = $errorText") - torRepo.updateTorStatus(TorStatus.ERROR) - } - - ACTION_STATUS -> { - val status = intent.getStringExtra(EXTRA_STATUS) - Timber.d("Tor status = $status") - - when (status) { - STATUS_ON -> { - updateNotification("Connected") - torRepo.updateTorStatus(TorStatus.CONNECTED) - } - - STATUS_OFF -> { - torRepo.updateTorStatus(TorStatus.DISCONNECTED) - } - - STATUS_STOPPING -> { - torRepo.updateTorStatus(TorStatus.DISCONNECTING) - } - - STATUS_STARTING -> { - torRepo.updateTorStatus(TorStatus.CONNECTING) - } - - else -> Timber.d("Got rogue action: ${intent.action}") - } - } - } - } - } - - private var receiver = TorServiceBroadcastReceiver() - - private val notificationManager by lazy { - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - } - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onCreate() { - super.onCreate() - registerBroadcastRecivers(receiver) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - startForeground(TOR_SERVICE_ID, createNotification("Tor is starting"), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - - torRepo.updatePorts(this.httpTunnelPort, this.socksPort) - - return START_STICKY - } - - private fun createNotification(text: String): Notification { - val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent -> - PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - } - - return NotificationCompat.Builder(this, TOR_SERVICE_CHANNEL) - .setContentTitle("Tor Service") - .setContentText(text) - .setSmallIcon(R.drawable.ic_tor) - .setContentIntent(pendingIntent) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .build() - } - - private fun registerBroadcastRecivers(receiver: BroadcastReceiver) { - LocalBroadcastManager.getInstance(applicationContext) - .registerReceiver(receiver, IntentFilter(ACTION_STATUS)) - } - - fun updateNotification(status: String) { - val notification = createNotification(status) - notificationManager.notify(TOR_SERVICE_ID, notification) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorRepository.kt index 90b5024b..054ac1d1 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorRepository.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorRepository.kt @@ -3,33 +3,17 @@ package net.opendasharchive.openarchive.services.tor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult -import net.opendasharchive.openarchive.services.SaveClient -import okhttp3.Request interface ITorRepository { val torStatus: StateFlow fun updateTorStatus(status: TorStatus) - fun updatePorts(http: Int, socks: Int) - val httpTunnelPort: Int - val socksPort: Int } class TorRepository() : ITorRepository { private val _torStatus = MutableStateFlow(TorStatus.DISCONNECTED) override val torStatus: StateFlow = _torStatus.asStateFlow() - private var _httpTunnelPort = 8118 - private var _socksPort = 9050 - - override val httpTunnelPort = _httpTunnelPort - override val socksPort = _socksPort override fun updateTorStatus(status: TorStatus) { _torStatus.value = status } - - override fun updatePorts(http: Int, socks: Int) { - _httpTunnelPort = http - _socksPort = socks - } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt index f6359220..eb4f0d50 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt @@ -4,12 +4,8 @@ enum class TorStatus { DISCONNECTED, CONNECTING, CONNECTED, - VERIFIED, DISCONNECTING, - ERROR; -} - -data class CheckTorResponse( - val IsTor: Boolean, - val IP: String, -) \ No newline at end of file + ERROR, + INVALID, + UNAVAILABLE +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt index cdd46191..a1dd6fc6 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt @@ -1,122 +1,61 @@ package net.opendasharchive.openarchive.services.tor +import android.app.Activity import android.app.Application import android.content.Intent -import android.os.Handler -import android.os.HandlerThread -import android.os.Looper -import android.util.Log -import androidx.core.content.ContentProviderCompat.requireContext import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.google.gson.Gson +import info.guardianproject.netcipher.proxy.OrbotHelper import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult -import net.opendasharchive.openarchive.services.SaveClient import net.opendasharchive.openarchive.util.Prefs -import okhttp3.Request import timber.log.Timber -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.Socket + class TorViewModel( private val application: Application, - private val torRepository: ITorRepository -) : AndroidViewModel(application) { + private val torRepository: ITorRepository, +) : AndroidViewModel(application), OrbotHelper.InstallCallback { val torStatus: StateFlow = torRepository.torStatus - init { - viewModelScope.launch { - torStatus.collect { status -> - if (status == TorStatus.CONNECTED) { - verifyTorStatus() - //verifyTorStatusWithAPI() - } - } - } - } - - private fun verifyTorStatus() { - val thread = HandlerThread("VerifyThread").apply { start() } - Handler(thread.looper).post { - val check = canConnectToSocket(torRepository.httpTunnelPort) && - isServerSocketInUse(torRepository.httpTunnelPort) - if (check) { - torRepository.updateTorStatus(TorStatus.VERIFIED) - } - } - } - - private fun canConnectToSocket(port: Int): Boolean { - try { - val socket = Socket(); - socket.connect(InetSocketAddress("localhost", port), 120); - socket.close(); - return true - } catch (e: Exception) { - return false + fun toggleTorServiceState(activity: Activity, enabled: Boolean) { + if (enabled) { + startTor(activity) + } else { + stopTor(activity) } } - fun isServerSocketInUse( port: Int): Boolean { - try { - ServerSocket(port).close(); - return false - } catch (e: Exception) { - // Could not connect. - return true + private fun startTor(activity: Activity) { + if (OrbotHelper.isOrbotInstalled(application)) { + OrbotHelper.requestStartTor(application) + } else { + OrbotHelper.get(application).addInstallCallback(this) + OrbotHelper.get(application).installOrbot(activity) } } - // API is broken on backend: https://forum.torproject.org/t/is-https-check-torproject-org-api-ip-broken/11377 - private suspend fun verifyTorStatusWithAPI() { - SaveClient.get(application).enqueueResult( - Request.Builder() - .url("https://check.torproject.org/api/ip") - .method("GET", null) - .build() - ) { response -> - try { - val check = Gson().fromJson(response.body?.string(), CheckTorResponse::class.java) - if (check.IsTor) { - torRepository.updateTorStatus(TorStatus.VERIFIED) - } - Timber.tag("TorViewModel").d("Verified Tor: ${check.IsTor}") - Result.success(response.isSuccessful) - } catch (e: Exception) { - Result.failure(e) + private fun stopTor(activity: Activity) { + if (OrbotHelper.isOrbotInstalled(application)) { + val intent = activity.packageManager.getLaunchIntentForPackage(OrbotHelper.ORBOT_PACKAGE_NAME) + if (intent != null) { + activity.startActivity(intent) + } else { + Timber.e("Orbot is not installed.") } - } - } - fun toggleTorServiceState() { - if (!Prefs.useTor) { - startTor() - } else { - stopTor() } } - fun updateTorServiceState() { - if (Prefs.useTor) { - startTor() - } else { - stopTor() - } + fun requestTorStatus() { + OrbotHelper.get(application).init() } - private fun startTor() { - Intent(getApplication(), TorForegroundService::class.java).also { intent -> - getApplication().startForegroundService(intent) - } + override fun onInstalled() { + OrbotHelper.get(application).removeInstallCallback(this) + OrbotHelper.requestStartTor(application) } - private fun stopTor() { - Intent(getApplication(), TorForegroundService::class.java).also { intent -> - getApplication().stopService(intent) - } + override fun onInstallTimeout() { + Timber.e("timeout on orbot install") } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/BasicAuthInterceptor.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/BasicAuthInterceptor.kt deleted file mode 100644 index 8f956d75..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/BasicAuthInterceptor.kt +++ /dev/null @@ -1,22 +0,0 @@ -package net.opendasharchive.openarchive.services.webdav - -import okhttp3.Credentials.basic -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response -import java.io.IOException -import kotlin.Throws - -class BasicAuthInterceptor(user: String?, password: String?) : Interceptor { - private val credentials: String - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val request: Request = chain.request() - val authenticatedRequest = request.newBuilder() - .header("Authorization", credentials).build() - return chain.proceed(authenticatedRequest) - } - init { - credentials = basic(user!!, password!!) - } -} \ 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 c824d2f4..efcbe422 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 @@ -4,23 +4,29 @@ import android.content.Context import com.thegrizzlylabs.sardineandroid.SardineListener import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import net.opendasharchive.openarchive.db.Media +import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.services.Conduit import net.opendasharchive.openarchive.services.SaveClient import okhttp3.HttpUrl +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import java.io.IOException import java.util.* -class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { +class WebDavConduit(media: Media, context: Context) : Conduit(media, context), KoinComponent { - private lateinit var mClient: OkHttpSardine + private val client: SaveClient by inject() + + private val space = mMedia.space!! + + private var webdav = client.webdav(space) override suspend fun upload(): Boolean { - val space = mMedia.space ?: return false val base = space.hostUrl ?: return false val path = getPath() ?: return false - mClient = SaveClient.getSardine(mContext, space) + webdav = client.webdav(space) sanitize() @@ -29,7 +35,7 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { try { createFolders(base, path) - uploadMetadata(base, path, fileName) + webdav.uploadMetadata(base, path, fileName) } catch (e: Throwable) { jobFailed(e) @@ -42,13 +48,13 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { // } if (mMedia.contentLength > CHUNK_FILESIZE_THRESHOLD) { - return uploadChunked(base, path, fileName) + return webdav.uploadChunked(base, path, fileName) } val fullPath = construct(base, path, fileName) try { - mClient.put(mContext.contentResolver, + webdav.put(mContext.contentResolver, fullPath, mMedia.fileUri, mMedia.contentLength, @@ -82,11 +88,11 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { } override suspend fun createFolder(url: String) { - if (!mClient.exists(url)) mClient.createDirectory(url) + if (!webdav.exists(url)) webdav.createDirectory(url) } @Throws(IOException::class) - private suspend fun uploadChunked(base: HttpUrl, path: List, fileName: String): Boolean { + private suspend fun OkHttpSardine.uploadChunked(base: HttpUrl, path: List, fileName: String): Boolean { val space = mMedia.space ?: return false val url = space.hostUrl ?: return false @@ -125,17 +131,17 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { val total = offset + length val chunkPath = construct(tmpBase, tmpPath, "$offset-$total") - val chunkExists = mClient.exists(chunkPath) + val chunkExists = exists(chunkPath) var chunkLengthMatches = false if (chunkExists) { - val dirList = mClient.list(chunkPath) + val dirList = list(chunkPath) chunkLengthMatches = !dirList.isNullOrEmpty() && dirList.first().contentLength == length.toLong() } if (!chunkExists || !chunkLengthMatches) { - mClient.put( + put( chunkPath, buffer, mMedia.mimeType, @@ -160,7 +166,7 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { val dest = mutableListOf("files", space.username) dest.addAll(path) - mClient.move(construct(tmpBase, tmpPath, ".file"), construct(tmpBase, dest, fileName)) + move(construct(tmpBase, tmpPath, ".file"), construct(tmpBase, dest, fileName)) mMedia.serverUrl = construct(base, path, fileName) @@ -175,12 +181,12 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context) { } } - private fun uploadMetadata(base: HttpUrl, path: List, fileName: String) { + private fun OkHttpSardine.uploadMetadata(base: HttpUrl, path: List, fileName: String) { val metadata = getMetadata() if (mCancelled) throw Exception("Cancelled") - mClient.put( + put( construct(base, path, "$fileName.meta.json"), metadata.toByteArray(), "text/plain", null) diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavFragment.kt index 8404a7b7..2fee3ba4 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 @@ -25,8 +25,10 @@ import net.opendasharchive.openarchive.util.AlertHelper import net.opendasharchive.openarchive.util.extensions.makeSnackBar import okhttp3.Call import okhttp3.Callback +import okhttp3.Credentials import okhttp3.Request import okhttp3.Response +import org.koin.java.KoinJavaComponent.inject import java.io.IOException import kotlin.coroutines.suspendCoroutine @@ -37,6 +39,8 @@ class WebDavFragment : Fragment() { private lateinit var mSnackbar: Snackbar private lateinit var mBinding: FragmentWebDavBinding + private val client: SaveClient by inject(SaveClient::class.java) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mSpaceId = arguments?.getLong(ARG_SPACE) ?: ARG_VAL_NEW_SPACE @@ -223,10 +227,9 @@ class WebDavFragment : Fragment() { private suspend fun testConnection() { val url = mSpace.hostUrl ?: throw IOException("400 Bad Request") - val client = SaveClient.get(requireContext(), mSpace.username, mSpace.password) - val request = Request.Builder().url(url).method("GET", null).addHeader("OCS-APIRequest", "true") + .addHeader("Authorization", Credentials.basic(mSpace.username, mSpace.password)) .addHeader("Accept", "application/json").build() return suspendCoroutine { 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 bbdb9d37..8aa61b6d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt @@ -15,6 +15,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.work.Configuration +import info.guardianproject.netcipher.proxy.OrbotHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -25,45 +26,17 @@ import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.features.main.MainActivity import net.opendasharchive.openarchive.services.Conduit +import net.opendasharchive.openarchive.services.tor.ITorRepository +import net.opendasharchive.openarchive.services.tor.TorStatus import net.opendasharchive.openarchive.util.Prefs +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.qualifier.named 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() { +class UploadService : JobService(), KoinComponent { companion object { private const val MY_BACKGROUND_JOB = 0 @@ -97,8 +70,7 @@ class UploadService : JobService() { private var mKeepUploading = true private val mConduits = ArrayList() private lateinit var notification: Notification - -// private val constraints = Constraints.Builder() + private val torRepo: ITorRepository by inject(named("tor")) override fun onCreate() { super.onCreate() @@ -113,18 +85,6 @@ class UploadService : JobService() { 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()) @@ -156,7 +116,7 @@ class UploadService : JobService() { return true } - private suspend fun upload(completed: () -> Unit) { + private fun upload(completed: () -> Unit) { if (mRunning) { return completed() } @@ -180,7 +140,7 @@ class UploadService : JobService() { ) { val datePublish = Date() - val media = results.removeFirst() + val media = results.removeAt(0) if (media.sStatus != Media.Status.Uploading) { media.uploadDate = datePublish @@ -218,7 +178,7 @@ class UploadService : JobService() { } @Throws(IOException::class) - private suspend fun upload(media: Media): Boolean { + private fun upload(media: Media): Boolean { val conduit = Conduit.get(media, this) ?: return false @@ -269,7 +229,7 @@ class UploadService : JobService() { } private fun isTorAvailable(): Boolean { - return false + return torRepo.torStatus.value == TorStatus.CONNECTED } private fun isNetworkAvailable(requireUnmetered: Boolean): Boolean { diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/extensions/PackageManager.kt b/app/src/main/java/net/opendasharchive/openarchive/util/extensions/PackageManager.kt index 59ff2771..796e9436 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/extensions/PackageManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/extensions/PackageManager.kt @@ -3,7 +3,7 @@ package net.opendasharchive.openarchive.util.extensions import android.content.pm.PackageManager import android.os.Build -fun PackageManager.getVersionName(packageName: String): String { +fun PackageManager.getVersionName(packageName: String): String? { return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) @@ -12,7 +12,7 @@ fun PackageManager.getVersionName(packageName: String): String { getPackageInfo(packageName, 0) }.versionName - } catch (e: PackageManager.NameNotFoundException) { + } catch (_: PackageManager.NameNotFoundException) { "unknown" } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index acf131d3..cdd8acdf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,5 +297,6 @@ Sign up No account? Continue + Orbot Status diff --git a/app/src/main/res/xml/prefs_general.xml b/app/src/main/res/xml/prefs_general.xml index 1e6ac1f0..7a95da1e 100644 --- a/app/src/main/res/xml/prefs_general.xml +++ b/app/src/main/res/xml/prefs_general.xml @@ -1,6 +1,6 @@ - + - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + app:summary="@string/prefs_theme_summary" + app:title="@string/prefs_theme_title" /> - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 52e188cf..2e8ef95d 100644 --- a/build.gradle +++ b/build.gradle @@ -52,7 +52,8 @@ buildscript { classpath "com.neenbedankt.gradle.plugins:android-apt:1.8" classpath "com.testdroid:gradle:2.63.1" classpath "gradle.plugin.com.browserstack.gradle:browserstack-gradle-plugin:2.3.1" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.23" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20" + classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.1.20' } } From 2d0c2efc0f49b221699aa002225f488515fe4e36 Mon Sep 17 00:00:00 2001 From: Elelan's Macbook Pro Date: Mon, 12 May 2025 00:46:04 +0530 Subject: [PATCH 04/15] major ui cleanup - phase 1 removed unused activities and layout files converting activities into fragments and link them in nav_graph space_setup_navigation.xml --- app/detekt-baseline.xml | 1 - app/src/main/AndroidManifest.xml | 48 +---- .../opendasharchive/openarchive/db/Space.kt | 59 ++---- .../openarchive/features/core/BaseActivity.kt | 2 + .../openarchive/features/core/NavArgument.kt | 9 + .../features/folders/AddFolderActivity.kt | 105 ----------- .../features/folders/AddFolderScreen.kt | 2 + .../features/folders/BrowseFoldersFragment.kt | 3 +- .../folders/CreateNewFolderFragment.kt | 5 +- .../presentation/InternetArchiveActivity.kt | 83 -------- .../presentation/InternetArchiveFragment.kt | 79 -------- .../presentation/InternetArchiveScreen.kt | 21 --- .../details/InternetArchiveDetailsFragment.kt | 51 +++++ .../details/InternetArchiveDetailsScreen.kt | 62 +++--- .../login/InternetArchiveLoginFragment.kt | 53 ++++++ .../login/InternetArchiveLoginState.kt | 1 + .../openarchive/features/main/MainActivity.kt | 4 +- .../features/main/ui/MainMediaScreen.kt | 9 +- .../features/onboarding/SpaceSetupActivity.kt | 51 +++-- .../features/settings/EditFolderActivity.kt | 134 ------------- .../features/settings/EditFolderFragment.kt | 135 +++++++++++++ .../features/settings/FolderListFragment.kt | 91 +++++++++ .../features/settings/FoldersActivity.kt | 144 -------------- .../settings/GeneralSettingsActivity.kt | 178 ------------------ .../features/settings/SettingsFragment.kt | 28 ++- .../features/settings/SpaceSetupFragment.kt | 42 +---- .../features/spaces/SpaceListFragment.kt | 47 ++--- .../services/webdav/WebDavActivity.kt | 71 ------- .../upload/UploadManagerActivity.kt | 151 --------------- .../main/res/layout/activity_add_folder.xml | 172 ----------------- .../layout/activity_settings_container.xml | 75 -------- .../res/layout/activity_upload_manager.xml | 21 --- app/src/main/res/layout/activity_webdav.xml | 41 ---- .../res/layout/content_upload_manager.xml | 18 -- ...it_folder.xml => fragment_edit_folder.xml} | 13 +- ...y_folders.xml => fragment_folder_list.xml} | 34 +--- .../main/res/layout/fragment_space_list.xml | 16 -- .../res/navigation/space_setup_navigation.xml | 79 ++++++-- gradle/libs.versions.toml | 2 +- 39 files changed, 569 insertions(+), 1571 deletions(-) create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/core/NavArgument.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderActivity.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsFragment.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginFragment.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderActivity.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderFragment.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderListFragment.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersActivity.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavActivity.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt delete mode 100644 app/src/main/res/layout/activity_add_folder.xml delete mode 100644 app/src/main/res/layout/activity_settings_container.xml delete mode 100644 app/src/main/res/layout/activity_upload_manager.xml delete mode 100644 app/src/main/res/layout/activity_webdav.xml delete mode 100644 app/src/main/res/layout/content_upload_manager.xml rename app/src/main/res/layout/{activity_edit_folder.xml => fragment_edit_folder.xml} (94%) rename app/src/main/res/layout/{activity_folders.xml => fragment_folder_list.xml} (55%) delete mode 100644 app/src/main/res/layout/fragment_space_list.xml diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index e46e32ce..aa96555c 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -389,7 +389,6 @@ FinalNewline:VideoRequestHandler.kt$net.opendasharchive.openarchive.fragments.VideoRequestHandler.kt FinalNewline:ViewExtension.kt$net.opendasharchive.openarchive.extensions.ViewExtension.kt FinalNewline:WebDAVModel.kt$net.opendasharchive.openarchive.db.WebDAVModel.kt - FinalNewline:WebDavActivity.kt$net.opendasharchive.openarchive.services.webdav.WebDavActivity.kt FinalNewline:WebDavConduit.kt$net.opendasharchive.openarchive.services.webdav.WebDavConduit.kt FinalNewline:WebDavFragment.kt$net.opendasharchive.openarchive.services.webdav.WebDavFragment.kt FinalNewline:WebDavSetupLicenseFragment.kt$net.opendasharchive.openarchive.services.webdav.WebDavSetupLicenseFragment.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 990ca5c9..b3f39e1e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -105,7 +105,8 @@ android:name=".features.main.HomeActivity" android:exported="true" android:screenOrientation="portrait" - android:theme="@style/SaveAppTheme.NoActionBar"> + android:theme="@style/SaveAppTheme.NoActionBar" + tools:ignore="DiscouragedApi"> @@ -142,44 +143,10 @@ - - - - - - - - - - - - - - - - - - - { return findAll(Space::class.java) @@ -131,6 +123,15 @@ data class Space( activity.startActivity(Intent(activity, SpaceSetupActivity::class.java)) } } + + fun navigate(activity: FragmentActivity) { + if (getAll().hasNext()) { + activity.finish() + } else { + activity.finishAffinity() + activity.startActivity(Intent(activity, SpaceSetupActivity::class.java)) + } + } } val friendlyName: String @@ -151,7 +152,7 @@ data class Space( var tType: Type get() = Type.entries.first { it.id == type } set(value) { - type = (value ?: Type.WEBDAV).id + type = value.id } var license: String? @@ -199,45 +200,30 @@ data class Space( "space_id = ? AND description = ?", id.toString(), description - ).size > 0 + ).isNotEmpty() } - fun getAvatar(context: Context, style: IconStyle = IconStyle.SOLID): Drawable? { - - - return when (tType) { + fun getAvatar(context: Context): Drawable? = 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) + ) Type.GDRIVE -> ContextCompat.getDrawable( context, R.drawable.logo_gdrive_outline - ) // ?.tint(color) - - Type.RAVEN -> ContextCompat.getDrawable(context, R.drawable.snowbird) // ?.tint(color) - - else -> { - val color = ContextCompat.getColor(context, R.color.colorOnBackground) - BitmapDrawable( - context.resources, - DrawableUtil.createCircularTextDrawable(initial, color) - ) - } + ) - } + Type.RAVEN -> ContextCompat.getDrawable(context, R.drawable.snowbird) } @Composable - fun getAvatar(): Painter { - - return when (tType) { + fun getAvatar(): Painter = when (tType) { Type.WEBDAV -> painterResource(R.drawable.ic_space_private_server) Type.INTERNET_ARCHIVE -> painterResource(R.drawable.ic_space_interent_archive) @@ -245,15 +231,8 @@ data class Space( Type.GDRIVE -> painterResource(R.drawable.logo_gdrive_outline) Type.RAVEN -> painterResource(R.drawable.ic_space_dweb) - null -> { - val context = LocalContext.current - val color = ContextCompat.getColor(context, R.color.colorOnBackground) - val bitmap = DrawableUtil.createCircularTextDrawable(initial, color) - val imageBitmap = bitmap.asImageBitmap() - BitmapPainter(imageBitmap) - } } - } + fun setAvatar(view: ImageView) { when (tType) { 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 4f49271a..63699944 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 @@ -6,6 +6,7 @@ import android.view.ViewGroup import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import com.google.android.material.appbar.MaterialToolbar import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme @@ -40,6 +41,7 @@ abstract class BaseActivity : AppCompatActivity() { // Add ComposeView if not already present if (rootView.findViewById(R.id.compose_dialog_host) == null) { ComposeView(this).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) id = R.id.compose_dialog_host layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/NavArgument.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/NavArgument.kt new file mode 100644 index 00000000..ca16436d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/NavArgument.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.features.core + +object NavArgument { + const val START_DESTINATION = "start_destination" + const val SPACE_ID = "space_id" + const val FOLDER_ID = "folder_id" + const val FOLDER_NAME = "folder_name" + const val SHOW_ARCHIVED_FOLDERS = "show_archived_folders" +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderActivity.kt deleted file mode 100644 index f04c5ee0..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderActivity.kt +++ /dev/null @@ -1,105 +0,0 @@ -package net.opendasharchive.openarchive.features.folders - -import android.content.Intent -import android.os.Bundle -import android.view.MenuItem -import androidx.activity.compose.setContent -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity - -class AddFolderActivity : BaseActivity() { - - companion object { - const val EXTRA_FOLDER_ID = "folder_id" - const val EXTRA_FOLDER_NAME = "folder_name" - } - - private lateinit var mResultLauncher: ActivityResultLauncher - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mResultLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - setResult(RESULT_OK, it.data) - finish() - } else { - val name = it.data?.getStringExtra(EXTRA_FOLDER_NAME) - - if (!name.isNullOrBlank()) { - val i = Intent(this, CreateNewFolderFragment::class.java) - i.putExtra(EXTRA_FOLDER_NAME, name) - - mResultLauncher.launch(i) - } - } - } - - //mBinding = ActivityAddFolderBinding.inflate(layoutInflater) - //setContentView(mBinding.root) - - - setContent { - - SaveAppTheme { - - AddFolderScreen( -// onCreateFolder = { -// setFolder(browse = false) -// }, -// onBrowseFolders = { -// setFolder(browse = true) -// }, -// onNavigateBack = { -// finish() -// } - ) - } - } - - - - - // We cannot browse the Internet Archive. Directly forward to creating a project, - // as it doesn't make sense to show a one-option menu. - if (Space.current?.tType == Space.Type.INTERNET_ARCHIVE) { - //mBinding.browseFolderContainer.hide() - - finish() - setFolder(false) - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - finish() - - return true - } - } - - return super.onOptionsItemSelected(item) - } - - private fun setFolder(browse: Boolean) { - if (Space.current == null) { - finish() - startActivity(Intent(this, SpaceSetupActivity::class.java)) - - return - } - - mResultLauncher.launch( - Intent( - this, - if (browse) BrowseFoldersFragment::class.java else CreateNewFolderFragment::class.java - ) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderScreen.kt index f7dd6932..04869121 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/AddFolderScreen.kt @@ -43,6 +43,8 @@ import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +// This screen is used in space_setup_navigation.xml nav graph +@Suppress("unused") @Composable fun AddFolderScreen() { diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersFragment.kt index 042c8a81..3b530d0d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersFragment.kt @@ -18,6 +18,7 @@ import net.opendasharchive.openarchive.databinding.FragmentBrowseFoldersBinding import net.opendasharchive.openarchive.db.Project import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.core.BaseFragment +import net.opendasharchive.openarchive.features.core.NavArgument import net.opendasharchive.openarchive.features.core.dialog.showSuccessDialog import net.opendasharchive.openarchive.util.extensions.toggle import org.koin.androidx.viewmodel.ext.android.viewModel @@ -98,7 +99,7 @@ class BrowseFoldersFragment : BaseFragment(), MenuProvider { private fun navigateBackWithResult(projectId: Long) { requireActivity().setResult(RESULT_OK, Intent().apply { - putExtra(AddFolderActivity.EXTRA_FOLDER_ID, projectId) + putExtra(NavArgument.FOLDER_ID, projectId) }) requireActivity().finish() } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderFragment.kt index 7386f33b..08b48214 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/CreateNewFolderFragment.kt @@ -18,6 +18,7 @@ import net.opendasharchive.openarchive.databinding.FragmentCreateNewFolderBindin import net.opendasharchive.openarchive.db.Project import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.core.BaseFragment +import net.opendasharchive.openarchive.features.core.NavArgument import net.opendasharchive.openarchive.features.core.dialog.showSuccessDialog import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager import net.opendasharchive.openarchive.util.extensions.hide @@ -45,7 +46,7 @@ class CreateNewFolderFragment : BaseFragment() { super.onViewCreated(view, savedInstanceState) val intent = requireActivity().intent - binding.newFolder.setText(intent.getStringExtra(AddFolderActivity.EXTRA_FOLDER_NAME)) + binding.newFolder.setText(intent.getStringExtra(NavArgument.FOLDER_NAME)) binding.newFolder.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_DONE) { @@ -142,7 +143,7 @@ class CreateNewFolderFragment : BaseFragment() { private fun navigateBackWithResult(projectId: Long) { val i = Intent() - i.putExtra(AddFolderActivity.EXTRA_FOLDER_ID, projectId) + i.putExtra(NavArgument.FOLDER_ID, projectId) requireActivity().setResult(RESULT_OK, i) requireActivity().finish() diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt deleted file mode 100644 index 87c6aef3..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt +++ /dev/null @@ -1,83 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation - -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.dialog.DialogHost -import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager -import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult -import net.opendasharchive.openarchive.features.internetarchive.presentation.components.getSpace -import net.opendasharchive.openarchive.features.internetarchive.presentation.login.ComposeAppBar -import net.opendasharchive.openarchive.features.main.MainActivity -import org.koin.androidx.viewmodel.ext.android.viewModel - -@Deprecated("use jetpack compose") -class InternetArchiveActivity : AppCompatActivity() { - - val dialogManager: DialogStateManager by viewModel() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val (space, isNewSpace) = intent.extras.getSpace(Space.Type.INTERNET_ARCHIVE) - - setContent { - - SaveAppTheme { - - DialogHost(dialogManager) - - Scaffold( - topBar = { - ComposeAppBar( - title = stringResource(R.string.internet_archive), - onNavigationAction = { onComplete(IAResult.Cancelled) } - ) - } - ) { paddingValues -> - Box(modifier = Modifier - .fillMaxSize() - .padding(paddingValues)) { - InternetArchiveScreen(space, isNewSpace) { - onComplete(it) - } - } - } - } - - - } - } - - private fun onComplete(result: IAResult) { - when (result) { - IAResult.Saved -> { - startActivity(Intent(this, MainActivity::class.java)) - // measureNewBackend(Space.Type.INTERNET_ARCHIVE) - } - - IAResult.Deleted -> Space.navigate(this) - IAResult.Cancelled -> onBackPressed() - } - } -} - -//fun Activity.measureNewBackend(type: Space.Type) { -// CleanInsightsManager.getConsent(this) { -// CleanInsightsManager.measureEvent( -// "backend", -// "new", -// type.friendlyName -// ) -// } -//} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt deleted file mode 100644 index c2a2fb53..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt +++ /dev/null @@ -1,79 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import androidx.navigation.fragment.findNavController -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult -import net.opendasharchive.openarchive.features.internetarchive.presentation.components.bundleWithNewSpace -import net.opendasharchive.openarchive.features.internetarchive.presentation.components.bundleWithSpaceId -import net.opendasharchive.openarchive.features.internetarchive.presentation.components.getSpace -import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.core.ToolbarConfigurable - -@Deprecated("only used for backward compatibility") -class InternetArchiveFragment : BaseFragment(), ToolbarConfigurable { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - val (space, isNewSpace) = arguments.getSpace(Space.Type.INTERNET_ARCHIVE) - - return ComposeView(requireContext()).apply { - setContent { - InternetArchiveScreen(space, isNewSpace) { result -> - finish(result) - } - } - } - } - - private fun finish(result: IAResult) { - if (isJetpackNavigation) { - when (result) { - IAResult.Saved -> { - val message = getString(R.string.you_have_successfully_connected_to_the_internet_archive) - val action = InternetArchiveFragmentDirections.actionFragmentInternetArchiveToFragmentSpaceSetupSuccess(message) - findNavController().navigate(action) - } - IAResult.Deleted -> TODO() - IAResult.Cancelled -> findNavController().popBackStack() - } - } else { - setFragmentResult(result.value, bundleOf()) - - if (result == IAResult.Saved) { - // activity?.measureNewBackend(Space.Type.INTERNET_ARCHIVE) - } - } - } - - companion object { - - val RESP_SAVED = IAResult.Saved.value - val RESP_CANCEL = IAResult.Cancelled.value - - @JvmStatic - fun newInstance(args: Bundle) = InternetArchiveFragment().apply { - arguments = args - } - - @JvmStatic - fun newInstance(spaceId: Long) = newInstance(args = bundleWithSpaceId(spaceId)) - - @JvmStatic - fun newInstance() = newInstance(args = bundleWithNewSpace()) - } - - override fun getToolbarTitle() = getString(R.string.internet_archive) - override fun shouldShowBackButton() = true -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt deleted file mode 100644 index 4429b4cd..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt +++ /dev/null @@ -1,21 +0,0 @@ -package net.opendasharchive.openarchive.features.internetarchive.presentation - -import androidx.compose.runtime.Composable -import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult -import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsScreen -import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginScreen - -@Composable -fun InternetArchiveScreen(space: Space, isNewSpace: Boolean, onFinish: (IAResult) -> Unit) = SaveAppTheme { - if (isNewSpace) { - InternetArchiveLoginScreen(space) { - onFinish(it) - } - } else { - InternetArchiveDetailsScreen(space) { - onFinish(it) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsFragment.kt new file mode 100644 index 00000000..fe71c7b2 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsFragment.kt @@ -0,0 +1,51 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.details + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.compose.content +import androidx.navigation.fragment.findNavController +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.core.BaseFragment +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.getSpace + +class InternetArchiveDetailsFragment: BaseFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = content { + + val (space, isNewSpace) = arguments.getSpace(Space.Type.INTERNET_ARCHIVE) + + SaveAppTheme { + InternetArchiveDetailsScreen( + space = space, + dialogManager = dialogManager, + onResult = { result -> + handleResult(result) + } + ) + } + + } + + private fun handleResult(result: IAResult) { + when (result) { + IAResult.Saved -> TODO() + IAResult.Deleted -> { + findNavController().popBackStack() + } + IAResult.Cancelled -> { + findNavController().popBackStack() + } + } + } + + override fun getToolbarTitle(): String = getString(R.string.internet_archive) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt index b006feb4..8cd15723 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt @@ -32,13 +32,18 @@ import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager import net.opendasharchive.openarchive.features.core.dialog.showDialog import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.InternetArchiveHeader import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsViewModel.Action import net.opendasharchive.openarchive.features.internetarchive.presentation.login.CustomTextField import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @Composable -fun InternetArchiveDetailsScreen(space: Space, onResult: (IAResult) -> Unit) { +fun InternetArchiveDetailsScreen( + space: Space, + onResult: (IAResult) -> Unit, + dialogManager: DialogStateManager, +) { val viewModel: InternetArchiveDetailsViewModel = koinViewModel { parametersOf(space) } @@ -55,14 +60,36 @@ fun InternetArchiveDetailsScreen(space: Space, onResult: (IAResult) -> Unit) { } } - InternetArchiveDetailsContent(state, viewModel::dispatch) + InternetArchiveDetailsContent( + state = state, + onRemove = { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + title = UiText.StringResource(R.string.remove_from_app) + message = + UiText.StringResource(R.string.are_you_sure_you_want_to_remove_this_server_from_the_app) + icon = UiImage.DrawableResource(R.drawable.ic_trash) + destructiveButton { + text = UiText.StringResource(R.string.remove) + action = { + viewModel.dispatch(Action.Remove) + } + } + + neutralButton { + text = UiText.StringResource(R.string.action_cancel) + action = { + //dismiss + } + } + } + } + ) } @Composable private fun InternetArchiveDetailsContent( state: InternetArchiveDetailsState, - dispatch: Dispatch, - dialogManager: DialogStateManager = koinViewModel() + onRemove: () -> Unit, ) { Box( @@ -73,7 +100,7 @@ private fun InternetArchiveDetailsContent( Column { - //InternetArchiveHeader() + Text(stringResource(R.string.account), fontSize = 18.sp, color = MaterialTheme.colorScheme.onSurface) Spacer(Modifier.height(ThemeDimensions.spacing.large)) @@ -110,28 +137,7 @@ private fun InternetArchiveDetailsContent( horizontalArrangement = Arrangement.Center ) { TextButton( - onClick = { - //isRemoving = true - - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - title = UiText.StringResource(R.string.remove_from_app) - message = UiText.StringResource(R.string.are_you_sure_you_want_to_remove_this_server_from_the_app) - icon = UiImage.DrawableResource(R.drawable.ic_trash) - destructiveButton { - text = UiText.StringResource(R.string.remove) - action = { - dispatch(Action.Remove) - } - } - - neutralButton { - text = UiText.StringResource(R.string.action_cancel) - action = { - //dismiss - } - } - } - }, + onClick = onRemove, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error ) @@ -161,7 +167,7 @@ private fun InternetArchiveScreenPreview() { userName = "@abc_name", screenName = "ABC Name" ), - dispatch = {} + onRemove = {} ) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginFragment.kt new file mode 100644 index 00000000..00794f8c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginFragment.kt @@ -0,0 +1,53 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.login + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.compose.content +import androidx.navigation.fragment.findNavController +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.core.BaseFragment +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.getSpace + +class InternetArchiveLoginFragment: BaseFragment() { + + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = content { + + val (space, isNewSpace) = arguments.getSpace(Space.Type.INTERNET_ARCHIVE) + SaveAppTheme { + InternetArchiveLoginScreen(space) { result -> + handleResult(result) + } + } + } + + private fun handleResult(result: IAResult) { + when (result) { + IAResult.Saved -> { + val message = getString(R.string.you_have_successfully_connected_to_the_internet_archive) + val action = + InternetArchiveLoginFragmentDirections.actionFragmentInternetArchiveLoginToFragmentSpaceSetupSuccess( + message = message, + ) + findNavController().navigate(action) + } + IAResult.Deleted -> { + findNavController().popBackStack() + } + IAResult.Cancelled -> { + findNavController().popBackStack() + } + } + } + + override fun getToolbarTitle(): String = getString(R.string.internet_archive) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt index 12bdc378..dcf2872f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt @@ -1,5 +1,6 @@ package net.opendasharchive.openarchive.features.internetarchive.presentation.login +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive 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 8b364488..f945e515 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 @@ -39,6 +39,7 @@ import net.opendasharchive.openarchive.db.Project import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.extensions.getMeasurments import net.opendasharchive.openarchive.features.core.BaseActivity +import net.opendasharchive.openarchive.features.core.NavArgument import net.opendasharchive.openarchive.features.core.UiImage import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.core.asUiImage @@ -48,7 +49,6 @@ import net.opendasharchive.openarchive.features.core.dialog.DialogConfig import net.opendasharchive.openarchive.features.core.dialog.DialogType import net.opendasharchive.openarchive.features.core.dialog.showDialog import net.opendasharchive.openarchive.features.core.dialog.showInfoDialog -import net.opendasharchive.openarchive.features.folders.AddFolderActivity import net.opendasharchive.openarchive.features.main.adapters.FolderDrawerAdapter import net.opendasharchive.openarchive.features.main.adapters.FolderDrawerAdapterListener import net.opendasharchive.openarchive.features.main.adapters.SpaceDrawerAdapter @@ -123,7 +123,7 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda private val mNewFolderResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { - refreshProjects(it.data?.getLongExtra(AddFolderActivity.EXTRA_FOLDER_ID, -1)) + refreshProjects(it.data?.getLongExtra(NavArgument.FOLDER_ID, -1)) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt index 64a64207..65cfaebb 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/MainMediaScreen.kt @@ -7,9 +7,7 @@ import android.os.Handler import android.os.Looper import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,9 +20,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error @@ -56,8 +51,6 @@ import net.opendasharchive.openarchive.db.Collection import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.features.media.PreviewActivity import net.opendasharchive.openarchive.upload.BroadcastManager -import net.opendasharchive.openarchive.upload.UploadManagerActivity -import org.koin.androidx.compose.koinViewModel /** * A data class representing one “section” (i.e. one Collection and its list of Media). @@ -400,7 +393,7 @@ private fun handleMediaClick(context: Context, media: Media, onError: (Media) -> Media.Status.Queued, Media.Status.Uploading -> { // Start the upload manager activity - context.startActivity(Intent(context, UploadManagerActivity::class.java)) + //TODO: show the bottom sheet for EditUploads here } Media.Status.Error -> { 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 144d87a7..c4e732cd 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt @@ -1,6 +1,9 @@ package net.opendasharchive.openarchive.features.onboarding +import android.content.Intent import android.os.Bundle +import androidx.annotation.IdRes +import androidx.core.bundle.bundleOf import androidx.fragment.app.Fragment import androidx.navigation.NavController import androidx.navigation.NavGraph @@ -11,12 +14,14 @@ import androidx.navigation.ui.setupActionBarWithNavController import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.databinding.ActivitySpaceSetupBinding import net.opendasharchive.openarchive.features.core.BaseActivity +import net.opendasharchive.openarchive.features.core.NavArgument import net.opendasharchive.openarchive.features.core.ToolbarConfigurable enum class StartDestination { SPACE_TYPE, SPACE_LIST, DWEB_DASHBOARD, + FOLDER_LIST, ADD_FOLDER, ADD_NEW_FOLDER } @@ -60,31 +65,39 @@ class SpaceSetupActivity : BaseActivity() { initSpaceSetupNavigation() } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + navController.handleDeepLink(intent) + } + private fun initSpaceSetupNavigation() { - val navHostFragment = + val navHost = supportFragmentManager.findFragmentById(R.id.space_nav_host_fragment) as NavHostFragment - navController = navHostFragment.navController + navController = navHost.navController navGraph = navController.navInflater.inflate(R.navigation.space_setup_navigation) - val startDestinationString = - intent.getStringExtra("start_destination") ?: StartDestination.SPACE_TYPE.name - val startDestination = StartDestination.valueOf(startDestinationString) - when (startDestination) { - StartDestination.SPACE_LIST -> { - navGraph.setStartDestination(R.id.fragment_space_list) - } - StartDestination.ADD_FOLDER -> { - navGraph.setStartDestination(R.id.fragment_add_folder) - } - StartDestination.ADD_NEW_FOLDER -> { - navGraph.setStartDestination(R.id.fragment_create_new_folder) - } - else -> { - navGraph.setStartDestination(R.id.fragment_space_setup) - } + val startDestName = + intent.getStringExtra(NavArgument.START_DESTINATION) ?: StartDestination.SPACE_TYPE.name + val startDestination = StartDestination.valueOf(startDestName) + @IdRes val startDest = when (startDestination) { + StartDestination.SPACE_TYPE -> R.id.fragment_space_setup + StartDestination.SPACE_LIST -> R.id.fragment_space_list + StartDestination.DWEB_DASHBOARD -> R.id.fragment_snowbird + StartDestination.FOLDER_LIST -> R.id.fragment_folder_list + StartDestination.ADD_FOLDER -> R.id.fragment_add_folder + StartDestination.ADD_NEW_FOLDER -> R.id.fragment_create_new_folder } - navController.graph = navGraph + + val startArgs: Bundle? = if (startDestination == StartDestination.FOLDER_LIST) { + bundleOf(NavArgument.SHOW_ARCHIVED_FOLDERS to intent.getBooleanExtra(NavArgument.SHOW_ARCHIVED_FOLDERS, false)) + bundleOf(NavArgument.SPACE_ID to intent.getLongExtra(NavArgument.SPACE_ID, -1L)) + bundleOf(NavArgument.FOLDER_ID to intent.getLongExtra(NavArgument.FOLDER_ID, -1L)) + } else null + + navGraph.setStartDestination(startDest) + + navController.setGraph(navGraph, startArgs) appBarConfiguration = AppBarConfiguration(emptySet()) setupActionBarWithNavController(navController, appBarConfiguration) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderActivity.kt deleted file mode 100644 index 9f0ef994..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderActivity.kt +++ /dev/null @@ -1,134 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.os.Bundle -import android.view.MenuItem -import android.view.inputmethod.EditorInfo -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityEditFolderBinding -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.core.UiImage -import net.opendasharchive.openarchive.features.core.UiText -import net.opendasharchive.openarchive.features.core.dialog.DialogType -import net.opendasharchive.openarchive.features.core.dialog.showDialog -import net.opendasharchive.openarchive.util.extensions.Position -import net.opendasharchive.openarchive.util.extensions.setDrawable - -class EditFolderActivity : BaseActivity() { - - companion object { - const val EXTRA_CURRENT_PROJECT_ID = "archive_extra_current_project_id" - } - - private lateinit var mProject: Project - private lateinit var mBinding: ActivityEditFolderBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val project = Project.getById(intent.getLongExtra(EXTRA_CURRENT_PROJECT_ID, -1L)) - ?: return finish() - - mProject = project - - mBinding = ActivityEditFolderBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - - setupToolbar("Edit Folder") - - mBinding.folderName.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE) { - val newName = mBinding.folderName.text.toString() - - if (newName.isNotBlank()) { - mProject.description = newName - mProject.save() - - supportActionBar?.title = newName - mBinding.folderName.hint = newName - - - setupToolbar(newName) - } - } - - false - } - - mBinding.btRemove.setOnClickListener { - showDeleteFolderConfirmDialog() - } - - mBinding.btArchive.setOnClickListener { - archiveProject() - } - - CreativeCommonsLicenseManager.initialize(mBinding.cc, null) { - mProject.licenseUrl = it - mProject.save() - } - - updateUi() - } - - private fun showDeleteFolderConfirmDialog() { - dialogManager.showDialog(dialogManager.requireResourceProvider()) { - type = DialogType.Error - icon = UiImage.DrawableResource(R.drawable.ic_trash) - title = UiText.StringResource(R.string.remove_from_app) - message = UiText.StringResource(R.string.action_remove_project) - destructiveButton { - text = UiText.StringResource(R.string.remove) - action = { - mProject.delete() - finish() - } - } - neutralButton { - text = UiText.StringResource(R.string.lbl_Cancel) - action = { - dialogManager.dismissDialog() - } - } - } - } - - private fun archiveProject() { - mProject.isArchived = !mProject.isArchived - mProject.save() - - updateUi() - } - - private fun updateUi() { - supportActionBar?.title = mProject.description - - mBinding.folderName.isEnabled = !mProject.isArchived - mBinding.folderName.hint = mProject.description - mBinding.folderName.setText(mProject.description) - - mBinding.btArchive.setText(if (mProject.isArchived) - R.string.action_unarchive_project else - R.string.action_archive_project) - - val global = mProject.space?.license != null - - if (global) { - mBinding.cc.tvCcLabel.setText(R.string.set_the_same_creative_commons_license_for_all_folders_on_this_server) - } - - CreativeCommonsLicenseManager.initialize(mBinding.cc, mProject.licenseUrl, !mProject.isArchived && !global) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - finish() - return true - } - } - - return super.onOptionsItemSelected(item) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderFragment.kt new file mode 100644 index 00000000..16cfb915 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/EditFolderFragment.kt @@ -0,0 +1,135 @@ +package net.opendasharchive.openarchive.features.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.navigation.fragment.findNavController +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.databinding.FragmentEditFolderBinding +import net.opendasharchive.openarchive.db.Project +import net.opendasharchive.openarchive.features.core.BaseFragment +import net.opendasharchive.openarchive.features.core.NavArgument +import net.opendasharchive.openarchive.features.core.UiImage +import net.opendasharchive.openarchive.features.core.UiText +import net.opendasharchive.openarchive.features.core.dialog.DialogType +import net.opendasharchive.openarchive.features.core.dialog.showDialog + +class EditFolderFragment : BaseFragment() { + + private lateinit var project: Project + private lateinit var binding: FragmentEditFolderBinding + + override fun getToolbarTitle(): String = "Edit Folder" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + arguments?.let { + val folderId = it.getLong(NavArgument.FOLDER_ID) + if (folderId == -1L) { + throw IllegalArgumentException("Folder ID cannot be -1") + } + project = Project.getById(folderId) ?: + throw IllegalArgumentException("Project not found for ID: $folderId") + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentEditFolderBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.folderName.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + val newName = binding.folderName.text.toString() + + if (newName.isNotBlank()) { + project.description = newName + project.save() + + //TODO: update toolbar title + //supportActionBar?.title = newName + binding.folderName.hint = newName + + + //setupToolbar(newName) + } + } + + false + } + + binding.btRemove.setOnClickListener { + showDeleteFolderConfirmDialog() + } + + binding.btArchive.setOnClickListener { + archiveProject() + } + + CreativeCommonsLicenseManager.initialize(binding.cc, null) { + project.licenseUrl = it + project.save() + } + + updateUi() + } + + private fun showDeleteFolderConfirmDialog() { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Error + icon = UiImage.DrawableResource(R.drawable.ic_trash) + title = UiText.StringResource(R.string.remove_from_app) + message = UiText.StringResource(R.string.action_remove_project) + destructiveButton { + text = UiText.StringResource(R.string.remove) + action = { + project.delete() + findNavController().popBackStack() + } + } + neutralButton { + text = UiText.StringResource(R.string.lbl_Cancel) + action = { + dialogManager.dismissDialog() + } + } + } + } + + private fun archiveProject() { + project.isArchived = !project.isArchived + project.save() + + updateUi() + } + + private fun updateUi() { + //supportActionBar?.title = project.description + + binding.folderName.isEnabled = !project.isArchived + binding.folderName.hint = project.description + binding.folderName.setText(project.description) + + binding.btArchive.setText(if (project.isArchived) + R.string.action_unarchive_project else + R.string.action_archive_project) + + val global = project.space?.license != null + + if (global) { + binding.cc.tvCcLabel.setText(R.string.set_the_same_creative_commons_license_for_all_folders_on_this_server) + } + + CreativeCommonsLicenseManager.initialize(binding.cc, project.licenseUrl, !project.isArchived && !global) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderListFragment.kt new file mode 100644 index 00000000..3f936571 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FolderListFragment.kt @@ -0,0 +1,91 @@ +package net.opendasharchive.openarchive.features.settings + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import net.opendasharchive.openarchive.FolderAdapter +import net.opendasharchive.openarchive.FolderAdapterListener +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.databinding.FragmentFolderListBinding +import net.opendasharchive.openarchive.db.Project +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.core.BaseFragment +import net.opendasharchive.openarchive.features.core.NavArgument +import net.opendasharchive.openarchive.util.extensions.toggle + +class FolderListFragment : BaseFragment(), FolderAdapterListener { + + private lateinit var binding: FragmentFolderListBinding + private lateinit var adapter: FolderAdapter + + private var showArchived = true + private var selectedSpaceId = -1L + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + arguments?.let { + showArchived = it.getBoolean(NavArgument.SHOW_ARCHIVED_FOLDERS, false) + selectedSpaceId = it.getLong(NavArgument.SPACE_ID, -1L) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentFolderListBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupRecyclerView() + } + + private fun setupRecyclerView() { + adapter = FolderAdapter(context = requireContext(), listener = this, isArchived = showArchived) + binding.rvProjects.layoutManager = LinearLayoutManager(requireContext()) + binding.rvProjects.adapter = adapter + } + + override fun onResume() { + super.onResume() + refreshProjects() + } + + private fun refreshProjects() { + val projects = if (showArchived) { + Space.current?.archivedProjects + } else { + Space.current?.projects?.filter { !it.isArchived } + } ?: emptyList() + + adapter.update(projects) + + if (projects.isEmpty()) { + binding.rvProjects.visibility = View.GONE + binding.tvNoFolders.visibility = View.VISIBLE + } else { + binding.rvProjects.visibility = View.VISIBLE + binding.tvNoFolders.visibility = View.GONE + } + } + + + override fun projectClicked(project: Project) { + val action = FolderListFragmentDirections.actionFragmentFolderListToFragmentEditFolder(folderId = project.id) + findNavController().navigate(action) + } + + override fun getToolbarTitle(): String = getString(R.string.archived_folders) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersActivity.kt deleted file mode 100644 index 3e1ac954..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/FoldersActivity.kt +++ /dev/null @@ -1,144 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.recyclerview.widget.LinearLayoutManager -import net.opendasharchive.openarchive.FolderAdapter -import net.opendasharchive.openarchive.FolderAdapterListener -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityFoldersBinding -import net.opendasharchive.openarchive.db.Project -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.util.extensions.toggle - -class FoldersActivity : BaseActivity(), FolderAdapterListener { - - companion object { - const val EXTRA_SHOW_ARCHIVED = "show_archived" - const val EXTRA_SELECTED_SPACE_ID = "selected_space_id" - const val EXTRA_SELECTED_PROJECT_ID = "SELECTED_PROJECT_ID" - } - - private lateinit var mBinding: ActivityFoldersBinding - private lateinit var mAdapter: FolderAdapter - - private var mArchived = true - private var mSelectedSpaceId = -1L - private var mSelectedProjectId: Long = -1L - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mArchived = intent.getBooleanExtra(EXTRA_SHOW_ARCHIVED, false) - mSelectedSpaceId = intent.getLongExtra(EXTRA_SELECTED_SPACE_ID, -1L) - mSelectedProjectId = intent.getLongExtra(EXTRA_SELECTED_PROJECT_ID, -1L) - - mBinding = ActivityFoldersBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setupToolbar( - title = getString(if (mArchived) R.string.archived_folders else R.string.folders), - showBackButton = true - ) - - setupRecyclerView() - - setupButtons() - } - - private fun setupRecyclerView() { - mAdapter = FolderAdapter(context = this, listener = this, isArchived = mArchived) - mBinding.rvProjects.layoutManager = LinearLayoutManager(this) - mBinding.rvProjects.adapter = mAdapter - } - - private fun setupButtons() { -// mBinding.fabAdd.apply { -// visibility = if (mArchived) View.INVISIBLE else View.VISIBLE -// setOnClickListener { addFolder() } -// } - - mBinding.btViewArchived.apply { - toggle(!mArchived) - setOnClickListener { - val i = Intent(this@FoldersActivity, FoldersActivity::class.java) - i.putExtra(EXTRA_SHOW_ARCHIVED, true) - startActivity(i) - } - } - } - - override fun onResume() { - super.onResume() - refreshProjects() - invalidateOptionsMenu() - } - - private fun refreshProjects() { - val projects = if (mArchived) { - Space.current?.archivedProjects - } else { - Space.current?.projects?.filter { !it.isArchived } - } ?: emptyList() - - mAdapter.update(projects) - - if (projects.isEmpty()) { - mBinding.rvProjects.visibility = View.GONE - mBinding.tvNoFolders.visibility = View.VISIBLE - } else { - mBinding.rvProjects.visibility = View.VISIBLE - mBinding.tvNoFolders.visibility = View.GONE - } - } - - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_folder_list, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - val archivedCount = Space.get(mSelectedSpaceId)?.archivedProjects?.size ?: 0 - menu?.findItem(R.id.action_archived_folders)?.isVisible = (!mArchived && archivedCount > 0) - return super.onPrepareOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - - R.id.action_archived_folders -> { - navigateToArchivedFolders() - true - } - - else -> super.onOptionsItemSelected(item) - } - } - - private fun navigateToArchivedFolders() { - val intent = Intent(this, FoldersActivity::class.java).apply { - putExtra(EXTRA_SHOW_ARCHIVED, true) - putExtra(EXTRA_SELECTED_SPACE_ID, mSelectedSpaceId) - putExtra(EXTRA_SELECTED_PROJECT_ID, mSelectedProjectId) - } - startActivity(intent) - } - - - override fun projectClicked(project: Project) { - val resultIntent = Intent() - resultIntent.putExtra("SELECTED_FOLDER_ID", project.id) - setResult(RESULT_OK, resultIntent) - //finish() // Close FoldersActivity and return to MainActivity - - val intent = Intent(this, EditFolderActivity::class.java).apply { - putExtra(EditFolderActivity.EXTRA_CURRENT_PROJECT_ID, project.id) - } - startActivity(intent) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt deleted file mode 100644 index 82fdee99..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/GeneralSettingsActivity.kt +++ /dev/null @@ -1,178 +0,0 @@ -package net.opendasharchive.openarchive.features.settings - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.view.MenuItem -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.compose.ui.res.stringResource -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivitySettingsContainerBinding -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository -import net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupActivity -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.Theme -import org.koin.android.ext.android.inject - - -class GeneralSettingsActivity: BaseActivity() { - - class Fragment: PreferenceFragmentCompat() { - - private val passcodeRepository by inject() - -// private var mCiConsentPref: SwitchPreferenceCompat? = null - - private var passcodePreference: SwitchPreferenceCompat? = null - - private val activityResultLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - val passcodeEnabled = result.data?.getBooleanExtra("passcode_enabled", false) ?: false - passcodePreference?.isChecked = passcodeEnabled - } else { - passcodePreference?.isChecked = false - } - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.prefs_general, rootKey) - - passcodePreference = findPreference(Prefs.PASSCODE_ENABLED) - -// findPreference(Prefs.PASSCODE_ENABLED)?.setOnPreferenceChangeListener { _, newValue -> -// //Prefs.lockWithPasscode = newValue as Boolean -// if (newValue as? Boolean == true) { -// -// val intent = Intent(context, PasscodeSetupActivity::class.java) -// activityResultLauncher.launch(intent) -// } -// false -// } - - - passcodePreference?.setOnPreferenceChangeListener { _, newValue -> - val enabled = newValue as Boolean - if (enabled) { - // Launch PasscodeSetupActivity - val intent = Intent(context, PasscodeSetupActivity::class.java) - activityResultLauncher.launch(intent) - } else { - // Show confirmation dialog - AlertDialog.Builder(requireContext()) - .setTitle("Disable Passcode") - .setMessage("Are you sure you want to disable the passcode?") - .setPositiveButton("Yes") { _, _ -> - passcodeRepository.clearPasscode() - passcodePreference?.isChecked = false - - // Update the FLAG_SECURE dynamically - (activity as? BaseActivity)?.updateScreenshotPrevention() - } - .setNegativeButton("No") { _, _ -> - passcodePreference?.isChecked = true - } - .show() - } - // Return false to avoid the preference updating immediately - false - } - -// findPreference(Prefs.USE_TOR)?.setOnPreferenceChangeListener { _, newValue -> -// val activity = activity ?: return@setOnPreferenceChangeListener true -// -// if (newValue as Boolean) { -// if (!OrbotHelper.isOrbotInstalled(activity) && !OrbotHelper.isTorServicesInstalled(activity)) { -// AlertHelper.show(activity, -// R.string.prefs_install_tor_summary, -// R.string.prefs_use_tor_title, -// buttons = listOf( -// AlertHelper.positiveButton(R.string.action_install) { _, _ -> -// activity.startActivity( -// OrbotHelper.getOrbotInstallIntent(activity)) -// }, -// AlertHelper.negativeButton(R.string.action_cancel) -// )) -// -// return@setOnPreferenceChangeListener false -// } -// } -// -// true -// } - - findPreference("proof_mode")?.setOnPreferenceClickListener { - startActivity(Intent(context, ProofModeSettingsActivity::class.java)) - - true - } - - findPreference(Prefs.THEME)?.setOnPreferenceChangeListener { _, newValue -> - Theme.set(requireActivity(), Theme.get(newValue as? String)) - - true - } - - findPreference(Prefs.PROHIBIT_SCREENSHOTS)?.setOnPreferenceClickListener { _ -> - if (activity is BaseActivity) { - // make sure this gets settings change gets applied instantly - // (all other activities rely on the hook in BaseActivity.onResume()) - (activity as BaseActivity).updateScreenshotPrevention() - } - - true - } - -// mCiConsentPref = findPreference("health_checks") -// -// mCiConsentPref?.setOnPreferenceChangeListener { _, newValue -> -// if (newValue as? Boolean == false) { -// CleanInsightsManager.deny() -// } -// else { -// startActivity(Intent(context, ConsentActivity::class.java)) -// } -// -// true -// } - } - -// override fun onResume() { -// super.onResume() -// -// mCiConsentPref?.isChecked = CleanInsightsManager.hasConsent() -// } - } - - - private lateinit var mBinding: ActivitySettingsContainerBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivitySettingsContainerBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setupToolbar(title = getString(R.string.general)) - - supportFragmentManager - .beginTransaction() - .replace(mBinding.container.id, Fragment()) - .commit() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - - return super.onOptionsItemSelected(item) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt index 63009df0..9f88d0cd 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 @@ -5,11 +5,14 @@ import android.content.Intent import android.os.Bundle import android.view.View import androidx.activity.result.contract.ActivityResultContracts +import androidx.navigation.NavDeepLinkBuilder import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.core.BaseActivity +import net.opendasharchive.openarchive.features.core.NavArgument import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager import net.opendasharchive.openarchive.features.core.dialog.DialogType @@ -115,15 +118,34 @@ class SettingsFragment : PreferenceFragmentCompat() { getPrefByKey(R.string.pref_media_servers)?.setOnPreferenceClickListener { val intent = Intent(context, SpaceSetupActivity::class.java) - intent.putExtra("start_destination", StartDestination.SPACE_LIST.name) + intent.putExtra(NavArgument.START_DESTINATION, StartDestination.SPACE_LIST.name) startActivity(intent) true } getPrefByKey(R.string.pref_media_folders)?.setOnPreferenceClickListener { - val intent = Intent(context, FoldersActivity::class.java) - intent.putExtra(FoldersActivity.EXTRA_SHOW_ARCHIVED, true) +// val intent = Intent(context, FoldersActivity::class.java) +// intent.putExtra(FoldersActivity.EXTRA_SHOW_ARCHIVED, true) +// startActivity(intent) +// + val intent = Intent(context, SpaceSetupActivity::class.java) + intent.putExtra(NavArgument.START_DESTINATION, StartDestination.FOLDER_LIST.name) + intent.putExtra(NavArgument.SHOW_ARCHIVED_FOLDERS, true) + intent.putExtra(NavArgument.SPACE_ID, Space.current?.id) startActivity(intent) + +// val args = Bundle().apply { +// putBoolean(NavArgument.SHOW_ARCHIVED_FOLDERS, true) +// putLong(NavArgument.SPACE_ID, -1L) +// putLong(NavArgument.FOLDER_ID, -1L) +// } +// NavDeepLinkBuilder(requireContext()) +// .setComponentName(SpaceSetupActivity::class.java) +// .setGraph(R.navigation.space_setup_navigation) +// .setDestination(R.id.fragment_folder_list) +// .setArguments(args) +// .createPendingIntent() +// .send() true } 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 34c0448c..2b52ec42 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 @@ -33,42 +33,28 @@ class SpaceSetupFragment : BaseFragment() { // Prepare click lambdas that use the fragment’s business logic. val onWebDavClick = { - if (isJetpackNavigation) { - findNavController().navigate(R.id.action_fragment_space_setup_to_fragment_web_dav) - } else { - setFragmentResult( - RESULT_REQUEST_KEY, - bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_WEBDAV) - ) - } + + val action = SpaceSetupFragmentDirections.actionFragmentSpaceSetupToFragmentWebDav() + findNavController().navigate(action) + } // Only enable Internet Archive if not already present val isInternetArchiveAllowed = !Space.has(Space.Type.INTERNET_ARCHIVE) val onInternetArchiveClick = { - if (isJetpackNavigation) { + val action = - SpaceSetupFragmentDirections.actionFragmentSpaceSetupToFragmentInternetArchive() + SpaceSetupFragmentDirections.actionFragmentSpaceSetupToFragmentInternetArchiveLogin() findNavController().navigate(action) - } else { - setFragmentResult( - RESULT_REQUEST_KEY, - bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_INTERNET_ARCHIVE) - ) - } + } // Show/hide Snowbird based on config val isDwebEnabled = appConfig.isDwebEnabled val onDwebClicked = { - if (isJetpackNavigation) { + val action = SpaceSetupFragmentDirections.actionFragmentSpaceSetupToFragmentSnowbird() findNavController().navigate(action) - } else { - setFragmentResult( - RESULT_REQUEST_KEY, - bundleOf(RESULT_BUNDLE_KEY to RESULT_VAL_RAVEN) - ) - } + } SaveAppTheme { @@ -83,16 +69,6 @@ class SpaceSetupFragment : BaseFragment() { } - companion object { - const val RESULT_REQUEST_KEY = "space_setup_fragment_result" - const val RESULT_BUNDLE_KEY = "space_setup_result_key" - const val RESULT_VAL_DROPBOX = "dropbox" - const val RESULT_VAL_WEBDAV = "webdav" - const val RESULT_VAL_RAVEN = "raven" - const val RESULT_VAL_INTERNET_ARCHIVE = "internet_archive" - const val RESULT_VAL_GDRIVE = "gdrive" - } - override fun getToolbarTitle() = getString(R.string.space_setup_title) override fun getToolbarSubtitle(): String? = null override fun shouldShowBackButton() = true diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt index bbac2982..e4e91af3 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt @@ -6,20 +6,18 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.navigation.fragment.findNavController import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme -import net.opendasharchive.openarchive.databinding.FragmentSpaceListBinding import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.core.BaseFragment -import net.opendasharchive.openarchive.features.internetarchive.presentation.InternetArchiveActivity import net.opendasharchive.openarchive.services.gdrive.GDriveActivity -import net.opendasharchive.openarchive.services.webdav.WebDavActivity import org.koin.compose.viewmodel.koinViewModel class SpaceListFragment : BaseFragment() { - private lateinit var binding: FragmentSpaceListBinding companion object { const val EXTRA_DATA_SPACE = "space_id" @@ -31,30 +29,27 @@ class SpaceListFragment : BaseFragment() { savedInstanceState: Bundle? ): View { - binding = FragmentSpaceListBinding.inflate(inflater) + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val viewModel: SpaceListViewModel = koinViewModel() - binding.composeViewSpaceList.setContent { + SaveAppTheme { - val viewModel: SpaceListViewModel = koinViewModel() + // Calling refresh here will update state & trigger recomposition + LaunchedEffect(Unit) { + viewModel.refreshSpaces() + } - SaveAppTheme { - - // Calling refresh here will update state & trigger recomposition - LaunchedEffect(Unit) { - viewModel.refreshSpaces() + SpaceListScreen( + onSpaceClicked = { space -> + startSpaceAuthActivity(space.id) + }, + ) } - - SpaceListScreen( - onSpaceClicked = { space -> - startSpaceAuthActivity(space.id) - }, - ) } - } - - return binding.root } override fun getToolbarTitle() = getString(R.string.pref_title_media_servers) @@ -64,9 +59,8 @@ class SpaceListFragment : BaseFragment() { when (space.tType) { Space.Type.INTERNET_ARCHIVE -> { - val intent = Intent(requireContext(), InternetArchiveActivity::class.java) - intent.putExtra(EXTRA_DATA_SPACE, space.id) - startActivity(intent) + val action = SpaceListFragmentDirections.actionFragmentSpaceListToFragmentInternetArchiveDetail(spaceId = spaceId) + findNavController().navigate(action) } Space.Type.GDRIVE -> { @@ -82,9 +76,8 @@ class SpaceListFragment : BaseFragment() { } else -> { - val intent = Intent(requireContext(), WebDavActivity::class.java) - intent.putExtra(EXTRA_DATA_SPACE, space.id) - startActivity(intent) + val action = SpaceListFragmentDirections.actionFragmentSpaceListToFragmentWebDav(spaceId = spaceId) + findNavController().navigate(action) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavActivity.kt deleted file mode 100644 index 22625ab7..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavActivity.kt +++ /dev/null @@ -1,71 +0,0 @@ -package net.opendasharchive.openarchive.services.webdav - -import android.content.Intent -import android.os.Bundle -import android.view.MenuItem -import androidx.fragment.app.commit -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityWebdavBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.main.MainActivity -import kotlin.properties.Delegates - -class WebDavActivity : BaseActivity() { - - companion object { - const val FRAGMENT_TAG = "webdav_fragment" - } - - private lateinit var mBinding: ActivityWebdavBinding - private var mSpaceId by Delegates.notNull() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivityWebdavBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setupToolbar(title = getString(R.string.edit_private_server), showBackButton = true) - - mSpaceId = intent.getLongExtra(EXTRA_DATA_SPACE, WebDavFragment.ARG_VAL_NEW_SPACE) - - if (mSpaceId != WebDavFragment.ARG_VAL_NEW_SPACE) { - supportFragmentManager.commit { - replace(mBinding.webDavFragment.id, WebDavFragment.newInstance(mSpaceId)) - } - } - - supportFragmentManager.setFragmentResultListener(WebDavFragment.RESP_SAVED, this) { _, _ -> - finishAffinity() - startActivity(Intent(this, MainActivity::class.java)) - } - - supportFragmentManager.setFragmentResultListener(WebDavFragment.RESP_DELETED, this) { _, _ -> - Space.navigate(this) - } - - supportFragmentManager.setFragmentResultListener(WebDavFragment.RESP_LICENSE, this) { _, _ -> - // Navigate to license fragment - // also update title with server name if available - like breadcrumb - supportFragmentManager - .beginTransaction() - .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left) - .replace( - mBinding.webDavFragment.id, - WebDavSetupLicenseFragment.newInstance(spaceId = mSpaceId, isEditing = true), - FRAGMENT_TAG, - ) - .commit() - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - // handle appbar back button tap - if (item.itemId == android.R.id.home) { - finish() - return true - } - return super.onOptionsItemSelected(item) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt deleted file mode 100644 index f56093dd..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadManagerActivity.kt +++ /dev/null @@ -1,151 +0,0 @@ -package net.opendasharchive.openarchive.upload - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.Menu -import android.view.MenuItem -import net.opendasharchive.openarchive.CleanInsightsManager -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityUploadManagerBinding -import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.features.core.BaseActivity - -class UploadManagerActivity : BaseActivity() { - - private lateinit var mBinding: ActivityUploadManagerBinding - var mFrag: UploadManagerFragment? = null - private var mMenuEdit: MenuItem? = null - - private val mMessageReceiver: BroadcastReceiver = object : BroadcastReceiver() { - private val handler = Handler(Looper.getMainLooper()) - - override fun onReceive(context: Context, intent: Intent) { - val action = BroadcastManager.getAction(intent) - val mediaId = action?.mediaId ?: return - - if (mediaId > -1) { - val media = Media.get(mediaId) - - if (action == BroadcastManager.Action.Delete || media?.sStatus == Media.Status.Uploaded) { - handler.post { mFrag?.removeItem(mediaId) } - } - else { - handler.post { mFrag?.updateItem(mediaId) } - } - } - } - } - - private var mEditMode = true // Setting Edit mode as the default mode - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivityUploadManagerBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setupToolbar( - title = getString(R.string.upload_manager_screen_title), - subtitle = getString(R.string.upload_manager_screen_subtitle), - showBackButton = true - ) - - //mFrag = supportFragmentManager.findFragmentById(R.id.fragUploadManager) as? UploadManagerFragment - - val bottomSheet = UploadManagerFragment() - bottomSheet.show(supportFragmentManager, UploadManagerFragment.TAG) - } - - override fun onResume() { - super.onResume() - mFrag?.refresh() - - BroadcastManager.register(this, mMessageReceiver) - - onStartEdit() - } - - override fun onPause() { - super.onPause() - - BroadcastManager.unregister(this, mMessageReceiver) - } - - private fun onStartEdit() { - UploadService.stopUploadService(this) - } - - private fun onCompleteEdit() { - UploadService.startUploadService(this) - } - - private fun toggleEditMode() { - mEditMode = !mEditMode - - mFrag?.refresh() - - if (mEditMode) { - mMenuEdit?.setTitle(R.string.menu_done) - - UploadService.stopUploadService(this) - } - else { - mMenuEdit?.setTitle(R.string.edit) - - UploadService.startUploadService(this) - } - - updateTitle() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_upload, menu) - mMenuEdit = menu.findItem(R.id.menu_done) - - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - finish() - return true - } - R.id.menu_done -> { - onCompleteEdit() - return true - } - } - return super.onOptionsItemSelected(item) - } - - override fun finish() { - // If we're still in edit mode, restart the upload service when the user leaves. - if (mEditMode) { - UploadService.startUploadService(this) - } - - super.finish() - } - - private fun updateTitle() { - if (mEditMode) { - supportActionBar?.title = getString(R.string.edit_media) - supportActionBar?.subtitle = getString(R.string.uploading_is_paused) - } else { - val count = mFrag?.getUploadingCounter() ?: 0 - - supportActionBar?.title = if (count < 1) { - getString(R.string.uploads) - } else { - getString(R.string.uploading_left, count) - } - - supportActionBar?.subtitle = null - } - } -} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_folder.xml b/app/src/main/res/layout/activity_add_folder.xml deleted file mode 100644 index 42438c74..00000000 --- a/app/src/main/res/layout/activity_add_folder.xml +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings_container.xml b/app/src/main/res/layout/activity_settings_container.xml deleted file mode 100644 index 0392c67a..00000000 --- a/app/src/main/res/layout/activity_settings_container.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_upload_manager.xml b/app/src/main/res/layout/activity_upload_manager.xml deleted file mode 100644 index cce55e17..00000000 --- a/app/src/main/res/layout/activity_upload_manager.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_webdav.xml b/app/src/main/res/layout/activity_webdav.xml deleted file mode 100644 index 15d46d7f..00000000 --- a/app/src/main/res/layout/activity_webdav.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/content_upload_manager.xml b/app/src/main/res/layout/content_upload_manager.xml deleted file mode 100644 index 66c4f746..00000000 --- a/app/src/main/res/layout/content_upload_manager.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/activity_edit_folder.xml b/app/src/main/res/layout/fragment_edit_folder.xml similarity index 94% rename from app/src/main/res/layout/activity_edit_folder.xml rename to app/src/main/res/layout/fragment_edit_folder.xml index 125ab7ce..db661aa5 100644 --- a/app/src/main/res/layout/activity_edit_folder.xml +++ b/app/src/main/res/layout/fragment_edit_folder.xml @@ -7,12 +7,7 @@ android:filterTouchesWhenObscured="true" android:orientation="vertical" tools:background="@color/colorBackground" - tools:context=".features.settings.EditFolderActivity"> - - - + tools:context=".features.settings.EditFolderFragment"> + app:layout_constraintTop_toTopOf="parent" /> - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_folders.xml b/app/src/main/res/layout/fragment_folder_list.xml similarity index 55% rename from app/src/main/res/layout/activity_folders.xml rename to app/src/main/res/layout/fragment_folder_list.xml index 4ef843c1..f6cacaca 100644 --- a/app/src/main/res/layout/activity_folders.xml +++ b/app/src/main/res/layout/fragment_folder_list.xml @@ -1,54 +1,40 @@ + tools:context=".features.settings.FolderListFragment"> + + android:orientation="vertical"> - - - - + android:layout_margin="@dimen/activity_vertical_margin" + android:layout_marginHorizontal="8dp" /> + android:textSize="18sp" + tools:visibility="gone" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_space_list.xml b/app/src/main/res/layout/fragment_space_list.xml deleted file mode 100644 index 396fb067..00000000 --- a/app/src/main/res/layout/fragment_space_list.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/app/src/main/res/navigation/space_setup_navigation.xml b/app/src/main/res/navigation/space_setup_navigation.xml index a105f47a..09666308 100644 --- a/app/src/main/res/navigation/space_setup_navigation.xml +++ b/app/src/main/res/navigation/space_setup_navigation.xml @@ -18,6 +18,13 @@ app:exitAnim="@anim/slide_out_left" app:popEnterAnim="@anim/slide_in_left" app:popExitAnim="@anim/slide_out_right" /> + @@ -32,23 +39,24 @@ app:exitAnim="@anim/slide_out_left" app:popEnterAnim="@anim/slide_in_left" app:popExitAnim="@anim/slide_out_right" /> + - + android:id="@+id/fragment_internet_archive_login" + android:name="net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginFragment" + android:label="InternetArchiveLoginFragment"> + + - + + + + + + + + + + + android:label="@string/add_a_folder"> - - + tools:layout="@layout/fragment_browse_folders" /> + tools:layout="@layout/fragment_create_new_folder" /> + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb2e1806..e716c2f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activity = "1.9.3" -agp = "8.9.2" +agp = "8.10.0" appcompat = "1.7.0" biometric = "1.1.0" coil = "3.0.4" From 2ee437442038e9928576add394e7aaf24813c4cd Mon Sep 17 00:00:00 2001 From: Ryan Jennings Date: Sun, 11 May 2025 12:07:16 -0700 Subject: [PATCH 05/15] refactor(gdrive,tor): separate files for gdrive refactor, untested --- .circleci/config.yml | 4 +- .../openarchive/CleanInsightsManager.kt | 1 + .../openarchive/FolderAdapter.kt | 6 +- .../opendasharchive/openarchive/SaveApp.kt | 23 +- .../features/folders/BrowseFoldersActivity.kt | 2 +- .../folders/BrowseFoldersViewModel.kt | 10 +- .../settings/GeneralSettingsActivity.kt | 66 +++--- .../settings/OpenOrbotPreference.java | 38 +++ .../openarchive/services/Module.kt | 2 +- .../openarchive/services/SaveClient.kt | 22 +- .../services/gdrive/GDriveApiConduit.kt | 140 +++++++++++ .../services/gdrive/GDriveApiFragment.kt | 106 +++++++++ .../services/gdrive/GDriveClient.kt | 29 +-- .../services/gdrive/GDriveConduit.kt | 224 +++++++++++++----- .../services/gdrive/GDriveViewModel.kt | 79 ++++++ .../openarchive/services/gdrive/Module.kt | 8 +- .../services/gdrive/ProgressRequestBody.kt | 7 +- .../openarchive/services/tor/TorStatus.kt | 3 - .../openarchive/services/tor/TorViewModel.kt | 36 +-- .../services/webdav/WebDavConduit.kt | 4 - app/src/main/res/layout/activity_main.xml | 3 +- app/src/main/res/layout/prefs_open_orbot.xml | 14 ++ app/src/main/res/values/strings.xml | 7 +- app/src/main/res/xml/prefs_general.xml | 23 +- 24 files changed, 660 insertions(+), 197 deletions(-) create mode 100644 app/src/main/java/net/opendasharchive/openarchive/features/settings/OpenOrbotPreference.java create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveApiConduit.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveApiFragment.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveViewModel.kt create mode 100644 app/src/main/res/layout/prefs_open_orbot.xml diff --git a/.circleci/config.yml b/.circleci/config.yml index 42c583e1..d9867073 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ jobs: steps: - checkout - restore_cache: - key: jars-{{ checksum "build.gradle.kts" }}-{{ checksum "app/build.gradle.kts" }} + 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 @@ -22,7 +22,7 @@ jobs: - save_cache: paths: - ~/.gradle - key: jars-{{ checksum "build.gradle.kts" }}-{{ checksum "app/build.gradle.kts" }} + key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} - run: name: Run Tests command: ./gradlew lint test diff --git a/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt b/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt index 40899a6f..06231c52 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt @@ -6,6 +6,7 @@ import android.content.Intent import net.opendasharchive.openarchive.features.settings.ConsentActivity import org.cleaninsights.sdk.* +@Suppress("unused") object CleanInsightsManager { private const val CI_CAMPAIGN = "main" diff --git a/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt index f9bde193..da8062a1 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/FolderAdapter.kt @@ -90,10 +90,14 @@ class FolderAdapter(listener: FolderAdapterListener?) : ListAdapter? = WeakReference(listener) + private val mListener: WeakReference? private var mLastSelected: Project? = null + init { + mListener = WeakReference(listener) + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return ViewHolder(RvSimpleRowBinding.inflate(LayoutInflater.from(parent.context), parent, false)) diff --git a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt index ca2f1bfc..8aee6bc6 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt @@ -1,7 +1,5 @@ package net.opendasharchive.openarchive -import android.app.NotificationChannel -import android.app.NotificationManager import android.content.Context import com.facebook.drawee.backends.pipeline.Fresco import com.facebook.imagepipeline.core.ImagePipelineConfig @@ -10,12 +8,10 @@ import com.orm.SugarApp import info.guardianproject.netcipher.proxy.OrbotHelper import net.opendasharchive.openarchive.core.di.coreModule import net.opendasharchive.openarchive.core.di.featuresModule -import net.opendasharchive.openarchive.services.tor.TorViewModel import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Theme import org.koin.android.ext.koin.androidContext import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import org.koin.core.context.startKoin import timber.log.Timber @@ -51,8 +47,6 @@ class SaveApp : SugarApp(), KoinComponent { CleanInsightsManager.init(this) - createTorNotificationChannel() - // enable timber logging library for debug builds if (BuildConfig.DEBUG){ Timber.plant(Timber.DebugTree()) @@ -61,24 +55,11 @@ class SaveApp : SugarApp(), KoinComponent { } private fun initTor() { - Timber.d( "Initializing internal tor client") + Timber.d( "Initializing tor client") + OrbotHelper.get(this).apply { init() requestStart(this@SaveApp) } } - - - private fun createTorNotificationChannel() { - val name = "Tor Service" - val descriptionText = "Keeps the Tor service running" - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel("torService", name, importance).apply { - description = descriptionText - } - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } - } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt index c2887743..3de2283a 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/folders/BrowseFoldersActivity.kt @@ -37,7 +37,7 @@ class BrowseFoldersActivity : BaseActivity() { mBinding.rvFolderList.layoutManager = LinearLayoutManager(this) val space = Space.current - if (space != null) mViewModel.getFiles(space) + if (space != null) mViewModel.getFiles(this, space) mViewModel.folders.observe(this) { mBinding.projectsEmpty.toggle(it.isEmpty()) 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 6fa75134..8de26624 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 @@ -24,14 +24,12 @@ class BrowseFoldersViewModel : ViewModel() { private val client: SaveClient by inject(SaveClient::class.java) - private val drive: GDriveConduit by inject(GDriveConduit::class.java) - val folders: LiveData> get() = mFolders val progressBarFlag = MutableLiveData(false) - fun getFiles(space: Space) { + fun getFiles(context: Context, space: Space) { viewModelScope.launch { progressBarFlag.value = true @@ -40,7 +38,7 @@ class BrowseFoldersViewModel : ViewModel() { when (space.tType) { Space.Type.WEBDAV -> getWebDavFolders(space) - Space.Type.GDRIVE -> getGDriveFolders() + Space.Type.GDRIVE -> getGDriveFolders(context) else -> emptyList() } @@ -73,7 +71,7 @@ class BrowseFoldersViewModel : ViewModel() { } ?: emptyList() } - private suspend fun getGDriveFolders(): List { - return drive.listFoldersInRoot() + private fun getGDriveFolders(context: Context): List { + return GDriveConduit.listFoldersInRoot(GDriveConduit.getDrive(context)) } } \ 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 67ee99a9..a7011cfb 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 @@ -4,66 +4,70 @@ import android.content.Intent import android.os.Bundle import android.view.MenuItem import androidx.lifecycle.lifecycleScope +import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat -import info.guardianproject.netcipher.proxy.OrbotHelper import kotlinx.coroutines.launch import net.opendasharchive.openarchive.CleanInsightsManager import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult import net.opendasharchive.openarchive.databinding.ActivitySettingsContainerBinding import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.services.SaveClient import net.opendasharchive.openarchive.services.tor.TorStatus import net.opendasharchive.openarchive.services.tor.TorViewModel import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Theme -import okhttp3.Request import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlin.getValue -class GeneralSettingsActivity: BaseActivity() { +class GeneralSettingsActivity : BaseActivity() { - private val torViewModel: TorViewModel by viewModel() - - override fun onResume() { - super.onResume() - torViewModel.requestTorStatus() - } - - class Fragment: PreferenceFragmentCompat() { + class Fragment : PreferenceFragmentCompat() { private val torViewModel: TorViewModel by viewModel() - private var hasToggled = false - private var mCiConsentPref: SwitchPreferenceCompat? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.prefs_general, rootKey) - val torStatusPref = findPreference("tor_status") val useTorPref = findPreference(Prefs.USE_TOR) - - useTorPref?.setOnPreferenceChangeListener { _, newValue -> - val enabled = newValue as Boolean - torViewModel.toggleTorServiceState(requireActivity(), enabled) - val status = if (enabled) TorStatus.CONNECTING else TorStatus.DISCONNECTED - torStatusPref?.summary = status.name.lowercase() - hasToggled = true - true - } + val torStatusPref = findPreference("tor_status") + + val setUseTorText: (TorStatus, Boolean) -> Unit = { torStatus, enabled -> + if (torStatus == TorStatus.CONNECTED) { + if (enabled) { + torStatusPref?.setSummary(R.string.prefs_use_tor_enabled) + } else { + torStatusPref?.setSummary(R.string.prefs_use_tor_ready) + } + } else { + if (enabled) { + torStatusPref?.setSummary(R.string.prefs_use_tor_disabled) + } else { + torStatusPref?.setSummary(R.string.prefs_use_tor_not_ready) + } + } + } this.lifecycleScope.launch { torViewModel.torStatus.collect { torStatus -> - if (!hasToggled) { - torStatusPref?.summary = torStatus.name.lowercase() - useTorPref?.isChecked = torStatus == TorStatus.CONNECTED - } + setUseTorText(torStatus, useTorPref?.isChecked == true) + } + } + useTorPref?.apply { + setUseTorText(torViewModel.torStatus.value, isChecked) + setOnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + torViewModel.toggleTorServiceState(requireActivity(), enabled) + setUseTorText(torViewModel.torStatus.value, enabled) + true } } + findPreference("open_orbot")?.setOnOpenOrbotListener { + torViewModel.requestOpenOrInstallOrbot(requireActivity()) + true + } findPreference("proof_mode")?.setOnPreferenceClickListener { startActivity(Intent(context, ProofModeSettingsActivity::class.java)) @@ -102,8 +106,8 @@ class GeneralSettingsActivity: BaseActivity() { override fun onResume() { super.onResume() - mCiConsentPref?.isChecked = CleanInsightsManager.hasConsent() + torViewModel.requestTorStatus() } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/OpenOrbotPreference.java b/app/src/main/java/net/opendasharchive/openarchive/features/settings/OpenOrbotPreference.java new file mode 100644 index 00000000..a8849a28 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/OpenOrbotPreference.java @@ -0,0 +1,38 @@ +package net.opendasharchive.openarchive.features.settings; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.Button; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import net.opendasharchive.openarchive.R; + +public class OpenOrbotPreference extends Preference { + public OpenOrbotPreference(Context context, AttributeSet attrs) { + super(context, attrs); + setWidgetLayoutResource(R.layout.prefs_open_orbot); + this.setSelectable(false); + this.setOnPreferenceClickListener(null); + } + + private View.OnClickListener onClickListener = null; + + public void setOnOpenOrbotListener(View.OnClickListener listener) { + this.onClickListener = listener; + } + + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + Button button = (Button) holder.findViewById(R.id.open_orbot_button); + button.setOnClickListener(v -> { + if (onClickListener != null) { + onClickListener.onClick(v); + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Module.kt index 9a1a7b43..824d7560 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Module.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Module.kt @@ -6,7 +6,7 @@ import org.koin.dsl.module internal val servicesModule = module { - single { SaveClient(get()) } + factory { SaveClient(get()) } includes(torModule, gdriveModule) } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt index 142ffbc8..e8859751 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt @@ -8,6 +8,7 @@ import info.guardianproject.netcipher.proxy.OrbotHelper import info.guardianproject.netcipher.proxy.OrbotHelper.SimpleStatusCallback import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.util.Prefs import okhttp3.Call import okhttp3.Interceptor import okhttp3.OkHttpClient @@ -17,27 +18,20 @@ import okhttp3.Response import org.koin.core.component.KoinComponent import timber.log.Timber import java.util.concurrent.TimeUnit -import java.util.logging.Level -import java.util.logging.Logger class SaveClient(private val context: Context) : SimpleStatusCallback(), KoinComponent, Call.Factory { private var okBuilder: OkHttpClient.Builder private val strongBuilder: StrongOkHttpClientBuilder - var proxyHttpPort: Int = -1 - private set - - var proxySocksPort: Int = -1 - private set - init { - Logger.getLogger(OkHttpClient::class.java.name).setLevel(Level.FINE) okBuilder = setup() strongBuilder = StrongOkHttpClientBuilder.forMaxSecurity(context) - OrbotHelper.get(context).apply { - addStatusCallback(this@SaveClient) - init() + if (Prefs.useTor) { + OrbotHelper.get(context).apply { + addStatusCallback(this@SaveClient) + init() + } } } @@ -61,10 +55,10 @@ class SaveClient(private val context: Context) : SimpleStatusCallback(), KoinCom override fun onEnabled(statusIntent: Intent?) { OrbotHelper.get(context).removeStatusCallback(this) + if (Prefs.useTor.not()) return + try { strongBuilder.applyTo(okBuilder, statusIntent) - proxyHttpPort = strongBuilder.getHttpPort(statusIntent) - proxySocksPort = strongBuilder.getSocksPort(statusIntent) } catch (e: Exception) { Timber.e(e, "Error setting up OkHttp client") } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveApiConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveApiConduit.kt new file mode 100644 index 00000000..082f7e0b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveApiConduit.kt @@ -0,0 +1,140 @@ +package net.opendasharchive.openarchive.services.gdrive + +import android.content.Context +import com.google.android.gms.common.Scopes +import com.google.android.gms.common.api.Scope +import com.google.api.client.http.InputStreamContent +import net.opendasharchive.openarchive.db.Media +import net.opendasharchive.openarchive.features.folders.BrowseFoldersViewModel +import net.opendasharchive.openarchive.services.Conduit +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import timber.log.Timber +import java.io.File +import java.io.InputStream +import java.util.Date + +/** + * This class contains all communication with / integration of Google Drive + * + * The only actually working documentation I could find about Googles Android GDrive API is this: + * https://stackoverflow.com/questions/56949872/ + * There's also this official documentation by Google for accessing GDrive, however it was pretty + * useless to me, since it doesn't explain what's going on at all. (I also couldn't get it to run + * in a reasonable amount of time): + * https://github.com/googleworkspace/android-samples/tree/master/drive/deprecation + * The official documentation doesn't mention Android and the Java Sample is only useful for + * integrating GDrive into backends. However it's still helpful for figuring building queries: + * https://developers.google.com/drive/api/guides/about-sdk + * Another important resource is this official guide on authenticating an Android app with Google: + * https://developers.google.com/identity/sign-in/android/start-integrating + */ +class GDriveApiConduit(media: Media, context: Context) : Conduit(media, context), KoinComponent { + + private val drive: GDriveRepository by inject() + + companion object { + + const val NAME = "Google Drive" + val SCOPE_NAMES = + arrayOf(Scopes.EMAIL, Scopes.DRIVE_FILE) + val SCOPES = SCOPE_NAMES.map { Scope(it) }.toTypedArray() + } + + private suspend fun getOrCreateFolder(folderName: String, parent: GDriveFile? = null): GDriveFile { + val folder = drive.folders(pageSize = 1).getOrNull() + + if (folder == null || folder.files.isEmpty()) { + return drive.newFolder(folderName, parent?.id).getOrThrow() + } + return folder.files.first() + } + + suspend fun createFolders(destinationPath: List): GDriveFileList = try { + var parentFolder: GDriveFile? = null + val result = mutableListOf() + for (pathElement in destinationPath) { + parentFolder = getOrCreateFolder(pathElement, parentFolder) + result.add(parentFolder) + } + GDriveFileList(result) + } catch (e: Exception) { + throw e + } + + suspend fun listFoldersInRoot(): List { + val result = ArrayList() + try { + var pageToken: String? = null + do { + val folders = drive.folders("root", 1000, pageToken).getOrThrow() + folders.files.forEach { + result.add(BrowseFoldersViewModel.Folder(it.name, Date(it.modifiedTime))) + } + pageToken = folders.nextPageToken + } while (pageToken != null) + } catch (e: IllegalArgumentException) { + Timber.e(e) + } + return result + } + + override suspend fun upload(): Boolean { + val destinationPath = getPath() ?: return false + val destinationFileName = getUploadFileName(mMedia) + sanitize() + + try { + val folder = createFolders(destinationPath).files.last() + uploadMetadata(folder, destinationFileName) + if (mCancelled) throw Exception("Cancelled") + uploadFile(mMedia.file, folder, destinationFileName) + } catch (e: Exception) { + jobFailed(e) + return false + } + + jobSucceeded() + + return true + } + + override suspend fun createFolder(url: String) { + throw NotImplementedError("the createFolder calls defined in Conduit don't map to GDrive API. use GDriveConduit.createFolder instead") + } + + private suspend fun uploadMetadata(parent: GDriveFile, fileName: String) { + val metadataFileName = "$fileName.meta.json" + + if (mCancelled) throw Exception("Cancelled") + + uploadFile(getMetadata().byteInputStream(), parent, metadataFileName) + + for (file in getProof()) { + if (mCancelled) throw Exception("Cancelled") + + uploadFile(file, parent, file.name) + } + } + + private suspend fun uploadFile( + sourceFile: File, + parentFolder: GDriveFile, + targetFileName: String, + ) = uploadFile(sourceFile.inputStream(), parentFolder, targetFileName) + + private suspend fun uploadFile( + inputStream: InputStream, + parentFolder: GDriveFile, + targetFileName: String, + ) = + try { + val fMeta = GDriveFile(name = targetFileName, parents = listOf(parentFolder.id!!)) + drive.upload(fMeta, InputStreamContent("application/octet-stream", inputStream)) { bytesWritten, percent -> + jobProgress(bytesWritten) + }.getOrThrow() + } catch (e: Exception) { + Timber.e(e, "gdrive upload of '$targetFileName' failed") + throw e + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveApiFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveApiFragment.kt new file mode 100644 index 00000000..a9a07719 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveApiFragment.kt @@ -0,0 +1,106 @@ +package net.opendasharchive.openarchive.services.gdrive + +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.core.text.HtmlCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.databinding.FragmentGdriveBinding +import org.koin.androidx.viewmodel.ext.android.viewModel + +class GDriveApiFragment : Fragment() { + + private lateinit var mBinding: FragmentGdriveBinding + + private val viewModel: GDriveViewModel by viewModel() + + companion object { + const val RESP_CANCEL = "gdrive_fragment_resp_cancel" + const val RESP_AUTHENTICATED = "gdrive_fragment_resp_authenticated" + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + mBinding = FragmentGdriveBinding.inflate(inflater) + + mBinding.disclaimer1.text = HtmlCompat.fromHtml( + getString( + R.string.gdrive_disclaimer_1, + getString(R.string.app_name), + getString(R.string.google_name), + getString(R.string.gdrive_sudp_name), + ), HtmlCompat.FROM_HTML_MODE_COMPACT + ) + mBinding.disclaimer1.movementMethod = LinkMovementMethod.getInstance() + mBinding.disclaimer2.text = getString( + R.string.gdrive_disclaimer_2, + getString(R.string.google_name), + getString(R.string.gdrive), + getString(R.string.app_name), + ) + mBinding.error.visibility = View.GONE + + mBinding.btBack.setOnClickListener { + setFragmentResult(RESP_CANCEL, bundleOf()) + } + + mBinding.btAuthenticate.setOnClickListener { + mBinding.error.visibility = View.GONE + viewModel.authenticate(requireActivity()) + mBinding.btBack.isEnabled = false + mBinding.btAuthenticate.isEnabled = false + } + + return mBinding.root + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + when (requestCode) { + RESULT_SIGN_IN -> authorized(resultCode, data) + else -> authFailed(getString(R.string.error)) + } + } + + private fun authorized(resultCode: Int, data: Intent?) { + when (resultCode) { + RESULT_OK -> { + viewModel.saveSpace(requireContext()) { + setFragmentResult(RESP_AUTHENTICATED, bundleOf()) + } + } + + else -> authFailed( + getString( + R.string.gdrive_auth_insufficient_permissions, + getString(R.string.app_name), + getString(R.string.gdrive) + ) + ) + } + } + + private fun authFailed(errorMessage: String?) { + MainScope().launch { + errorMessage?.let { + mBinding.error.text = errorMessage + mBinding.error.visibility = View.VISIBLE + } + mBinding.btBack.isEnabled = true + mBinding.btAuthenticate.isEnabled = true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveClient.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveClient.kt index e94f547c..e89d273a 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveClient.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveClient.kt @@ -1,27 +1,15 @@ package net.opendasharchive.openarchive.services.gdrive -import android.content.Context import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential import com.google.api.client.http.InputStreamContent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult import net.opendasharchive.openarchive.services.SaveClient -import okhttp3.Call -import okhttp3.Callback import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody -import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import java.io.File import java.io.IOException -import java.io.InputStream -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine private const val BASE_API = "https://www.googleapis.com/drive/v3/files" @@ -56,21 +44,6 @@ class GDriveClient(private val client: SaveClient, private val credential: Googl } } - suspend fun uploadFile(context: Context, file: File): Result { - val token = getAccessToken(credential) - val requestBody = file.asRequestBody("image/jpeg".toMediaType()) - - val request = Request.Builder() - .url("${BASE_API}?uploadType=media") - .addHeader("Authorization", "Bearer $token") - .post(requestBody) - .build() - - return client.enqueue(request).mapCatching { response -> - response.body?.string() ?: throw IOException("No response body") - } - } - suspend fun listFolders(parents: String? = null, pageSize: Int = 1000, pageToken: String? = null): Result { val token = getAccessToken(credential) val request = Request.Builder() @@ -84,7 +57,7 @@ class GDriveClient(private val client: SaveClient, private val credential: Googl } suspend fun upload(file: GDriveFile, content: InputStreamContent, onProgress: ProgressListener): Result { - val requestBody = ProgressRequestBody(content, "application/octet-stream") { bytesWritten, contentLength -> + val requestBody = ProgressRequestBody(content) { bytesWritten, contentLength -> val progress = (bytesWritten.toFloat() / contentLength.toFloat()) * 100 onProgress.onProgressUpdate(bytesWritten, progress.toInt()) } 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 f32558ad..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 @@ -3,19 +3,47 @@ package net.opendasharchive.openarchive.services.gdrive import android.content.Context +import androidx.core.content.ContextCompat import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.common.Scopes import com.google.android.gms.common.api.Scope +import com.google.api.client.extensions.android.http.AndroidHttp +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.googleapis.media.MediaHttpUploader +import com.google.api.client.http.HttpTransport import com.google.api.client.http.InputStreamContent +import com.google.api.client.http.apache.ApacheHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import info.guardianproject.netcipher.proxy.OrbotHelper +import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.features.folders.BrowseFoldersViewModel import net.opendasharchive.openarchive.services.Conduit -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject +import net.opendasharchive.openarchive.util.Prefs +import org.apache.http.conn.ClientConnectionManager +import org.apache.http.conn.params.ConnManagerParams +import org.apache.http.conn.params.ConnPerRouteBean +import org.apache.http.conn.scheme.PlainSocketFactory +import org.apache.http.conn.scheme.Scheme +import org.apache.http.conn.scheme.SchemeRegistry +import org.apache.http.conn.ssl.SSLSocketFactory +import org.apache.http.impl.client.DefaultHttpClient +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler +import org.apache.http.impl.conn.ProxySelectorRoutePlanner +import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager +import org.apache.http.params.BasicHttpParams +import org.apache.http.params.HttpConnectionParams import timber.log.Timber -import java.io.File +import java.io.IOException import java.io.InputStream +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI import java.util.Date /** @@ -33,14 +61,13 @@ import java.util.Date * Another important resource is this official guide on authenticating an Android app with Google: * https://developers.google.com/identity/sign-in/android/start-integrating */ -class GDriveConduit(media: Media, context: Context) : Conduit(media, context), KoinComponent { +class GDriveConduit(media: Media, context: Context) : Conduit(media, context) { - private val drive: GDriveRepository by inject() + private var mDrive: Drive = getDrive(mContext) companion object { - const val NAME = "Google Drive" - val SCOPES = + var SCOPES = arrayOf(Scope(DriveScopes.DRIVE_FILE), Scope(Scopes.EMAIL)) fun permissionsGranted(context: Context): Boolean { @@ -50,44 +77,123 @@ class GDriveConduit(media: Media, context: Context) : Conduit(media, context), K *SCOPES ) } - } - private suspend fun getOrCreateFolder(folderName: String, parent: GDriveFile? = null): GDriveFile { - val folder = drive.folders(pageSize = 1).getOrNull() + fun getDrive(context: Context): Drive { + val credential = + GoogleAccountCredential.usingOAuth2( + context, + setOf(DriveScopes.DRIVE_FILE, Scopes.EMAIL) + ) + credential.selectedAccount = GoogleSignIn.getLastSignedInAccount(context)?.account + + // in case we need to debug authentication: + // Timber.v("GDriveConduit.getDrive(): credential $credential") + // Timber.v("GDriveConduit.getDrive(): credential.selectedAccount ${credential.selectedAccount}") + // Timber.v("GDriveConduit.getDrive(): credential.selectedAccount.name ${credential.selectedAccount?.name}") + + val transport: HttpTransport = if (Prefs.useTor) { + // initialization code copied from: ApacheHttpTransport.newDefaultHttpParams() + // This is the simplest solution I could come up with for actually sending traffic + // to GDrive through Tor. Note that all calls to deprecated functions are copied + // from the only known to work version of GDrive API. + val params = BasicHttpParams() + HttpConnectionParams.setStaleCheckingEnabled(params, false) + HttpConnectionParams.setSocketBufferSize(params, 8192) + ConnManagerParams.setMaxTotalConnections(params, 200) + ConnManagerParams.setMaxConnectionsPerRoute(params, ConnPerRouteBean(20)) + val registry = SchemeRegistry() + registry.register(Scheme("http", PlainSocketFactory.getSocketFactory(), 80)) + registry.register(Scheme("https", SSLSocketFactory.getSocketFactory(), 443)) + val connectionManager: ClientConnectionManager = + ThreadSafeClientConnManager(params, registry) + val defaultHttpClient = DefaultHttpClient(connectionManager, params) + defaultHttpClient.httpRequestRetryHandler = DefaultHttpRequestRetryHandler(0, false) + val proxySelector = object : ProxySelector() { + override fun select(uri: URI?): MutableList { + return mutableListOf( + // tried SOCKS here, but in my tests when specifying SOCKS, the uploads + // seamed to bypass proxy settings altogether and connect directly instead + Proxy( + Proxy.Type.HTTP, + InetSocketAddress( + OrbotHelper.DEFAULT_PROXY_HOST, + OrbotHelper.DEFAULT_PROXY_HTTP_PORT + ) + ) + ) + } + + override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { + Timber.e("proxy connection Failed ($uri, $sa)", ioe) + } + } + defaultHttpClient.routePlanner = ProxySelectorRoutePlanner( + registry, + proxySelector + ) + + ApacheHttpTransport(defaultHttpClient) + } else { + AndroidHttp.newCompatibleTransport() + } - if (folder == null || folder.files.isEmpty()) { - return drive.newFolder(folderName, parent?.id).getOrThrow() + return Drive.Builder(transport, GsonFactory(), credential) + .setApplicationName(ContextCompat.getString(context, R.string.app_name)).build() } - return folder.files.first() - } - suspend fun createFolders(destinationPath: List): GDriveFileList = try { - var parentFolder: GDriveFile? = null - val result = mutableListOf() - for (pathElement in destinationPath) { - parentFolder = getOrCreateFolder(pathElement, parentFolder) - result.add(parentFolder) + private fun createFolder(gdrive: Drive, folderName: String, parent: File?): File { + val parentId: String = parent?.id ?: "root" + val folders = + gdrive.files().list().setPageSize(1) + .setQ("mimeType='application/vnd.google-apps.folder' and name = '$folderName' and trashed = false and '$parentId' in parents") + .setFields("files(id, name)").execute() + + if (folders.files.isNotEmpty()) { + // folder exists, return it now + return folders.files.first() + } + + // create new folder + val folderMeta = File() + folderMeta.name = folderName + folderMeta.parents = listOf(parentId) + folderMeta.mimeType = "application/vnd.google-apps.folder" + + // return newly created folders + return gdrive.files().create(folderMeta).setFields("id").execute() } - GDriveFileList(result) - } catch (e: Exception) { - throw e - } - suspend fun listFoldersInRoot(): List { - val result = ArrayList() - try { - var pageToken: String? = null - do { - val folders = drive.folders("root", 1000, pageToken).getOrThrow() - folders.files.forEach { - result.add(BrowseFoldersViewModel.Folder(it.name, Date(it.modifiedTime))) - } - pageToken = folders.nextPageToken - } while (pageToken != null) - } catch (e: IllegalArgumentException) { - Timber.e(e) + fun createFolders(mDrive: Drive, destinationPath: List): File { + var parentFolder: File? = null + for (pathElement in destinationPath) { + parentFolder = createFolder(mDrive, pathElement, parentFolder) + } + if (parentFolder == null) { + throw Exception("could not create folders $destinationPath") + } + return parentFolder + } + + fun listFoldersInRoot(gdrive: Drive): List { + val result = ArrayList() + try { + var pageToken: String? = null + do { + val folders = + gdrive.files().list().setPageSize(1000).setPageToken(pageToken) + .setQ("mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed = false") + .setFields("nextPageToken, files(id, name, createdTime)").execute() + for (f in folders.files) { + val date = Date(f.createdTime.value) + result.add(BrowseFoldersViewModel.Folder(f.name, date)) + } + pageToken = folders.nextPageToken + } while (pageToken != null) + } catch (e: java.lang.IllegalArgumentException) { + Timber.e(e) + } + return result } - return result } override suspend fun upload(): Boolean { @@ -96,7 +202,7 @@ class GDriveConduit(media: Media, context: Context) : Conduit(media, context), K sanitize() try { - val folder = createFolders(destinationPath).files.last() + val folder = createFolders(mDrive, destinationPath) uploadMetadata(folder, destinationFileName) if (mCancelled) throw Exception("Cancelled") uploadFile(mMedia.file, folder, destinationFileName) @@ -114,7 +220,7 @@ class GDriveConduit(media: Media, context: Context) : Conduit(media, context), K throw NotImplementedError("the createFolder calls defined in Conduit don't map to GDrive API. use GDriveConduit.createFolder instead") } - private suspend fun uploadMetadata(parent: GDriveFile, fileName: String) { + private fun uploadMetadata(parent: File, fileName: String) { val metadataFileName = "$fileName.meta.json" if (mCancelled) throw java.lang.Exception("Cancelled") @@ -128,24 +234,36 @@ class GDriveConduit(media: Media, context: Context) : Conduit(media, context), K } } - private suspend fun uploadFile( - sourceFile: File, - parentFolder: GDriveFile, + private fun uploadFile( + sourceFile: java.io.File, + parentFolder: File, targetFileName: String, - ) = uploadFile(sourceFile.inputStream(), parentFolder, targetFileName) + ) { + uploadFile(sourceFile.inputStream(), parentFolder, targetFileName) + } - private suspend fun uploadFile( + private fun uploadFile( inputStream: InputStream, - parentFolder: GDriveFile, + parentFolder: File, targetFileName: String, - ) = + ) { try { - val fMeta = GDriveFile(name = targetFileName, parents = listOf(parentFolder.id!!)) - drive.upload(fMeta, InputStreamContent(null, inputStream)) { bytesWritten, percent -> - jobProgress(bytesWritten) - }.getOrThrow() + val fMeta = File() + fMeta.name = targetFileName + fMeta.parents = listOf(parentFolder.id) + val request = + mDrive.files().create(fMeta, InputStreamContent(null, inputStream)) + request.mediaHttpUploader.isDirectUploadEnabled = false + request.mediaHttpUploader.chunkSize = + 262144 // magic minimum chunk-size number (smaller number will cause exception) + request.mediaHttpUploader.setProgressListener { + if (it.uploadState == MediaHttpUploader.UploadState.MEDIA_IN_PROGRESS) { + jobProgress(it.numBytesUploaded) + } + } + val response = request.execute() } catch (e: Exception) { - Timber.e(e, "gdrive upload of '$targetFileName' failed") - throw e + 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/GDriveViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveViewModel.kt new file mode 100644 index 00000000..e1b8975c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/GDriveViewModel.kt @@ -0,0 +1,79 @@ +package net.opendasharchive.openarchive.services.gdrive + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.ActivityCompat.startActivityForResult +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.Scope +import com.google.api.services.drive.DriveScopes +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.db.Space + +const val RESULT_SIGN_IN = 1000 +const val REQUEST_CODE_GOOGLE_AUTH = 1001 +const val PERMISSION_REQUEST_CODE = 1002 + +class GDriveViewModel : ViewModel() { + + fun authenticate(activity: Activity) { + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken("YOUR_CLIENT_ID.apps.googleusercontent.com") + .requestEmail() + .requestScopes(Scope(DriveScopes.DRIVE), *GDriveApiConduit.SCOPES) + .build() + + val googleSignInClient = GoogleSignIn.getClient(activity, gso) + + startActivityForResult(activity, googleSignInClient.signInIntent, RESULT_SIGN_IN, null) + } + + private fun authorize(activity: Activity) = + if (!GDriveConduit.permissionsGranted(activity)) { + GoogleSignIn.requestPermissions( + activity, + REQUEST_CODE_GOOGLE_AUTH, + GoogleSignIn.getLastSignedInAccount(activity), + *GDriveConduit.SCOPES + ) + true + } else { + false + } + + private fun requestPermissions(activity: Activity): Boolean = + if (ContextCompat.checkSelfPermission( + activity, + Manifest.permission.GET_ACCOUNTS + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.GET_ACCOUNTS), + PERMISSION_REQUEST_CODE + ) + false + } else { + true + } + + fun saveSpace(context: Context, callback: () -> Unit) { + viewModelScope.launch { + val space = Space(Space.Type.GDRIVE) + // we don't really know the host here, that's hidden by Drive Api + space.host = GDriveApiConduit.NAME + val account = GoogleSignIn.getLastSignedInAccount(context) + space.displayname = account?.email ?: "" + space.save() + Space.current = space + MainScope().launch { callback() } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/Module.kt index da0386fe..ac5757fa 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/Module.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/Module.kt @@ -1,10 +1,9 @@ package net.opendasharchive.openarchive.services.gdrive +import org.koin.androidx.viewmodel.dsl.viewModel import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.common.Scopes import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential -import com.google.api.services.drive.DriveScopes import org.koin.dsl.module internal val gdriveModule = module { @@ -12,11 +11,12 @@ internal val gdriveModule = module { GDriveClient( get(), GoogleAccountCredential.usingOAuth2( get(), - setOf(DriveScopes.DRIVE_FILE, Scopes.EMAIL) + GDriveApiConduit.SCOPE_NAMES.toList() ).apply { selectedAccount = GoogleSignIn.getLastSignedInAccount(context)?.account }) } - single { GDriveRepository(get()) } + factory { GDriveRepository(get()) } factory { params -> GDriveConduit(params.get(), get()) } + viewModel { GDriveViewModel() } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/ProgressRequestBody.kt b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/ProgressRequestBody.kt index 7fbe6c9d..c667c71d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/ProgressRequestBody.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/gdrive/ProgressRequestBody.kt @@ -4,9 +4,7 @@ import com.google.api.client.http.InputStreamContent import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody -import okio.* -import java.io.File -import java.io.InputStream +import okio.BufferedSink fun interface ProgressListener { fun onProgressUpdate(uploadedBytes: Long, percent: Int) @@ -14,7 +12,6 @@ fun interface ProgressListener { class ProgressRequestBody( private val content: InputStreamContent, - private val contentType: String, private val chunkSize: Int = 262144, private val listener: (bytesWritten: Long, contentLength: Long) -> Unit ) : RequestBody() { @@ -24,7 +21,7 @@ class ProgressRequestBody( override fun contentLength(): Long = content.length override fun writeTo(sink: BufferedSink) { - val buffer = ByteArray(8192) + val buffer = ByteArray(chunkSize) var bytesRead: Int var totalBytesWritten = 0L diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt index eb4f0d50..ea7c79ac 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorStatus.kt @@ -5,7 +5,4 @@ enum class TorStatus { CONNECTING, CONNECTED, DISCONNECTING, - ERROR, - INVALID, - UNAVAILABLE } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt index a1dd6fc6..ae879945 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/tor/TorViewModel.kt @@ -2,17 +2,15 @@ package net.opendasharchive.openarchive.services.tor import android.app.Activity import android.app.Application -import android.content.Intent import androidx.lifecycle.AndroidViewModel import info.guardianproject.netcipher.proxy.OrbotHelper import kotlinx.coroutines.flow.StateFlow -import net.opendasharchive.openarchive.util.Prefs import timber.log.Timber class TorViewModel( private val application: Application, - private val torRepository: ITorRepository, + torRepository: ITorRepository, ) : AndroidViewModel(application), OrbotHelper.InstallCallback { val torStatus: StateFlow = torRepository.torStatus @@ -20,8 +18,15 @@ class TorViewModel( fun toggleTorServiceState(activity: Activity, enabled: Boolean) { if (enabled) { startTor(activity) + } + } + + fun requestOpenOrInstallOrbot(activity: Activity) { + if (OrbotHelper.isOrbotInstalled(application)) { + requestOpenOrbot(activity) } else { - stopTor(activity) + OrbotHelper.get(application).addInstallCallback(this) + OrbotHelper.get(application).installOrbot(activity) } } @@ -34,19 +39,22 @@ class TorViewModel( } } - private fun stopTor(activity: Activity) { - if (OrbotHelper.isOrbotInstalled(application)) { - val intent = activity.packageManager.getLaunchIntentForPackage(OrbotHelper.ORBOT_PACKAGE_NAME) - if (intent != null) { - activity.startActivity(intent) - } else { - Timber.e("Orbot is not installed.") - } - + private fun requestOpenOrbot(activity: Activity): Boolean { + if (!OrbotHelper.isOrbotInstalled(application)) { + return false } + val intent = + activity.packageManager.getLaunchIntentForPackage(OrbotHelper.ORBOT_PACKAGE_NAME) + if (intent == null) { + Timber.e("Orbot is not installed.") + return false + } + + activity.startActivity(intent) + return true } - fun requestTorStatus() { + fun requestTorStatus() { OrbotHelper.get(application).init() } 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 efcbe422..b3d8cbd2 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 @@ -4,14 +4,12 @@ import android.content.Context import com.thegrizzlylabs.sardineandroid.SardineListener import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import net.opendasharchive.openarchive.db.Media -import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.services.Conduit import net.opendasharchive.openarchive.services.SaveClient import okhttp3.HttpUrl import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.IOException -import java.util.* class WebDavConduit(media: Media, context: Context) : Conduit(media, context), KoinComponent { @@ -26,8 +24,6 @@ class WebDavConduit(media: Media, context: Context) : Conduit(media, context), K val base = space.hostUrl ?: return false val path = getPath() ?: return false - webdav = client.webdav(space) - sanitize() val fileName = getUploadFileName(mMedia) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 14271c49..5b2cf774 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -135,9 +135,9 @@ android:layout_centerHorizontal="true" android:layout_marginTop="8dp" android:layout_marginBottom="0dp" - android:backgroundTint="@color/colorBottomNavbar" android:paddingTop="0dp" android:paddingBottom="0dp" + android:background="@color/transparent" app:icon="@drawable/outline_perm_media_24" app:iconGravity="textStart" app:iconPadding="0dp" @@ -199,6 +199,7 @@ android:backgroundTint="@color/colorBottomNavbar" android:paddingTop="0dp" android:paddingBottom="0dp" + android:background="@color/transparent" app:icon="@drawable/ic_settings" app:iconGravity="textStart" app:iconPadding="0dp" diff --git a/app/src/main/res/layout/prefs_open_orbot.xml b/app/src/main/res/layout/prefs_open_orbot.xml new file mode 100644 index 00000000..ec556ffc --- /dev/null +++ b/app/src/main/res/layout/prefs_open_orbot.xml @@ -0,0 +1,14 @@ + + +