diff --git a/app/build.gradle b/app/build.gradle index 2535cc9a..c783a94f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "net.opendasharchive.openarchive" minSdkVersion 21 targetSdkVersion 34 - versionCode 20549 - versionName '0.3.1-alpha2' + versionCode 20555 + versionName '0.3.3' archivesBaseName = "Save-$versionName" multiDexEnabled true vectorDrawables.useSupportLibrary = true @@ -63,18 +63,25 @@ android { buildFeatures { viewBinding true + buildConfig true + compose true } + lint { abortOnError false } + composeOptions { + kotlinCompilerExtensionVersion "1.5.10" + } + namespace 'net.opendasharchive.openarchive' } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.21" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1" + implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.22" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.appcompat:appcompat:1.6.1" @@ -87,6 +94,18 @@ dependencies { implementation 'androidx.preference:preference-ktx:1.2.1' implementation "androidx.work:work-runtime-ktx:2.9.0" + implementation "androidx.compose.ui:ui:1.6.4" + implementation "androidx.compose.material3:material3:1.2.1" + implementation 'androidx.compose.foundation:foundation:1.6.4' + implementation "androidx.compose.ui:ui-tooling-preview:1.6.4" + implementation "androidx.activity:activity-compose:1.8.2" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0" + + implementation "io.insert-koin:koin-core:3.5.3" + implementation "io.insert-koin:koin-android:3.5.3" + implementation "io.insert-koin:koin-androidx-compose:3.5.3" + implementation "com.github.satyan:sugar:1.5" implementation "com.google.code.gson:gson:2.10" @@ -95,8 +114,9 @@ dependencies { // 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.4" implementation "com.github.bumptech.glide:glide:4.16.0" annotationProcessor "com.github.bumptech.glide:compiler:4.16.0" @@ -113,7 +133,7 @@ dependencies { implementation "info.guardianproject.netcipher:netcipher:2.2.0-alpha" //from here: https://github.com/guardianproject/proofmode - implementation ("org.proofmode:android-libproofmode:1.0.26") { + implementation("org.proofmode:android-libproofmode:1.0.26") { transitive = false diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index da927fff..a8e82454 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -21,4 +21,12 @@ -dontwarn javax.annotation.** -dontwarn org.conscrypt.** # A resource is loaded with a relative path so the package of this class must be preserved. --keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase \ No newline at end of file +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase + +-assumenosideeffects class androidx.compose.material.icons.extended.{ + !Visibility, + !VisibilityOff, + ** +} { + ; +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c15ff3e6..6598c6c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -101,26 +101,14 @@ - - - - - - @@ -226,7 +214,7 @@ + android:value="36" /> OkHttpClient.enqueueResult( + request: Request, + onResume: (Response) -> T +) = 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.invokeOnCancellation { + dispatcher.cancelAll() + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/StatefulViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/StatefulViewModel.kt new file mode 100644 index 00000000..3aceb31d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/StatefulViewModel.kt @@ -0,0 +1,29 @@ +package net.opendasharchive.openarchive.core.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import net.opendasharchive.openarchive.core.state.StateDispatcher +import net.opendasharchive.openarchive.core.state.StoreObserver +import net.opendasharchive.openarchive.core.state.Stateful +import net.opendasharchive.openarchive.core.state.Store + +abstract class StatefulViewModel( + initialState: State, +) : ViewModel(), Store, Stateful { + + private val dispatcher = + StateDispatcher(viewModelScope, initialState, ::reduce, ::effects) + + private val observer = StoreObserver() + + override val state = dispatcher.state + override val actions = observer.actions + + abstract fun reduce(state: State, action: Action): State + + abstract suspend fun effects(state: State, action: Action) + + override fun dispatch(action: Action) = dispatcher.dispatch(action) + + override suspend fun notify(action: Action) = observer.notify(action) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/PrimaryButton.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/PrimaryButton.kt new file mode 100644 index 00000000..c7e2ff25 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/components/PrimaryButton.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.core.presentation.components + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.Button +import androidx.compose.runtime.Composable + +@Composable +fun PrimaryButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) = + Button(onClick = onClick, content = content) diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Colors.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Colors.kt new file mode 100644 index 00000000..e4e8db82 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Colors.kt @@ -0,0 +1,129 @@ +package net.opendasharchive.openarchive.core.presentation.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +private val c23_nav_drawer_night = Color(0xff101010) +private val c23_darker_grey = Color(0xff212021) +private val c23_dark_grey = Color(0xff333333) +private val c23_medium_grey = Color(0xff696666) +private val c23_grey = Color(0xff9f9f9f) +private val c23_grey_50 = Color(0xff777979) +private val c23_light_grey = Color(0xffe3e3e4) +private val c23_teal_100 = Color(0xff00ffeb) // h=175,3 s=100 v=100 --> +private val c23_teal_90 = Color(0xff00e7d5) // v=90.6 --> +private val c23_teal_80 = Color(0xff00cebe) // v=80.6 --> +private val c23_teal = Color(0xff00b4a6) // v=70.6 --> +private val c23_teal_60 = Color(0xff009b8f) // v=60.6 --> +private val c23_teal_50 = Color(0xff008177) // v=50.6 --> +private val c23_teal_40 = Color(0xff00685f) // v=40.6 --> +private val c23_teal_30 = Color(0xff004e48) // v=30.6 --> +private val c23_teal_20 = Color(0xff003530) // v=20.6 --> +private val c23_teal_10 = Color(0xff001b19) // v=10.6 --> +private val c23_powder_blue = Color(0xffaae6e1) + +@Immutable +data class ColorTheme( + val material: ColorScheme, + val primaryDark: Color = c23_teal_40, + val primaryBright: Color = c23_powder_blue, + + val disabledContainer: Color = c23_teal_20, + val onDisabledContainer: Color = c23_light_grey, +) + +private val LightColorScheme = ColorTheme( + material = lightColorScheme( + + primary = c23_teal, + onPrimary = Color.Black, + primaryContainer = c23_teal, + onPrimaryContainer = Color.Black, + + secondary = c23_teal, + onSecondary = Color.Black, + secondaryContainer = c23_teal_90, + onSecondaryContainer = Color.Black, + + tertiary = c23_powder_blue, + onTertiary = Color.Black, + tertiaryContainer = c23_powder_blue, + onTertiaryContainer = Color.Black, + + error = Color.Red, + onError = Color.Black, + errorContainer = Color.Red, + onErrorContainer = Color.Black, + + background = Color.White, + onBackground = Color.Black, + + surface = c23_light_grey, + onSurface = Color.Black, + surfaceVariant = c23_grey, + onSurfaceVariant = c23_darker_grey, + + outline = Color.Black, + inverseOnSurface = Color.White, + inverseSurface = c23_dark_grey, + inversePrimary = Color.Black, + surfaceTint = c23_teal, + outlineVariant = c23_darker_grey, + scrim = c23_light_grey, + surfaceBright = c23_light_grey, + surfaceContainer = Color.White, + surfaceDim = c23_light_grey + ), +) + +private val DarkColorScheme = ColorTheme( + material = darkColorScheme( + primary = c23_teal, + onPrimary = Color.Black, + primaryContainer = c23_teal, + onPrimaryContainer = Color.White, + + secondary = c23_teal, + onSecondary = Color.Black, + secondaryContainer = c23_teal_20, + onSecondaryContainer = Color.White, + + tertiary = c23_powder_blue, + onTertiary = Color.Black, + tertiaryContainer = c23_powder_blue, + onTertiaryContainer = Color.Black, + + error = Color.Red, + onError = Color.Black, + errorContainer = Color.Red, + onErrorContainer = Color.Black, + + background = Color.Black, + onBackground = Color.White, + + surface = c23_darker_grey, + onSurface = Color.White, + surfaceVariant = c23_dark_grey, + onSurfaceVariant = c23_light_grey, + + outline = Color.White, + inverseSurface = c23_light_grey, + inverseOnSurface = Color.Black, + inversePrimary = Color.White, + surfaceTint = c23_teal, + outlineVariant = c23_light_grey, + scrim = c23_light_grey, + surfaceBright = c23_grey, + surfaceContainer = c23_medium_grey, + surfaceDim = c23_dark_grey + ), +) + +fun getThemeColors(isDarkTheme: Boolean) = if (isDarkTheme) DarkColorScheme else LightColorScheme + +val LocalColors = staticCompositionLocalOf { LightColorScheme } + diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Dimensions.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Dimensions.kt new file mode 100644 index 00000000..a813a0b6 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Dimensions.kt @@ -0,0 +1,44 @@ +package net.opendasharchive.openarchive.core.presentation.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class Elevations(val card: Dp = 12.dp) + +@Immutable +data class Icons( + val small: Dp = 24.dp, + val medium: Dp = 48.dp, + val large: Dp = 72.dp +) + +@Immutable +data class Spacing( + val xsmall: Dp = 4.dp, + val small: Dp = 8.dp, + val medium: Dp = 16.dp, + val large: Dp = 24.dp, + val xlarge: Dp = 32.dp +) + +@Immutable +data class DimensionsTheme( + val touchable: Dp = 48.dp, + val spacing: Spacing = Spacing(), + val elevations: Elevations = Elevations(), + val icons: Icons = Icons(), + val bubbleArrow: Dp = 24.dp, + val roundedCorner: Dp = 8.dp +) + +private val DimensionsLight = DimensionsTheme() + +private val DimensionsDark = DimensionsTheme(elevations = Elevations(card = 0.dp)) + +fun getThemeDimensions(isDarkTheme: Boolean) = + if (isDarkTheme) DimensionsDark else DimensionsLight + +val LocalDimensions = staticCompositionLocalOf { DimensionsLight } diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Theme.kt b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Theme.kt new file mode 100644 index 00000000..e0bfbea0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/presentation/theme/Theme.kt @@ -0,0 +1,33 @@ +package net.opendasharchive.openarchive.core.presentation.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState + +@Composable +fun Theme( + content: @Composable () -> Unit +) { + val isDarkTheme by rememberUpdatedState(newValue = isSystemInDarkTheme()) + + val colors = getThemeColors(isDarkTheme) + + val dimensions = getThemeDimensions(isDarkTheme) + + CompositionLocalProvider( + LocalDimensions provides dimensions, + LocalColors provides colors, + ) { + MaterialTheme( + colorScheme = colors.material, + content = content + ) + } +} + + +val ThemeColors: ColorTheme @Composable get() = LocalColors.current +val ThemeDimensions: DimensionsTheme @Composable get() = LocalDimensions.current diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Dispatcher.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Dispatcher.kt new file mode 100644 index 00000000..c03cb9f3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Dispatcher.kt @@ -0,0 +1,8 @@ +package net.opendasharchive.openarchive.core.state + +typealias Dispatch = (A) -> Unit + +fun interface Dispatcher { + + fun dispatch(action: Action) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Effects.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Effects.kt new file mode 100644 index 00000000..677c100c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Effects.kt @@ -0,0 +1,4 @@ +package net.opendasharchive.openarchive.core.state + + +typealias Effects = suspend (T, A) -> Unit \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Listener.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Listener.kt new file mode 100644 index 00000000..9fdc43a3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Listener.kt @@ -0,0 +1,7 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.flow.Flow + +interface Listener { + val actions: Flow +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Notifier.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Notifier.kt new file mode 100644 index 00000000..c5901fb7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Notifier.kt @@ -0,0 +1,8 @@ +package net.opendasharchive.openarchive.core.state + + +typealias Notify = suspend (A) -> Unit + +fun interface Notifier { + suspend fun notify(action: Action) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Reducer.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Reducer.kt new file mode 100644 index 00000000..0f20ef84 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Reducer.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.updateAndGet + +typealias Reducer = (T, A) -> T + +fun MutableStateFlow.apply(action: A, reducer: Reducer) = + updateAndGet { reducer(it, action) } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/StateDispatcher.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/StateDispatcher.kt new file mode 100644 index 00000000..e9ec57b7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/StateDispatcher.kt @@ -0,0 +1,25 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class StateDispatcher( + private val scope: CoroutineScope, + initialState: T, + private val reducer: Reducer, + private val effects: Effects +) : Dispatcher, Stateful { + private val _state = MutableStateFlow(initialState) + + override val state = _state.asStateFlow() + + override fun dispatch(action: A) { + val state = _state.apply(action, reducer) + scope.launch(Dispatchers.Default) { + effects(state, action) + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Stateful.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Stateful.kt new file mode 100644 index 00000000..9dfcda1e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Stateful.kt @@ -0,0 +1,7 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.flow.StateFlow + +interface Stateful { + val state: StateFlow +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/Store.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/Store.kt new file mode 100644 index 00000000..c5453fb3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/Store.kt @@ -0,0 +1,6 @@ +package net.opendasharchive.openarchive.core.state + +interface Store : Dispatcher, Listener, Notifier { + + operator fun invoke(action: Action) = dispatch(action) +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/state/StoreObserver.kt b/app/src/main/java/net/opendasharchive/openarchive/core/state/StoreObserver.kt new file mode 100644 index 00000000..94f7fa3c --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/state/StoreObserver.kt @@ -0,0 +1,13 @@ +package net.opendasharchive.openarchive.core.state + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +class StoreObserver : Notifier, Listener { + private val _actions = Channel() + override val actions = _actions.receiveAsFlow() + + override suspend fun notify(action: T) { + _actions.send(action) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt b/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt index e8f9e5dc..56d24470 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt @@ -28,6 +28,7 @@ data class Space( var displayname: String = "", var password: String = "", var host: String = "", + var metaData: String = "", private var licenseUrl: String? = null, private var chunking: Boolean? = null ) : SugarRecord() { diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt new file mode 100644 index 00000000..f512d6b9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt @@ -0,0 +1,32 @@ +package net.opendasharchive.openarchive.features.internetarchive + +import com.google.gson.FieldNamingPolicy +import com.google.gson.Gson +import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.InternetArchiveLoginUseCase +import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.ValidateLoginCredentialsUseCase +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveLocalSource +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveRemoteSource +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.mapping.InternetArchiveMapper +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository.InternetArchiveRepository +import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsViewModel +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +typealias InternetArchiveGson = Gson + +val internetArchiveModule = module { + single { + Gson().newBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create() + } + factory { ValidateLoginCredentialsUseCase() } + factory { InternetArchiveRemoteSource(get(), get()) } + single { InternetArchiveLocalSource() } + factory { InternetArchiveMapper() } + factory { InternetArchiveRepository(get(), get(), get()) } + factory { args -> InternetArchiveLoginUseCase(get(), get(), args.get()) } + viewModel { args -> InternetArchiveDetailsViewModel(get(), args.get()) } + viewModel { args -> InternetArchiveLoginViewModel(get(), args.get()) } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/model/InternetArchive.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/model/InternetArchive.kt new file mode 100644 index 00000000..843cefd6 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/model/InternetArchive.kt @@ -0,0 +1,18 @@ +package net.opendasharchive.openarchive.features.internetarchive.domain.model + +data class InternetArchive( + val meta: MetaData, + val auth: Auth +) { + data class MetaData( + val userName: String, + val screenName: String, + val email: String, + ) + + + data class Auth( + val access: String, + val secret: String, + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt new file mode 100644 index 00000000..84ea461a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt @@ -0,0 +1,32 @@ +package net.opendasharchive.openarchive.features.internetarchive.domain.usecase + +import com.google.gson.Gson +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository.InternetArchiveRepository + +class InternetArchiveLoginUseCase( + private val repository: InternetArchiveRepository, + private val gson: Gson, + private val space: Space, +) { + + suspend operator fun invoke(email: String, password: String): Result = + repository.login(email, password).mapCatching { response -> + + response.auth.let { auth -> + repository.testConnection(auth).getOrThrow() + space.username = auth.access + space.password = auth.secret + } + + // TODO: use local data source for database + space.metaData = gson.toJson(response.meta) + space.save() + + Space.current = space + + response + } + +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/ValidateLoginCredentialsUseCase.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/ValidateLoginCredentialsUseCase.kt new file mode 100644 index 00000000..d8054ee8 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/ValidateLoginCredentialsUseCase.kt @@ -0,0 +1,18 @@ +package net.opendasharchive.openarchive.features.internetarchive.domain.usecase + +class ValidateLoginCredentialsUseCase { + + operator fun invoke(identifier: String, factor: String): Boolean { + return if (identifier.contains('@')) { + validateEmail(identifier) + } else { + validateUsername(identifier) + } && validatePassword(factor) + } + + private fun validateEmail(identifier: String) = identifier.isNotBlank() + + private fun validateUsername(identifier: String) = identifier.isNotBlank() + + private fun validatePassword(factor: String) = factor.isNotBlank() +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveLocalSource.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveLocalSource.kt new file mode 100644 index 00000000..01037892 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveLocalSource.kt @@ -0,0 +1,21 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive + +class InternetArchiveLocalSource { + // TODO: just use a memory cache for demo, will need to store in DB + // the database should be SQLCipher (https://www.zetetic.net/sqlcipher/) + // as we are storing access keys. Sugar record does not support sql cipher + // so planning a migration using local data sources. + private val cache = MutableStateFlow(null) + + fun set(value: InternetArchive) = cache.update { value } + + fun get() = cache.value + + fun subscribe() = cache.filterNotNull() +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveRemoteSource.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveRemoteSource.kt new file mode 100644 index 00000000..610bf437 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/datasource/InternetArchiveRemoteSource.kt @@ -0,0 +1,48 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource + +import android.content.Context +import com.google.gson.Gson +import net.opendasharchive.openarchive.core.infrastructure.client.enqueueResult +import net.opendasharchive.openarchive.features.internetarchive.InternetArchiveGson +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginRequest +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginResponse +import net.opendasharchive.openarchive.services.SaveClient +import net.opendasharchive.openarchive.services.internetarchive.IaConduit.Companion.ARCHIVE_API_ENDPOINT +import okhttp3.FormBody +import okhttp3.Request + +private const val LOGIN_URI = "https://archive.org/services/xauthn?op=login" + +class InternetArchiveRemoteSource( + private val context: Context, + private val gson: InternetArchiveGson +) { + suspend fun login(request: InternetArchiveLoginRequest): Result = + SaveClient.get(context).enqueueResult( + Request.Builder() + .url(LOGIN_URI) + .post( + FormBody.Builder().add("email", request.email) + .add("password", request.password).build() + ) + .build() + ) { response -> + val data = gson.fromJson( + response.body?.string(), + InternetArchiveLoginResponse::class.java + ) + Result.success(data) + } + + suspend fun testConnection(auth: InternetArchive.Auth): Result = + SaveClient.get(context).enqueueResult( + Request.Builder() + .url(ARCHIVE_API_ENDPOINT) + .method("GET", null) + .addHeader("Authorization", "LOW ${auth.access}:${auth.secret}") + .build() + ) { response -> + Result.success(response.isSuccessful) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/mapping/InternetArchiveMapper.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/mapping/InternetArchiveMapper.kt new file mode 100644 index 00000000..665403c7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/mapping/InternetArchiveMapper.kt @@ -0,0 +1,20 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.mapping + +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginResponse + +class InternetArchiveMapper { + + private operator fun invoke(response: InternetArchiveLoginResponse.S3) = InternetArchive.Auth( + access = response.access, secret = response.secret + ) + + operator fun invoke(response: InternetArchiveLoginResponse.Values) = InternetArchive( + meta = InternetArchive.MetaData( + userName = response.itemname ?: "", + email = response.email ?: "", + screenName = response.screenname ?: "" + ), + auth = response.s3?.let { invoke(it) } ?: InternetArchive.Auth("", "") + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginRequest.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginRequest.kt new file mode 100644 index 00000000..9e9f9889 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginRequest.kt @@ -0,0 +1,6 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.model + +data class InternetArchiveLoginRequest( + val email: String, + val password: String, +) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginResponse.kt new file mode 100644 index 00000000..28734530 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/InternetArchiveLoginResponse.kt @@ -0,0 +1,20 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.model + +data class InternetArchiveLoginResponse( + val success: Boolean, + val values: Values, + val version: Int, +) { + data class Values( + val s3: S3? = null, + val screenname: String? = null, + val email: String? = null, + val itemname: String? = null, + val reason: String? = null, + ) + + data class S3( + val access: String, + val secret: String, + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/UnauthenticatedException.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/UnauthenticatedException.kt new file mode 100644 index 00000000..a803cb4b --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/model/UnauthenticatedException.kt @@ -0,0 +1,3 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.model + +class UnauthenticatedException : RuntimeException() diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/repository/InternetArchiveRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/repository/InternetArchiveRepository.kt new file mode 100644 index 00000000..cec01ebc --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/infrastructure/repository/InternetArchiveRepository.kt @@ -0,0 +1,36 @@ +package net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveLocalSource +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.datasource.InternetArchiveRemoteSource +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.mapping.InternetArchiveMapper +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.InternetArchiveLoginRequest +import net.opendasharchive.openarchive.features.internetarchive.infrastructure.model.UnauthenticatedException + +class InternetArchiveRepository( + private val remoteSource: InternetArchiveRemoteSource, + private val localSource: InternetArchiveLocalSource, + private val mapper: InternetArchiveMapper +) { + suspend fun login(email: String, password: String): Result = + withContext(Dispatchers.IO) { + remoteSource.login( + InternetArchiveLoginRequest(email, password) + ).mapCatching { response -> + if (response.success.not()) { + throw IllegalArgumentException(response.values.reason) + } + when (response.version) { + else -> mapper(response.values) + } + }.onSuccess { localSource.set(it) } + } + + suspend fun testConnection(auth: InternetArchive.Auth): Result = + withContext(Dispatchers.IO) { + remoteSource.testConnection(auth) + .mapCatching { if (!it) throw UnauthenticatedException() } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt new file mode 100644 index 00000000..3f3cee65 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveActivity.kt @@ -0,0 +1,49 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import net.opendasharchive.openarchive.CleanInsightsManager +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.getSpace +import net.opendasharchive.openarchive.features.main.MainActivity + +@Deprecated("use jetpack compose") +class InternetArchiveActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val (space, isNewSpace) = intent.extras.getSpace(Space.Type.INTERNET_ARCHIVE) + + setContent { + InternetArchiveScreen(space, isNewSpace) { + finish(it) + } + } + } + + private fun finish(result: IAResult) { + when (result) { + IAResult.Saved -> { + startActivity(Intent(this, MainActivity::class.java)) + measureNewBackend(Space.Type.INTERNET_ARCHIVE) + } + + IAResult.Deleted -> Space.navigate(this) + else -> Unit + } + } +} + +fun Activity.measureNewBackend(type: Space.Type) { + CleanInsightsManager.getConsent(this) { + CleanInsightsManager.measureEvent( + "backend", + "new", + type.friendlyName + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt new file mode 100644 index 00000000..45313cae --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveFragment.kt @@ -0,0 +1,61 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.bundleWithNewSpace +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.bundleWithSpaceId +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.getSpace + +@Deprecated("only used for backward compatibility") +class InternetArchiveFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + + val (space, isNewSpace) = arguments.getSpace(Space.Type.INTERNET_ARCHIVE) + + return ComposeView(requireContext()).apply { + setContent { + InternetArchiveScreen(space, isNewSpace) { result -> + finish(result) + } + } + } + } + + private fun finish(result: IAResult) { + setFragmentResult(result.value, bundleOf()) + + if (result == IAResult.Saved) { + activity?.measureNewBackend(Space.Type.INTERNET_ARCHIVE) + } + } + + companion object { + + val RESP_SAVED = IAResult.Saved.value + val RESP_CANCEL = IAResult.Cancelled.value + + @JvmStatic + fun newInstance(args: Bundle) = InternetArchiveFragment().apply { + arguments = args + } + + @JvmStatic + fun newInstance(spaceId: Long) = newInstance(args = bundleWithSpaceId(spaceId)) + + @JvmStatic + fun newInstance() = newInstance(args = bundleWithNewSpace()) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt new file mode 100644 index 00000000..36338e8d --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/InternetArchiveScreen.kt @@ -0,0 +1,21 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation + +import androidx.compose.runtime.Composable +import net.opendasharchive.openarchive.core.presentation.theme.Theme +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult +import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsScreen +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginScreen + +@Composable +fun InternetArchiveScreen(space: Space, isNewSpace: Boolean, onFinish: (IAResult) -> Unit) = Theme { + if (isNewSpace) { + InternetArchiveLoginScreen(space) { + onFinish(it) + } + } else { + InternetArchiveDetailsScreen(space) { + onFinish(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/BundleExt.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/BundleExt.kt new file mode 100644 index 00000000..177d5615 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/BundleExt.kt @@ -0,0 +1,37 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.components + +import android.os.Bundle +import androidx.core.os.bundleOf +import net.opendasharchive.openarchive.db.Space + +@Deprecated("only for use with fragments and activities") +private const val ARG_VAL_NEW_SPACE = -1L + +@Deprecated("only for use with fragments and activities") +private const val ARG_SPACE = "space" + +@Deprecated("only for use with fragments and activities") +enum class IAResult( + val value: String +) { + Saved("ia_fragment_resp_saved"), Deleted("ia_fragment_resp_deleted"), Cancelled("ia_fragment_resp_cancel"), +} + +@Deprecated("only for use with fragments and activities") +fun bundleWithSpaceId(spaceId: Long) = bundleOf(ARG_SPACE to spaceId) + +@Deprecated("only for use with fragments and activities") +fun bundleWithNewSpace() = bundleOf(ARG_SPACE to ARG_VAL_NEW_SPACE) + +@Deprecated("only for use with fragments and activities") +fun Bundle?.getSpace(type: Space.Type): Pair { + val mSpaceId = this?.getLong(ARG_SPACE, ARG_VAL_NEW_SPACE) ?: ARG_VAL_NEW_SPACE + + val isNewSpace = ARG_VAL_NEW_SPACE == mSpaceId + + return if (isNewSpace) { + Pair(Space(type), true) + } else { + Space.get(mSpaceId)?.let { Pair(it, false) } ?: Pair(Space(type), true) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/InternetArchiveHeader.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/InternetArchiveHeader.kt new file mode 100644 index 00000000..2f6d1a07 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/components/InternetArchiveHeader.kt @@ -0,0 +1,76 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter.Companion.tint +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions + +@Composable +fun InternetArchiveHeader(modifier: Modifier = Modifier, titleSize: TextUnit = 18.sp) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(ThemeDimensions.touchable) + .background( + color = ThemeColors.material.surface, + shape = CircleShape + ) + .clip(CircleShape) + ) { + Image( + modifier = Modifier + .matchParentSize() + .padding(11.dp), + painter = painterResource(id = R.drawable.ic_internet_archive), + contentDescription = stringResource( + id = R.string.internet_archive + ), + colorFilter = tint(colorResource(id = R.color.colorPrimary)) + ) + } + Column(modifier = Modifier.padding(start = ThemeDimensions.spacing.medium)) { + Text( + text = stringResource(id = R.string.internet_archive), + fontSize = titleSize, + fontWeight = FontWeight.Bold, + color = ThemeColors.material.onSurface + ) + Text( + text = stringResource(id = R.string.internet_archive_description), + color = ThemeColors.material.onSurfaceVariant + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun InternetArchiveHeaderPreview() { + InternetArchiveHeader() +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt new file mode 100644 index 00000000..c851cde5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsScreen.kt @@ -0,0 +1,185 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.details + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.core.state.Dispatch +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.InternetArchiveHeader +import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsViewModel.Action +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun InternetArchiveDetailsScreen(space: Space, onResult: (IAResult) -> Unit) { + val viewModel: InternetArchiveDetailsViewModel = koinViewModel { + parametersOf(space) + } + + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actions.collect { action -> + when (action) { + is Action.Remove -> onResult(IAResult.Deleted) + is Action.Cancel -> onResult(IAResult.Cancelled) + else -> Unit + } + } + } + + InternetArchiveDetailsContent(state, viewModel::dispatch) +} + +@Composable +private fun InternetArchiveDetailsContent( + state: InternetArchiveDetailsState, + dispatch: Dispatch +) { + + var isRemoving by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + + Column { + + InternetArchiveHeader() + + Spacer(Modifier.height(ThemeDimensions.spacing.large)) + + Text( + text = stringResource(id = R.string.label_username), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = state.userName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + modifier = Modifier.padding(top = 16.dp), + text = stringResource(id = R.string.label_screen_name), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + text = state.screenName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + modifier = Modifier.padding(top = 16.dp), + text = stringResource(id = R.string.label_email), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + text = state.email, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + } + + Button( + modifier = Modifier + .padding(top = 12.dp) + .align(Alignment.BottomCenter), + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { + isRemoving = true + }, + colors = ButtonDefaults.buttonColors(containerColor = ThemeColors.material.error) + ) { + Text(stringResource(id = R.string.menu_delete)) + } + } + + if (isRemoving) { + RemoveInternetArchiveDialog(onDismiss = { isRemoving = false }) { + isRemoving = false + dispatch(Action.Remove) + } + } +} + +@Composable +private fun RemoveInternetArchiveDialog(onDismiss: () -> Unit, onRemove: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = ThemeColors.material.surface, + titleContentColor = ThemeColors.material.onSurface, + textContentColor = ThemeColors.material.onSurfaceVariant, + title = { + Text(text = stringResource(id = R.string.remove_from_app)) + }, + text = { Text(stringResource(id = R.string.are_you_sure_you_want_to_remove_this_server_from_the_app)) }, + dismissButton = { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner) + ) { + Text(stringResource(id = R.string.action_cancel)) + } + }, confirmButton = { + Button( + onClick = onRemove, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + colors = ButtonDefaults.buttonColors(containerColor = ThemeColors.material.error) + ) { + Text(stringResource(id = R.string.remove)) + } + }) +} + +@Composable +@Preview(showBackground = true) +private fun InternetArchiveScreenPreview() { + InternetArchiveDetailsContent( + state = InternetArchiveDetailsState( + email = "abc@example.com", + userName = "@abc_name", + screenName = "ABC Name" + ) + ) {} +} + +@Composable +@Preview(showBackground = true) +private fun RemoveInternetArchiveDialogPreview() { + RemoveInternetArchiveDialog(onDismiss = { }) {} +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt new file mode 100644 index 00000000..d81558c9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsState.kt @@ -0,0 +1,11 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.details + +import androidx.compose.runtime.Immutable + +@Immutable +data class InternetArchiveDetailsState( + val userName: String = "", + val screenName: String = "", + val email: String = "", +) + diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt new file mode 100644 index 00000000..b5a267c2 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/details/InternetArchiveDetailsViewModel.kt @@ -0,0 +1,54 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.details + +import com.google.gson.Gson +import net.opendasharchive.openarchive.core.presentation.StatefulViewModel +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive +import net.opendasharchive.openarchive.features.internetarchive.presentation.details.InternetArchiveDetailsViewModel.Action + +class InternetArchiveDetailsViewModel( + private val gson: Gson, + private val space: Space +) : StatefulViewModel(InternetArchiveDetailsState()) { + + init { + dispatch(Action.Load(space)) + } + + override fun reduce(state: InternetArchiveDetailsState, action: Action) = when(action) { + is Action.Loaded -> state.copy( + userName = action.value.userName, + email = action.value.email, + screenName = action.value.screenName + ) + else -> state + } + + override suspend fun effects(state: InternetArchiveDetailsState, action: Action) { + when (action) { + is Action.Remove -> { + space.delete() + notify(action) + } + + is Action.Load -> { + val metaData = gson.fromJson(space.metaData, InternetArchive.MetaData::class.java) + dispatch(Action.Loaded(metaData)) + } + + is Action.Cancel -> notify(action) + else -> Unit + } + } + + sealed interface Action { + + data class Load(val value: Space) : Action + + data class Loaded(val value: InternetArchive.MetaData) : Action + + data object Remove : Action + + data object Cancel : Action + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginScreen.kt new file mode 100644 index 00000000..6c901635 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginScreen.kt @@ -0,0 +1,253 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.login + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.delay +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors +import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions +import net.opendasharchive.openarchive.core.state.Dispatch +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.IAResult +import net.opendasharchive.openarchive.features.internetarchive.presentation.components.InternetArchiveHeader +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.CreateLogin +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.Login +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdatePassword +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdateUsername +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction as Action + +@Composable +fun InternetArchiveLoginScreen(space: Space, onResult: (IAResult) -> Unit) { + val viewModel: InternetArchiveLoginViewModel = koinViewModel { + parametersOf(space) + } + + val state by viewModel.state.collectAsState() + + val launcher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult(), + onResult = {}) + + LaunchedEffect(Unit) { + viewModel.actions.collect { action -> + when (action) { + is CreateLogin -> launcher.launch( + Intent( + Intent.ACTION_VIEW, Uri.parse(CreateLogin.URI) + ) + ) + + is Action.Cancel -> onResult(IAResult.Cancelled) + + is Action.LoginSuccess -> onResult(IAResult.Saved) + + else -> Unit + } + } + } + + InternetArchiveLoginContent(state, viewModel::dispatch) +} + +@Composable +private fun InternetArchiveLoginContent( + state: InternetArchiveLoginState, dispatch: Dispatch +) { + + // If extra paranoid could pre-hash password in memory + // and use the store/dispatcher + var showPassword by rememberSaveable { + mutableStateOf(false) + } + + LaunchedEffect(state.isLoginError) { + while (state.isLoginError) { + delay(3000) + dispatch(Action.ErrorClear) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(ThemeDimensions.spacing.medium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + InternetArchiveHeader( + modifier = Modifier.padding(bottom = ThemeDimensions.spacing.large) + ) + + OutlinedTextField( + value = state.username, + enabled = !state.isBusy, + onValueChange = { dispatch(UpdateUsername(it)) }, + label = { + Text(stringResource(R.string.label_username)) + }, + placeholder = { + Text(stringResource(R.string.placeholder_email_or_username)) + }, + singleLine = true, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + autoCorrect = false, + keyboardType = KeyboardType.Email + ), + isError = state.isUsernameError, + ) + + Spacer(Modifier.height(ThemeDimensions.spacing.large)) + + OutlinedTextField( + value = state.password, + enabled = !state.isBusy, + onValueChange = { dispatch(UpdatePassword(it)) }, + label = { + Text(stringResource(R.string.label_password)) + }, + placeholder = { + Text(stringResource(R.string.placeholder_password)) + }, + singleLine = true, + trailingIcon = { + IconButton(modifier = Modifier.sizeIn(ThemeDimensions.touchable), onClick = { showPassword = !showPassword }) { + Icon( + imageVector = if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = "show password" + ) + } + }, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrect = false, + imeAction = ImeAction.Go + ), + isError = state.isPasswordError, + ) + + AnimatedVisibility( + visible = state.isLoginError, + enter = fadeIn(), + exit = fadeOut() + ) { + Text( + text = stringResource(R.string.error_incorrect_username_or_password), + color = MaterialTheme.colorScheme.error + ) + } + Row( + modifier = Modifier + .padding(top = ThemeDimensions.spacing.small) + .weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.prompt_no_account), + color = ThemeColors.material.onBackground + ) + TextButton( + modifier = Modifier.heightIn(ThemeDimensions.touchable), + onClick = { dispatch(CreateLogin) }) { + Text( + text = stringResource(R.string.label_create_login), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = ThemeDimensions.spacing.medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton( + modifier = Modifier + .weight(1f) + .heightIn(ThemeDimensions.touchable) + .padding(ThemeDimensions.spacing.small), + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { dispatch(Action.Cancel) }) { + Text(stringResource(R.string.action_cancel)) + } + Button( + modifier = Modifier + .heightIn(ThemeDimensions.touchable) + .weight(1f), + enabled = !state.isBusy && state.isValid, + shape = RoundedCornerShape(ThemeDimensions.roundedCorner), + onClick = { dispatch(Login) }, + ) { + if (state.isBusy) { + CircularProgressIndicator(color = ThemeColors.material.primary) + } else { + Text(stringResource(R.string.label_login)) + } + } + } + } +} + +@Composable +@Preview(showBackground = true) +private fun InternetArchiveLoginPreview() { + InternetArchiveLoginContent( + state = InternetArchiveLoginState( + username = "user@example.org", password = "abc123" + ) + ) {} +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt new file mode 100644 index 00000000..12bdc378 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginState.kt @@ -0,0 +1,34 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.login + +import androidx.compose.runtime.Immutable +import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive + +@Immutable +data class InternetArchiveLoginState( + val username: String = "", + val password: String = "", + val isUsernameError: Boolean = false, + val isPasswordError: Boolean = false, + val isLoginError: Boolean = false, + val isBusy: Boolean = false, + val isValid: Boolean = false, +) + +sealed interface InternetArchiveLoginAction { + data object Login : InternetArchiveLoginAction + + data object Cancel : InternetArchiveLoginAction + + data class LoginSuccess(val value: InternetArchive) : InternetArchiveLoginAction + + data class LoginError(val value: Throwable) : InternetArchiveLoginAction + + data object ErrorClear : InternetArchiveLoginAction + + data object CreateLogin : InternetArchiveLoginAction { + const val URI = "https://archive.org/account/signup" + } + + data class UpdateUsername(val value: String) : InternetArchiveLoginAction + data class UpdatePassword(val value: String) : InternetArchiveLoginAction +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt new file mode 100644 index 00000000..a04aee8a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/presentation/login/InternetArchiveLoginViewModel.kt @@ -0,0 +1,65 @@ +package net.opendasharchive.openarchive.features.internetarchive.presentation.login + +import net.opendasharchive.openarchive.core.presentation.StatefulViewModel +import net.opendasharchive.openarchive.db.Space +import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.InternetArchiveLoginUseCase +import net.opendasharchive.openarchive.features.internetarchive.domain.usecase.ValidateLoginCredentialsUseCase +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.Cancel +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.CreateLogin +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.ErrorClear +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.Login +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.LoginError +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.LoginSuccess +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdatePassword +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction.UpdateUsername +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.parameter.parametersOf +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginAction as Action +import net.opendasharchive.openarchive.features.internetarchive.presentation.login.InternetArchiveLoginState as State + +class InternetArchiveLoginViewModel( + private val validateLoginCredentials: ValidateLoginCredentialsUseCase, + private val space: Space, +) : StatefulViewModel(State()), KoinComponent { + + private val loginUseCase: InternetArchiveLoginUseCase by inject { + parametersOf(space) + } + + override fun reduce( + state: State, + action: Action + ): State = when (action) { + is UpdateUsername -> state.copy( + username = action.value, + isValid = validateLoginCredentials(action.value, state.password) + ) + + is UpdatePassword -> state.copy( + password = action.value, + isValid = validateLoginCredentials(state.username, action.value) + ) + + is Login -> state.copy(isBusy = true) + is LoginError -> state.copy(isLoginError = true, isBusy = false) + is LoginSuccess, is Cancel -> state.copy(isBusy = false) + is ErrorClear -> state.copy(isLoginError = false) + else -> state + } + + override suspend fun effects(state: State, action: Action) { + when (action) { + is Login -> + loginUseCase(state.username, state.password) + .onSuccess { ia -> + notify(LoginSuccess(ia)) + } + .onFailure { dispatch(LoginError(it)) } + + is CreateLogin, is Cancel -> notify(action) + else -> Unit + } + } + +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt index 34e4aefc..3488a036 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 @@ -13,14 +13,13 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.TooltipCompat import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.ViewPager2 import com.esafirm.imagepicker.features.ImagePickerLauncher import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar.SnackbarLayout -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import net.opendasharchive.openarchive.FolderAdapter import net.opendasharchive.openarchive.FolderAdapterListener @@ -299,8 +298,8 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener if (currentSpace != null) { mBinding.space.setDrawable( - currentSpace.getAvatar(this) - ?.scaled(32, this), Position.Start, tint = false + currentSpace.getAvatar(this@MainActivity) + ?.scaled(32, this@MainActivity), Position.Start, tint = false ) mBinding.space.text = currentSpace.friendlyName } else { @@ -376,10 +375,10 @@ class MainActivity : BaseActivity(), FolderAdapterListener, SpaceAdapterListener mSnackBar?.show() - CoroutineScope(Dispatchers.IO).launch { + lifecycleScope.launch(Dispatchers.IO) { val media = Picker.import(this@MainActivity, getSelectedProject(), uri) - MainScope().launch { + lifecycleScope.launch(Dispatchers.Main) { mSnackBar?.dismiss() intent = null diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainMediaFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainMediaFragment.kt index 5926261f..28311560 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainMediaFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainMediaFragment.kt @@ -127,7 +127,6 @@ class MainMediaFragment : Fragment() { fun updateItem(collectionId: Long, mediaId: Long, progress: Long) { mAdapters[collectionId]?.apply { - doImageFade = false updateItem(mediaId, progress) if (progress == -1L) { updateHeader(collectionId, media) 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 16336697..fd11b2e0 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 @@ -10,7 +10,7 @@ import net.opendasharchive.openarchive.features.settings.SpaceSetupFragment import net.opendasharchive.openarchive.features.settings.SpaceSetupSuccessFragment import net.opendasharchive.openarchive.services.dropbox.DropboxFragment import net.opendasharchive.openarchive.services.gdrive.GDriveFragment -import net.opendasharchive.openarchive.services.internetarchive.InternetArchiveFragment +import net.opendasharchive.openarchive.features.internetarchive.presentation.InternetArchiveFragment import net.opendasharchive.openarchive.services.internetarchive.Util import net.opendasharchive.openarchive.services.webdav.WebDavFragment @@ -242,4 +242,4 @@ class SpaceSetupActivity : BaseActivity() { Util.setBackgroundTint(mBinding.progressBlock.bar2, R.color.colorSpaceSetupProgressOn) Util.setBackgroundTint(mBinding.progressBlock.dot3, R.color.colorSpaceSetupProgressOn) } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt index 02ebd64d..8c9acfe2 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 @@ -12,7 +12,7 @@ import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.core.BaseActivity import net.opendasharchive.openarchive.services.dropbox.DropboxActivity import net.opendasharchive.openarchive.services.gdrive.GDriveActivity -import net.opendasharchive.openarchive.services.internetarchive.InternetArchiveActivity +import net.opendasharchive.openarchive.features.internetarchive.presentation.InternetArchiveActivity import net.opendasharchive.openarchive.services.webdav.WebDavActivity import net.opendasharchive.openarchive.util.extensions.Position import net.opendasharchive.openarchive.util.extensions.getVersionName diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt index 83cdd285..a03f3505 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -114,7 +114,6 @@ abstract class Conduit( fun jobProgress(uploadedBytes: Long) { mMedia.progress = uploadedBytes - BroadcastManager.postProgress(mContext, mMedia.collectionId, mMedia.id, uploadedBytes) } 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 bb217a70..da251069 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/SaveClient.kt @@ -158,4 +158,4 @@ class SaveClient(context: Context) : StrongBuilderBase return sardine } } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt index 270ce5e8..22071e1c 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaConduit.kt @@ -43,12 +43,11 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { // TODO this should make sure we aren't accidentally using one of archive.org's metadata fields by accident val slug = getSlug(mMedia.title) - /// Upload metadata - var basePath = "$slug-${Util.RandomString(4).nextString()}" val fileName = getUploadFileName(mMedia, true) - val metaJson = gson.toJsonTree(mMedia) + val metaJson = gson.toJson(mMedia) val proof = getProof() + var basePath = "$slug-${Util.RandomString(4).nextString()}" val url = "$ARCHIVE_API_ENDPOINT/$basePath/$fileName" // upload content synchronously for progress @@ -56,7 +55,7 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { // upload metadata and proofs async, and report failures basePath = "$slug-${Util.RandomString(4).nextString()}" - client.uploadMetaData(metaJson.toString(), basePath, fileName) + client.uploadMetaData(metaJson, basePath, fileName) /// Upload ProofMode metadata, if enabled and successfully created. for (file in proof) { @@ -66,9 +65,7 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { val finalPath = ARCHIVE_DETAILS_ENDPOINT + basePath mMedia.serverUrl = finalPath - withContext(Dispatchers.IO) { - jobSucceeded() - } + jobSucceeded() return true } catch (e: Exception) { @@ -83,7 +80,6 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { } private suspend fun OkHttpClient.uploadContent(url: String, mimeType: String) { - val mediaUri = mMedia.originalFilePath val requestBody = RequestBodyUtil.create( mContext.contentResolver, @@ -92,10 +88,7 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { mimeType.toMediaTypeOrNull(), createListener(cancellable = { !mCancelled }, onProgress = { jobProgress(it) - }) { - Thread.sleep(500) - jobSucceeded() - } + }) ) val request = Request.Builder() @@ -113,7 +106,7 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { textMediaType, content.byteInputStream(), content.length.toLong(), - null + createListener(cancellable = { !mCancelled }) ) val url = "$ARCHIVE_API_ENDPOINT/$basePath/$fileName.meta.json" @@ -251,4 +244,4 @@ class IaConduit(media: Media, context: Context) : Conduit(media, context) { }) } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowFragment.kt deleted file mode 100644 index 215202e6..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowFragment.kt +++ /dev/null @@ -1,99 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.Lifecycle -import androidx.viewpager2.adapter.FragmentStateAdapter -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentIaLearnHowBinding - -class IaLearnHowFragment : BottomSheetDialogFragment() { - private lateinit var mBinding: FragmentIaLearnHowBinding - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - - // make sure this bottom sheet is expanded on start - dialog.setOnShowListener { - val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) - bottomSheet?.let { - BottomSheetBehavior.from(it).state = BottomSheetBehavior.STATE_EXPANDED - } - } - return dialog - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - // Inflate the layout for this fragment - mBinding = FragmentIaLearnHowBinding.inflate(inflater) - - mBinding.viewPager.adapter = LearnHowAdapter( - requireActivity().supportFragmentManager, - requireActivity().lifecycle - ) - mBinding.dotsIndicator.attachTo(mBinding.viewPager) - - mBinding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - if (mBinding.viewPager.currentItem + 1 < mBinding.viewPager.adapter!!.itemCount) { - mBinding.nextButton.setText(R.string.next) - } else { - mBinding.nextButton.setText(R.string.done) - } - } - }) - - mBinding.nextButton.setOnClickListener { - if (mBinding.viewPager.currentItem + 1 < mBinding.viewPager.adapter!!.itemCount) { - mBinding.viewPager.currentItem++ - } else { - dismiss() - } - } - - return mBinding.root - } - - class LearnHowAdapter( - fragmentManager: FragmentManager, - lifecycle: Lifecycle - ) : FragmentStateAdapter(fragmentManager, lifecycle) { - - override fun getItemCount(): Int { - return 3 - } - - override fun createFragment(position: Int): Fragment { - when (position) { - 0 -> return IaLearnHowStepFragment.newInstance( - R.string.ia_learn_how_summary_step_1, - R.drawable.ia_learn_how_illustration1 - ) - - 1 -> return IaLearnHowStepFragment.newInstance( - R.string.ia_learn_how_summary_step_2, - R.drawable.ia_learn_how_illustration2 - ) - - 2 -> return IaLearnHowStepFragment.newInstance( - R.string.ia_learn_how_summary_step_3, - R.drawable.ia_learn_how_illustration3 - ) - } - throw IndexOutOfBoundsException() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowStepFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowStepFragment.kt deleted file mode 100644 index 4e981194..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaLearnHowStepFragment.kt +++ /dev/null @@ -1,43 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.fragment.app.Fragment -import net.opendasharchive.openarchive.databinding.FragmentIaLearnHowStepBinding - -class IaLearnHowStepFragment : Fragment() { - - private lateinit var mBinding: FragmentIaLearnHowStepBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mBinding = FragmentIaLearnHowStepBinding.inflate(inflater) - - arguments?.let { - mBinding.summary.text = getString(it.getInt(ARG_SUMMARY_STRING_RES)) - mBinding.illustration.setImageResource(it.getInt(ARG_ILLUSTRATION_DRAWABLE_RES)) - } - - return mBinding.root - } - - companion object { - const val ARG_SUMMARY_STRING_RES = "summary" - const val ARG_ILLUSTRATION_DRAWABLE_RES = "illustration" - - fun newInstance(@StringRes summary: Int, @DrawableRes illustration: Int): IaLearnHowStepFragment { - return IaLearnHowStepFragment().apply { - arguments = Bundle().apply { - putInt(ARG_SUMMARY_STRING_RES, summary) - putInt(ARG_ILLUSTRATION_DRAWABLE_RES, illustration) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaScrapeActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaScrapeActivity.kt deleted file mode 100644 index 3716cddb..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/IaScrapeActivity.kt +++ /dev/null @@ -1,211 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.annotation.SuppressLint -import android.content.DialogInterface -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.webkit.JavascriptInterface -import android.webkit.WebView -import android.webkit.WebViewClient -import android.widget.Toast -import info.guardianproject.netcipher.webkit.WebkitProxy -import kotlinx.coroutines.* -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.ActivityIaScrapeBinding -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.services.SaveClient -import net.opendasharchive.openarchive.services.internetarchive.Util.clearWebviewAndCookies -import net.opendasharchive.openarchive.util.AlertHelper -import net.opendasharchive.openarchive.util.extensions.show -import timber.log.Timber -import java.net.InetSocketAddress -import java.net.Proxy -import java.util.regex.Pattern - -class IaScrapeActivity : BaseActivity() { - - private lateinit var mBinding: ActivityIaScrapeBinding - - private var mAccessResult = RESULT_CANCELED - private var mAccessKey: String? = null - private var mSecretKey: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivityIaScrapeBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setSupportActionBar(mBinding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - val doRegister = intent.getBooleanExtra("register", false) - - login(if (doRegister) { ARCHIVE_CREATE_ACCOUNT_URL } else { ARCHIVE_LOGIN_URL }) - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_refresh, menu) - - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - - true - } - - R.id.action_refresh -> { - mBinding.webView.reload() - - true - } - - else -> super.onOptionsItemSelected(item) - } - } - - @SuppressLint("SetJavaScriptEnabled") - private fun login(currentURL: String) { - mBinding.webView.settings.javaScriptEnabled = true - mBinding.webView.addJavascriptInterface(JSInterface(), "htmlout") - mBinding.webView.show() - - mBinding.webView.webViewClient = object : WebViewClient() { - - @Deprecated("Deprecated in Java") - override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { - - // If logged in, redirect to credentials. - if (url == ARCHIVE_LOGGED_IN_URL) { - view.loadUrl(ARCHIVE_CREDENTIALS_URL) - return true - } - - return false - } - - override fun onPageFinished(view: WebView, url: String) { - super.onPageFinished(view, url) - - //if credentials page, inject JS for scraping. - when (url) { - ARCHIVE_CREDENTIALS_URL -> { - sIsLoginScreen = true - val jsCmd = StringBuffer() - jsCmd.append("javascript:(function(){") - jsCmd.append("window.htmlout.processHTML(''+document.getElementsByTagName('html')[0].innerHTML+'');") - jsCmd.append("document.getElementById('confirm').checked=true;") - jsCmd.append("document.getElementById('generateNewKeys').click();") - jsCmd.append("})();") - mBinding.webView.loadUrl(jsCmd.toString()) - } - ARCHIVE_CREATE_ACCOUNT_URL -> { - sIsLoginScreen = false - } - IaConduit.ARCHIVE_BASE_URL -> { - view.loadUrl(ARCHIVE_CREDENTIALS_URL) - } - } - } - } - - CoroutineScope(Dispatchers.IO).launch { - try { - val client = SaveClient.get(this@IaScrapeActivity) - - if (client.proxy?.type() == Proxy.Type.HTTP || client.proxy?.type() == Proxy.Type.SOCKS) { - val address = client.proxy?.address() as? InetSocketAddress - - if (address != null) { - WebkitProxy.setProxy(applicationContext,address.hostString, address.port) - } - } - - MainScope().launch { - mBinding.webView.loadUrl(currentURL) - } - } - catch (e: Exception) { - MainScope().launch { - Toast.makeText(this@IaScrapeActivity, e.localizedMessage, Toast.LENGTH_LONG).show() - - finish() - } - } - } - } - - private fun parseArchiveCredentials(rawHtml: String) { - try { - val pattern = Pattern.compile("
(.+?)
") - val matcher = pattern.matcher(rawHtml) - - if (matcher.find()) { - mAccessKey = matcher.group(1)?.split(":".toRegex())?.get(1)?.trim { it <= ' ' } - } - - if (matcher.find()) { - mSecretKey = matcher.group(1)?.split(":".toRegex())?.get(1)?.trim { it <= ' ' } - } - } - catch (e: Exception) { - Timber.d(e, "Unable to get site S3 credentials.") - } - } - - internal inner class JSInterface { - @JavascriptInterface - fun processHTML(html: String?) { - if (null == html) return - - if (sIsLoginScreen) { - parseArchiveCredentials(html) - if (mAccessKey != null && mSecretKey != null) { - mAccessResult = RESULT_OK - finish() - } - } - else if (html.contains("Verification Email Sent")) { - showAccountCreatedDialog { _, _ -> finish() } - } - } - } - - private fun showAccountCreatedDialog(positiveBtnClickListener: DialogInterface.OnClickListener) { - AlertHelper.show(this, R.string.archive_message, R.string.archive_title, buttons = listOf( - AlertHelper.positiveButton { dialog, which -> - positiveBtnClickListener.onClick(dialog, which) - })) - } - - override fun finish() { - Timber.d("finish()") - - val data = Intent() - data.putExtra(EXTRAS_KEY_USERNAME, mAccessKey) - data.putExtra(EXTRAS_KEY_CREDENTIALS, mSecretKey) - setResult(mAccessResult, data) - - super.finish() - - clearWebviewAndCookies(mBinding.webView) - } - - companion object { - private const val ARCHIVE_CREATE_ACCOUNT_URL = "https://archive.org/account/login.createaccount.php" - private const val ARCHIVE_LOGIN_URL = "https://archive.org/account/login.php" - private const val ARCHIVE_LOGGED_IN_URL = "https://archive.org/index.php" - private const val ARCHIVE_CREDENTIALS_URL = "https://archive.org/account/s3.php" - - const val EXTRAS_KEY_USERNAME = "username" - const val EXTRAS_KEY_CREDENTIALS = "credentials" - - private var sIsLoginScreen = false - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveActivity.kt deleted file mode 100644 index 057f6289..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveActivity.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.content.Intent -import android.os.Bundle -import android.view.* -import androidx.fragment.app.commit -import com.google.android.material.snackbar.Snackbar -import net.opendasharchive.openarchive.databinding.ActivityInternetArchiveBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.features.core.BaseActivity -import net.opendasharchive.openarchive.features.main.MainActivity -import kotlin.properties.Delegates - -class InternetArchiveActivity : BaseActivity() { - - private var mSpaceId by Delegates.notNull() - private lateinit var mSpace: Space - private lateinit var mBinding: ActivityInternetArchiveBinding - private var mSnackbar: Snackbar? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mBinding = ActivityInternetArchiveBinding.inflate(layoutInflater) - setContentView(mBinding.root) - - setSupportActionBar(mBinding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - mSpaceId = intent.getLongExtra(EXTRA_DATA_SPACE, InternetArchiveFragment.ARG_VAL_NEW_SPACE) - - if (mSpaceId != InternetArchiveFragment.ARG_VAL_NEW_SPACE) { - supportFragmentManager.commit { - replace(mBinding.internetArchiveFragment.id, InternetArchiveFragment.newInstance(mSpaceId)) - } - } - - supportFragmentManager.setFragmentResultListener(InternetArchiveFragment.RESP_SAVED, this) { _, _ -> - finishAffinity() - startActivity(Intent(this, MainActivity::class.java)) - } - supportFragmentManager.setFragmentResultListener(InternetArchiveFragment.RESP_DELETED, this) { _, _ -> - Space.navigate(this) - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - // handle appbar back button tap - if (item.itemId == android.R.id.home) { - finish() - return true - } - return super.onOptionsItemSelected(item) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveFragment.kt deleted file mode 100644 index fad7a484..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/InternetArchiveFragment.kt +++ /dev/null @@ -1,392 +0,0 @@ -package net.opendasharchive.openarchive.services.internetarchive - -import android.content.Intent -import android.os.Bundle -import android.view.KeyEvent -import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.widget.TextView -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import net.opendasharchive.openarchive.CleanInsightsManager -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.FragmentInternetArchiveBinding -import net.opendasharchive.openarchive.db.Space -import net.opendasharchive.openarchive.services.SaveClient -import net.opendasharchive.openarchive.util.AlertHelper -import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.extensions.Position -import net.opendasharchive.openarchive.util.extensions.makeSnackBar -import net.opendasharchive.openarchive.util.extensions.setDrawable -import okhttp3.Call -import okhttp3.Callback -import okhttp3.Request -import okhttp3.Response -import org.xmlpull.v1.XmlPullParser -import org.xmlpull.v1.XmlPullParserException -import org.xmlpull.v1.XmlPullParserFactory -import java.io.IOException -import java.io.InputStream -import kotlin.coroutines.suspendCoroutine - -class InternetArchiveFragment : Fragment() { - - private lateinit var mSnackbar: Snackbar - private lateinit var mSpace: Space - private var mSpaceId: Long = ARG_VAL_NEW_SPACE - private lateinit var mBinding: FragmentInternetArchiveBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mBinding = FragmentInternetArchiveBinding.inflate(inflater) - - mSpaceId = arguments?.getLong(ARG_SPACE) ?: ARG_VAL_NEW_SPACE - - if (ARG_VAL_NEW_SPACE != mSpaceId) { - mSpace = Space.get(mSpaceId) ?: Space(Space.Type.INTERNET_ARCHIVE) - - mBinding.header.visibility = View.GONE - - mBinding.accessKey.isEnabled = false - mBinding.secretKey.isEnabled = false - - mBinding.btAcquireKeys.isEnabled = false - - mBinding.btRemove.setDrawable(R.drawable.ic_delete, Position.Start, 0.5) - mBinding.btRemove.visibility = View.VISIBLE - mBinding.btRemove.setOnClickListener { - removeProject() - } - - mBinding.buttonBar.visibility = View.GONE - } - else { - mSpace = Space(Space.Type.INTERNET_ARCHIVE) - } - - mBinding.accessKey.setText(mSpace.username) - mBinding.secretKey.setText(mSpace.password) - - mBinding.secretKey.setOnEditorActionListener { _: TextView?, id: Int, _: KeyEvent? -> - if (id == EditorInfo.IME_ACTION_DONE || id == EditorInfo.IME_NULL) { - attemptLogin() - return@setOnEditorActionListener true - } - false - } - - mBinding.btAcquireKeys.setOnClickListener { - acquireKeys() - } - - mBinding.btLearnHow.setOnClickListener { - Prefs.iaHintShown = false - showFirstTimeIa() - } - - mBinding.btBack.setOnClickListener { - setFragmentResult(RESP_CANCEL, bundleOf()) - } - - mBinding.btNext.setOnClickListener { - attemptLogin() - } - - showFirstTimeIa() - - return mBinding.root - } - - private val mAcquireKeysResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode != AppCompatActivity.RESULT_OK) { - return@registerForActivityResult - } - - val username = it.data?.getStringExtra(IaScrapeActivity.EXTRAS_KEY_USERNAME) - val credentials = it.data?.getStringExtra(IaScrapeActivity.EXTRAS_KEY_CREDENTIALS) - - mBinding.accessKey.setText(username) - mBinding.secretKey.setText(credentials) - } - - private fun acquireKeys() { - mAcquireKeysResultLauncher.launch(Intent(requireContext(), IaScrapeActivity::class.java)) - } - - /** - * Attempts to sign in or register the account specified by the login form. - * If there are form errors (invalid email, missing fields, etc.), the - * errors are presented and no actual login attempt is made. - */ - private fun attemptLogin() { - // Store values at the time of the login attempt. - mSpace.username = mBinding.accessKey.text.toString() - mSpace.password = mBinding.secretKey.text.toString() - var focusView: View? = null - - // Check for a valid password, if the user entered one. - if (mSpace.password.isEmpty()) { - mBinding.secretKey.error = getString(R.string.error_field_required) - focusView = mBinding.secretKey - } - - // Check for a valid password, if the user entered one. - if (mSpace.username.isEmpty()) { - mBinding.accessKey.error = getString(R.string.error_field_required) - focusView = mBinding.accessKey - } - - if (focusView != null) { - // There was an error; don't attempt login and focus the first form field with an error. - focusView.requestFocus() - Toast.makeText(requireContext(), getString(R.string.IA_login_error), Toast.LENGTH_SHORT).show() - - return - } - - // Show a progress spinner, and kick off a background task to - // perform the user login attempt. - mSnackbar = mBinding.root.makeSnackBar(getString(R.string.login_activity_logging_message)) - mSnackbar.show() - - CoroutineScope(Dispatchers.IO).launch { - try { - testConnection() - - mSpace.save() - - Space.current = mSpace - - CleanInsightsManager.getConsent(requireActivity()) { - CleanInsightsManager.measureEvent("backend", "new", Space.Type.INTERNET_ARCHIVE.friendlyName) - } - - mSnackbar.dismiss() - - setFragmentResult(RESP_SAVED, bundleOf()) - } - catch (exception: IOException) { - if (exception.message?.startsWith("401") == true) { - showError(getString(R.string.error_incorrect_username_or_password), true) - } else { - showError(exception.localizedMessage ?: getString(R.string.error)) - } - } - } - } - - /** - * Unfortunately, this test actually only tests if the `access key` is correct. - * We can provide any `secret key` to the IA's S3 API. - * - * I couldn't find a test which proofs the latter, too, short of `PUT`ing an asset on their - * server. Which is a really bad idea, considering that we cannot `DELETE` the created bucket again. - */ - private suspend fun testConnection() { - val url = mSpace.hostUrl ?: throw IOException("400 Bad Request") - - val client = SaveClient.get(requireContext()) - - val request = Request.Builder() - .url(url) - .method("GET", null) - .addHeader("Authorization", "LOW ${mSpace.username}:${mSpace.password}") - .build() - - return suspendCoroutine { - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - it.resumeWith(Result.failure(e)) - } - - override fun onResponse(call: Call, response: Response) { - val code = response.code - val message = response.message - - val username = getUsername(response.body?.byteStream()) - - response.close() - - if (code != 200 && code != 204) { - return it.resumeWith(Result.failure(IOException("$code $message"))) - } - - if (username == null) { - return it.resumeWith(Result.failure(IOException("401 Unauthorized"))) - } - - mSpace.displayname = username - - it.resumeWith(Result.success(Unit)) - } - }) - } - } - - /** - * Parses the usernome out of an XML document which starts like this: - * - * ``` - * - * - * OpaqueIDStringGoesHere - * Readable ID Goes Here - * - * - * ``` - * - * The username is expected in the first `DisplayName` tag in the first `Owner` tag in the - * first `ListAllMyBucketsResult` tag. - * - * A username of `"Readable ID Goes Here".lowercase()` is considered not to be a username. - * (That's what the Internet Archive S3 API should return, if authorization was unsuccessful.) - */ - private fun getUsername(body: InputStream?): String? { - if (body == null) return null - - try { - val xpp = XmlPullParserFactory.newInstance().newPullParser() - xpp.setInput(body, null) - - var eventType = xpp.eventType - - var container = false - var owner = false - var displayName = false - - while (eventType != XmlPullParser.END_DOCUMENT) { - when (eventType) { - XmlPullParser.START_TAG -> { - when (xpp.name) { - "ListAllMyBucketsResult" -> { - container = true - } - "Owner" -> { - if (container) owner = true - } - "DisplayName" -> { - if (container && owner) displayName = true - } - } - } - XmlPullParser.END_TAG -> { - when (xpp.name) { - "ListAllMyBucketsResult" -> { - // Almost done anyway. - return null - } - "Owner" -> { - // It should be the first "Owner" element. - // If that went by without a "DisplayName" element, stop it. - if (container) return null - } - "DisplayName" -> { - // If the first "DisplayName" element in the first "Owner" - // element doesn't have a name, stop it. - if (container && owner) return null - } - } - } - XmlPullParser.TEXT -> { - if (container && owner && displayName) { - val username = xpp.text.trim() - - // If the access key wasn't correct, a dummy username is displayed. Ignore. - if (username.isBlank() ) { - return null - } - - // according to brenton@archive.org: - // > I just confirmed with our engineer that that response is a correct, - // > non-error response if you haven’t uploaded any items yet. It’s - // > strange text, but it’s not an error. So, his suggestion is to try - // > uploading something! - if ( username.lowercase() == "Readable ID Goes Here".lowercase() ) { - return getString(R.string.new_user) - } - - // Yay! Found a username! - return username - } - } - } - - eventType = xpp.next() - } - } - catch (e: XmlPullParserException) { - // ignore - } - catch (e: IOException) { - // ignore - } - - return null - } - - private fun showError(text: CharSequence, onForm: Boolean = false) { - requireActivity().runOnUiThread { - mSnackbar.dismiss() - - if (onForm) { - mBinding.secretKey.error = text - mBinding.secretKey.requestFocus() - } - else { - mSnackbar = mBinding.root.makeSnackBar(text, Snackbar.LENGTH_LONG) - mSnackbar.show() - - mBinding.accessKey.requestFocus() - } - } - } - - private fun removeProject() { - AlertHelper.show(requireContext(), R.string.are_you_sure_you_want_to_remove_this_server_from_the_app, R.string.remove_from_app, buttons = listOf( - AlertHelper.positiveButton(R.string.remove) { _, _ -> - mSpace.delete() - setFragmentResult(RESP_DELETED, bundleOf()) - }, - AlertHelper.negativeButton())) - } - - private fun showFirstTimeIa() { - if (Prefs.iaHintShown) return - - val f = IaLearnHowFragment() - f.show(requireActivity().supportFragmentManager, f.tag) - - Prefs.iaHintShown = true - } - - companion object { - const val ARG_SPACE = "space" - const val ARG_VAL_NEW_SPACE = -1L - - const val RESP_SAVED = "ia_fragment_resp_saved" - const val RESP_DELETED = "ia_dav_fragment_resp_deleted" - const val RESP_CANCEL = "ia_fragment_resp_cancel" - - @JvmStatic - fun newInstance(spaceId: Long) = InternetArchiveFragment().apply { - arguments = Bundle().apply { - putLong(ARG_SPACE, spaceId) - } - } - - @JvmStatic - fun newInstance() = newInstance(ARG_VAL_NEW_SPACE) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt index 1b97fe71..ddab48a9 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/RequestBodyUtil.kt @@ -1,14 +1,14 @@ package net.opendasharchive.openarchive.services.internetarchive -import okio.source -import okhttp3.internal.closeQuietly -import okhttp3.RequestBody -import kotlin.Throws -import okio.BufferedSink import android.content.ContentResolver import android.net.Uri import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.internal.closeQuietly +import okio.BufferedSink import okio.Source +import okio.source import timber.log.Timber import java.io.* @@ -150,4 +150,4 @@ object RequestBodyUtil { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/BroadcastManager.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/BroadcastManager.kt index d76d2e34..4b078e9f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/BroadcastManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/BroadcastManager.kt @@ -42,7 +42,7 @@ object BroadcastManager { } fun getAction(intent: Intent): Action? { - val action = Action.values().firstOrNull { it.id == intent.action } + val action = Action.entries.firstOrNull { it.id == intent.action } action?.mediaId = intent.getLongExtra(MEDIA_ID, -1) action?.collectionId = intent.getLongExtra(COLLECTION_ID, -1) action?.progress = intent.getLongExtra(MEDIA_PROGRESS, -1) @@ -61,4 +61,4 @@ object BroadcastManager { fun unregister(context: Context, receiver: BroadcastReceiver) { LocalBroadcastManager.getInstance(context).unregisterReceiver(receiver) } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/activity_ia_scrape.xml b/app/src/main/res/layout/activity_ia_scrape.xml deleted file mode 100644 index 0d2b3196..00000000 --- a/app/src/main/res/layout/activity_ia_scrape.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_internet_archive.xml b/app/src/main/res/layout/activity_internet_archive.xml deleted file mode 100644 index 490be10c..00000000 --- a/app/src/main/res/layout/activity_internet_archive.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b5d935f2..175e6afb 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -13,14 +13,12 @@ + android:layout_height="wrap_content"> + android:layout_height="?attr/actionBarSize"> - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_ia_learn_how_step.xml b/app/src/main/res/layout/fragment_ia_learn_how_step.xml deleted file mode 100644 index bfb6e69f..00000000 --- a/app/src/main/res/layout/fragment_ia_learn_how_step.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_internet_archive.xml b/app/src/main/res/layout/fragment_internet_archive.xml deleted file mode 100644 index 6ffdbf4f..00000000 --- a/app/src/main/res/layout/fragment_internet_archive.xml +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -