From c3f5311c37c90dab9234b3dfa63212983085eab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Kwiecin=CC=81ski?= Date: Wed, 5 Jan 2022 15:15:13 +0100 Subject: [PATCH 1/5] 2fa api --- .../wykopmobilny/api/endpoints/LoginRetrofitApi.kt | 12 ++++++++++-- .../api/responses/TwoFactorAuthorizationResponse.kt | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/responses/TwoFactorAuthorizationResponse.kt diff --git a/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/endpoints/LoginRetrofitApi.kt b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/endpoints/LoginRetrofitApi.kt index a1554012f..8a21f3c3d 100755 --- a/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/endpoints/LoginRetrofitApi.kt +++ b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/endpoints/LoginRetrofitApi.kt @@ -2,8 +2,9 @@ package io.github.wykopmobilny.api.endpoints import io.github.wykopmobilny.APP_KEY import io.github.wykopmobilny.REMOVE_USERKEY_HEADER -import io.github.wykopmobilny.api.responses.WykopApiResponse import io.github.wykopmobilny.api.responses.LoginResponse +import io.github.wykopmobilny.api.responses.TwoFactorAuthorizationResponse +import io.github.wykopmobilny.api.responses.WykopApiResponse import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.Headers @@ -16,6 +17,13 @@ interface LoginRetrofitApi { @POST("/login/index/appkey/$APP_KEY") suspend fun getUserSessionToken( @Field("login") login: String, - @Field("accountkey", encoded = true) accountKey: String + @Field("accountkey", encoded = true) accountKey: String, ): WykopApiResponse + + + @FormUrlEncoded + @POST("/login/2fa/appkey/$APP_KEY") + suspend fun autorizeWith2FA( + @Field("code") code: String, + ): WykopApiResponse } diff --git a/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/responses/TwoFactorAuthorizationResponse.kt b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/responses/TwoFactorAuthorizationResponse.kt new file mode 100644 index 000000000..d909599f4 --- /dev/null +++ b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/responses/TwoFactorAuthorizationResponse.kt @@ -0,0 +1,6 @@ +package io.github.wykopmobilny.api.responses + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class TwoFactorAuthorizationResponse From 027a2b8d75600d32ef1510372ac60cb62f4ddfb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Kwiecin=CC=81ski?= Date: Wed, 5 Jan 2022 21:11:45 +0100 Subject: [PATCH 2/5] 2fa logic --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 5 + .../kotlin/io/github/wykopmobilny/WykopApp.kt | 10 +- .../wykopmobilny/api/UserTokenRefresher.kt | 57 +++++++---- .../errorhandler/ErrorHandlerTransformer.kt | 11 +++ .../github/wykopmobilny/api/user/LoginApi.kt | 3 +- .../wykopmobilny/api/user/LoginRepository.kt | 15 ++- .../wykopmobilny/di/modules/NetworkModule.kt | 4 - .../initializers/RemoteConfigInitializer.kt | 6 +- .../ui/dialogs/ExceptionDialog.kt | 40 +++++--- .../mainnavigation/MainNavigationActivity.kt | 2 +- .../mikroblog/feed/hot/HotPresenter.kt | 6 +- .../TwoFactorAuthorizationActivity.kt | 31 ++++++ app/src/main/res/values/strings.xml | 7 +- .../wykopmobilny/api/ErrorBodyParser.kt | 9 ++ .../io/github/wykopmobilny/api/WykopApi.kt | 2 + .../wykop/remote/MoshiErrorBodyParser.kt | 27 +++++ .../wykop/remote/RetrofitModule.kt | 6 ++ domain/build.gradle | 1 + .../io/github/wykopmobilny/domain/api/Api.kt | 33 ++++++- .../wykopmobilny/domain/di/DomainComponent.kt | 5 +- .../wykopmobilny/domain/di/StoresModule.kt | 5 + .../domain/errorhandling/KnownError.kt | 6 ++ .../di/TwoFactorAuthDomainComponent.kt | 7 ++ .../domain/twofactor/di/TwoFactorAuthScope.kt | 6 ++ ui/two-factor/android/build.gradle | 19 ++++ .../android/TwoFactorMainFragment.kt | 26 +++++ .../main/res/layout/fragment_two_factor.xml | 99 +++++++++++++++++++ .../android/src/main/res/values/strings.xml | 8 ++ ui/two-factor/api/build.gradle | 8 ++ .../ui/twofactor/TwoFactorAuthDependencies.kt | 3 + 31 files changed, 407 insertions(+), 61 deletions(-) create mode 100644 app/src/main/kotlin/io/github/wykopmobilny/ui/modules/twofactor/TwoFactorAuthorizationActivity.kt create mode 100644 data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/ErrorBodyParser.kt create mode 100644 data/wykop/remote/src/main/kotlin/io/github/wykopmobilny/wykop/remote/MoshiErrorBodyParser.kt create mode 100644 domain/src/main/kotlin/io/github/wykopmobilny/domain/errorhandling/KnownError.kt create mode 100644 domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthDomainComponent.kt create mode 100644 domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthScope.kt create mode 100644 ui/two-factor/android/build.gradle create mode 100644 ui/two-factor/android/src/main/kotlin/io/github/wykopmobilny/ui/twofactor/android/TwoFactorMainFragment.kt create mode 100644 ui/two-factor/android/src/main/res/layout/fragment_two_factor.xml create mode 100644 ui/two-factor/android/src/main/res/values/strings.xml create mode 100644 ui/two-factor/api/build.gradle create mode 100644 ui/two-factor/api/src/main/kotlin/io/github/wykopmobilny/ui/twofactor/TwoFactorAuthDependencies.kt diff --git a/app/build.gradle b/app/build.gradle index 7bb33332a..d2ab6a045 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -123,6 +123,7 @@ dependencies { implementation(projects.ui.search.android) implementation(projects.ui.settings.android) implementation(projects.ui.notifications.android) + implementation(projects.ui.twoFactor.android) implementation(projects.domain) implementation(libs.recyclerview.core) implementation(libs.appcompat.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5804c9c1b..6237cb8fe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -109,6 +109,11 @@ android:parentActivityName="io.github.wykopmobilny.ui.modules.mainnavigation.MainNavigationActivity" android:theme="@style/Theme.App.Dark" /> + getOrPutScope(scopeId) { domainComponent.work() } SearchDependencies::class -> getOrPutScope(scopeId) { domainComponent.search() } NotificationDependencies::class -> getOrPutScope(scopeId) { domainComponent.notifications() } + TwoFactorAuthDependencies::class -> getOrPutScope(scopeId) { domainComponent.twoFactor() } LinkDetailsDependencies::class -> { scopeId as LinkDetailsKey getOrPutScope(scopeId) { domainComponent.linkDetails().create(key = scopeId) } @@ -306,6 +309,7 @@ open class WykopApp : DaggerApplication(), ApplicationInjector, AppScopes { LinkDetailsDependencies::class -> scopes.remove(scopeKey(scopeId)) ProfileDependencies::class -> scopes.remove(scopeKey(scopeId)) NotificationDependencies::class -> scopes.remove(scopeKey(scopeId)) + TwoFactorAuthDependencies::class -> scopes.remove(scopeKey(scopeId)) else -> error("Unknown dependency type $clazz") }?.coroutineScope?.cancel() } diff --git a/app/src/main/kotlin/io/github/wykopmobilny/api/UserTokenRefresher.kt b/app/src/main/kotlin/io/github/wykopmobilny/api/UserTokenRefresher.kt index 404d3c35e..5dff78b59 100755 --- a/app/src/main/kotlin/io/github/wykopmobilny/api/UserTokenRefresher.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/api/UserTokenRefresher.kt @@ -1,41 +1,60 @@ package io.github.wykopmobilny.api +import io.github.aakira.napier.Napier import io.github.wykopmobilny.api.errorhandler.WykopExceptionParser import io.github.wykopmobilny.api.user.LoginApi +import io.github.wykopmobilny.domain.api.WykopApiCodes +import io.github.wykopmobilny.domain.errorhandling.KnownError import io.github.wykopmobilny.utils.usermanager.UserManagerApi import io.reactivex.Flowable import io.reactivex.functions.Function import kotlinx.coroutines.rx2.rxSingle import org.reactivestreams.Publisher import retrofit2.HttpException +import javax.inject.Inject -class UserTokenRefresher( +class UserTokenRefresher @Inject constructor( private val userApi: LoginApi, private val userManagerApi: UserManagerApi, + private val errorBodyParser: ErrorBodyParser, ) : Function, Publisher<*>> { - override fun apply(t: Flowable): Publisher<*> = - t.flatMap { - when (it) { - is WykopExceptionParser.WykopApiException -> { - when (it.code) { - 11, 12 -> getSaveUserSessionFlowable() - else -> Flowable.error(it) - } + override fun apply(errors: Flowable) = + errors.flatMapSingle { failure -> rxSingle { refresh(failure) } } + + private suspend fun refresh(failure: Throwable) { + when (failure) { + is WykopExceptionParser.WykopApiException -> { + when (failure.code) { + 11, 12 -> saveUserSession() + else -> throw failure } - is HttpException -> { - if (it.code() == 401) { - getSaveUserSessionFlowable() - } else { - Flowable.error(it) + } + is HttpException -> { + if (failure.code() == 401) { + val errorBody = failure.response()?.errorBody()?.let { errorBody -> errorBodyParser.parse(errorBody) } + + when (errorBody?.code) { + WykopApiCodes.TwoFactorAuthorizationRequired -> + throw KnownError.TwoFactorAuthorizationRequired("[${errorBody.code}] ${errorBody.messagePl}") + WykopApiCodes.InvalidUserKey, + WykopApiCodes.WrongUserSessionKey, + -> saveUserSession() + else -> { + Napier.w(message = "Unsupported error code $errorBody") + saveUserSession() + } } + } else { + throw failure } - else -> Flowable.error(it) } + else -> throw failure } + } - private fun getSaveUserSessionFlowable() = - userApi.getUserSessionToken() - .flatMap { rxSingle { userManagerApi.saveCredentials(it) } } - .toFlowable() + private suspend fun saveUserSession() { + val token = userApi.getUserSessionToken() + userManagerApi.saveCredentials(token) + } } diff --git a/app/src/main/kotlin/io/github/wykopmobilny/api/errorhandler/ErrorHandlerTransformer.kt b/app/src/main/kotlin/io/github/wykopmobilny/api/errorhandler/ErrorHandlerTransformer.kt index 5462f739f..56adf6093 100755 --- a/app/src/main/kotlin/io/github/wykopmobilny/api/errorhandler/ErrorHandlerTransformer.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/api/errorhandler/ErrorHandlerTransformer.kt @@ -15,3 +15,14 @@ class ErrorHandlerTransformer : SingleTransformer, } } } + +suspend fun unwrapping(block: suspend () -> WykopApiResponse): T { + val response = block() + val exception = WykopExceptionParser.getException(response) + + return if (exception != null) { + throw exception + } else { + response.data!! + } +} diff --git a/app/src/main/kotlin/io/github/wykopmobilny/api/user/LoginApi.kt b/app/src/main/kotlin/io/github/wykopmobilny/api/user/LoginApi.kt index 06f48bd5b..3a5e9a606 100755 --- a/app/src/main/kotlin/io/github/wykopmobilny/api/user/LoginApi.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/api/user/LoginApi.kt @@ -1,8 +1,7 @@ package io.github.wykopmobilny.api.user import io.github.wykopmobilny.api.responses.LoginResponse -import io.reactivex.Single interface LoginApi { - fun getUserSessionToken(): Single + suspend fun getUserSessionToken(): LoginResponse } diff --git a/app/src/main/kotlin/io/github/wykopmobilny/api/user/LoginRepository.kt b/app/src/main/kotlin/io/github/wykopmobilny/api/user/LoginRepository.kt index af4aae294..735d8f362 100755 --- a/app/src/main/kotlin/io/github/wykopmobilny/api/user/LoginRepository.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/api/user/LoginRepository.kt @@ -1,10 +1,10 @@ package io.github.wykopmobilny.api.user import io.github.wykopmobilny.api.endpoints.LoginRetrofitApi -import io.github.wykopmobilny.api.errorhandler.ErrorHandlerTransformer +import io.github.wykopmobilny.api.errorhandler.unwrapping +import io.github.wykopmobilny.api.responses.LoginResponse import io.github.wykopmobilny.storage.api.SessionStorage import kotlinx.coroutines.flow.first -import kotlinx.coroutines.rx2.rxSingle import javax.inject.Inject class LoginRepository @Inject constructor( @@ -12,10 +12,9 @@ class LoginRepository @Inject constructor( private val apiPreferences: SessionStorage, ) : LoginApi { - override fun getUserSessionToken() = - rxSingle { - val session = apiPreferences.session.first().let(::checkNotNull) - loginApi.getUserSessionToken(login = session.login, accountKey = session.token) - } - .compose(ErrorHandlerTransformer()) + override suspend fun getUserSessionToken(): LoginResponse = unwrapping { + val session = apiPreferences.session.first().let(::checkNotNull) + loginApi.getUserSessionToken(login = session.login, accountKey = session.token) + } + } diff --git a/app/src/main/kotlin/io/github/wykopmobilny/di/modules/NetworkModule.kt b/app/src/main/kotlin/io/github/wykopmobilny/di/modules/NetworkModule.kt index 02b121fd5..26d2aa505 100755 --- a/app/src/main/kotlin/io/github/wykopmobilny/di/modules/NetworkModule.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/di/modules/NetworkModule.kt @@ -27,10 +27,6 @@ class NetworkModule { fun provideNotificationManager(context: Context): NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - @Provides - fun provideUserTokenRefresher(userApi: LoginApi, userManagerApi: UserManagerApi) = - UserTokenRefresher(userApi, userManagerApi) - @Provides fun provideNavigatorApi(): NavigatorApi = Navigator() diff --git a/app/src/main/kotlin/io/github/wykopmobilny/initializers/RemoteConfigInitializer.kt b/app/src/main/kotlin/io/github/wykopmobilny/initializers/RemoteConfigInitializer.kt index f1dc3a363..561bb72fb 100644 --- a/app/src/main/kotlin/io/github/wykopmobilny/initializers/RemoteConfigInitializer.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/initializers/RemoteConfigInitializer.kt @@ -9,7 +9,7 @@ import io.github.wykopmobilny.initializers.RemoteConfigKeys.API_APP_SECRET import io.github.wykopmobilny.initializers.RemoteConfigKeys.BLACKLIST_FLEX_INTERVAL import io.github.wykopmobilny.initializers.RemoteConfigKeys.BLACKLIST_REFRESH_INTERVAL import io.github.wykopmobilny.initializers.RemoteConfigKeys.NOTIFICATIONS_ENABLED -import kotlin.time.Duration +import kotlin.time.Duration.Companion.days internal class RemoteConfigInitializer : Initializer { @@ -18,8 +18,8 @@ internal class RemoteConfigInitializer : Initializer { mapOf( API_APP_KEY to BuildConfig.APP_KEY, API_APP_SECRET to BuildConfig.APP_SECRET, - BLACKLIST_REFRESH_INTERVAL to Duration.days(7).inWholeMilliseconds, - BLACKLIST_FLEX_INTERVAL to Duration.days(1).inWholeMilliseconds, + BLACKLIST_REFRESH_INTERVAL to 7.days.inWholeMilliseconds, + BLACKLIST_FLEX_INTERVAL to 1.days.inWholeMilliseconds, NOTIFICATIONS_ENABLED to false, ), ) diff --git a/app/src/main/kotlin/io/github/wykopmobilny/ui/dialogs/ExceptionDialog.kt b/app/src/main/kotlin/io/github/wykopmobilny/ui/dialogs/ExceptionDialog.kt index 27316d4e9..8957aa282 100644 --- a/app/src/main/kotlin/io/github/wykopmobilny/ui/dialogs/ExceptionDialog.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/ui/dialogs/ExceptionDialog.kt @@ -6,30 +6,44 @@ import io.github.aakira.napier.Napier import io.github.wykopmobilny.R import io.github.wykopmobilny.api.errorhandler.WykopExceptionParser import io.github.wykopmobilny.base.BaseActivity +import io.github.wykopmobilny.domain.errorhandling.KnownError +import io.github.wykopmobilny.ui.modules.twofactor.TwoFactorAuthorizationActivity import okio.IOException import javax.net.ssl.SSLException -fun Context.showExceptionDialog(ex: Throwable) { +fun Context.showExceptionDialog(throwable: Throwable) { if (this is BaseActivity && isRunning) { - exceptionDialog(this, ex)?.show() + if (throwable is KnownError.TwoFactorAuthorizationRequired) { + exceptionDialog( + throwable = throwable, + onPositive = R.string.open_2fa to { startActivity(TwoFactorAuthorizationActivity.createIntent(this)) }, + ) + } else { + exceptionDialog(throwable = throwable) + } + .show() } - when (ex) { - is SSLException -> Napier.e("SSL error", ex) - is IOException -> Napier.w("IO error", ex) - else -> Napier.e("Unknown error", ex) + when (throwable) { + is SSLException -> Napier.e("SSL error", throwable) + is IOException -> Napier.w("IO error", throwable) + else -> Napier.e("Unknown error", throwable) } } -private fun exceptionDialog(context: Context, e: Throwable): AlertDialog? { +private fun Context.exceptionDialog( + throwable: Throwable, + onPositive: Pair Unit> = android.R.string.ok to { }, +): AlertDialog { val message = when { - e is WykopExceptionParser.WykopApiException -> "${e.message} (${e.code})" - e.message.isNullOrEmpty() -> e.toString() - else -> e.message + throwable is WykopExceptionParser.WykopApiException -> "${throwable.message} (${throwable.code})" + throwable.message.isNullOrEmpty() -> throwable.toString() + else -> throwable.message } - AlertDialog.Builder(context).run { + val builder = AlertDialog.Builder(this).apply { setTitle(context.getString(R.string.error_occured)) setMessage(message) - setPositiveButton(android.R.string.ok, null) - return create() + setPositiveButton(onPositive.first) { _, _ -> onPositive.second() } } + + return builder.create() } diff --git a/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/mainnavigation/MainNavigationActivity.kt b/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/mainnavigation/MainNavigationActivity.kt index 0f9a8b940..9e20d7b68 100644 --- a/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/mainnavigation/MainNavigationActivity.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/mainnavigation/MainNavigationActivity.kt @@ -361,7 +361,7 @@ class MainNavigationActivity : } appObserveTag.setOnClickListener { - navigator.openTagActivity("otwartywykopmobilny") + navigator.openTagActivity("otwartywykopmobilny2") dialog.dismiss() } diff --git a/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/mikroblog/feed/hot/HotPresenter.kt b/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/mikroblog/feed/hot/HotPresenter.kt index 2e3c04861..4472ab4b0 100644 --- a/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/mikroblog/feed/hot/HotPresenter.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/mikroblog/feed/hot/HotPresenter.kt @@ -8,7 +8,7 @@ import io.github.wykopmobilny.utils.intoComposite class HotPresenter( val schedulers: Schedulers, - val entriesApi: EntriesApi + val entriesApi: EntriesApi, ) : BasePresenter() { var page = 1 @@ -22,7 +22,9 @@ class HotPresenter( view?.showHotEntries(it, shouldRefresh) } else view?.disableLoading() } - val failure: (Throwable) -> Unit = { view?.showErrorDialog(it) } + val failure: (Throwable) -> Unit = { + view?.showErrorDialog(it) + } when (period) { "24", "12", "6" -> { diff --git a/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/twofactor/TwoFactorAuthorizationActivity.kt b/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/twofactor/TwoFactorAuthorizationActivity.kt new file mode 100644 index 000000000..04664dfd7 --- /dev/null +++ b/app/src/main/kotlin/io/github/wykopmobilny/ui/modules/twofactor/TwoFactorAuthorizationActivity.kt @@ -0,0 +1,31 @@ +package io.github.wykopmobilny.ui.modules.twofactor + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import io.github.wykopmobilny.base.ThemableActivity +import io.github.wykopmobilny.databinding.ActivityContainerBinding +import io.github.wykopmobilny.ui.twofactor.android.twoFactorMainFragment +import io.github.wykopmobilny.utils.viewBinding + +internal class TwoFactorAuthorizationActivity : ThemableActivity() { + + + private val binding by viewBinding(ActivityContainerBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .replace(binding.fragmentContainer.id, twoFactorMainFragment()) + .commit() + } + } + companion object { + + fun createIntent(context: Context) = + Intent(context, TwoFactorAuthorizationActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1befff98b..726ec4d5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - Wykop Mobilny + Wypok Mobilny Wyszukiwarka Twój profil @@ -24,8 +24,8 @@ Publikacja aplikacji na licencji MIT Privacy policy/Licencja prywatności Zgłoś błąd pod tagiem #owmbugi - Obserwuj tag #otwartywykopmobilny - Wykop Mobilny (%1$s) + Obserwuj tag #otwartywykopmobilny2 + Wypok (%1$s) Osiągnięcia @@ -175,6 +175,7 @@ Duża nad tekstem Duża pod tekstem Otwórz w … + Otwórz 2FA Zapisz do mp4 Zapisano plik "Błąd podczas zapisu pliku" diff --git a/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/ErrorBodyParser.kt b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/ErrorBodyParser.kt new file mode 100644 index 000000000..4cb776e37 --- /dev/null +++ b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/ErrorBodyParser.kt @@ -0,0 +1,9 @@ +package io.github.wykopmobilny.api + +import io.github.wykopmobilny.api.responses.WykopErrorResponse +import okhttp3.ResponseBody + +interface ErrorBodyParser { + + suspend fun parse(body: ResponseBody): WykopErrorResponse? +} diff --git a/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/WykopApi.kt b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/WykopApi.kt index 0b5418e64..6f8265b96 100644 --- a/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/WykopApi.kt +++ b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/WykopApi.kt @@ -41,4 +41,6 @@ interface WykopApi { fun suggestRetrofitApi(): SuggestRetrofitApi fun tagRetrofitApi(): TagRetrofitApi + + fun errorBodyParser(): ErrorBodyParser } diff --git a/data/wykop/remote/src/main/kotlin/io/github/wykopmobilny/wykop/remote/MoshiErrorBodyParser.kt b/data/wykop/remote/src/main/kotlin/io/github/wykopmobilny/wykop/remote/MoshiErrorBodyParser.kt new file mode 100644 index 000000000..376a62589 --- /dev/null +++ b/data/wykop/remote/src/main/kotlin/io/github/wykopmobilny/wykop/remote/MoshiErrorBodyParser.kt @@ -0,0 +1,27 @@ +package io.github.wykopmobilny.wykop.remote + +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.Reusable +import io.github.wykopmobilny.api.ErrorBodyParser +import io.github.wykopmobilny.api.responses.WykopApiResponse +import io.github.wykopmobilny.ui.base.AppDispatchers +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import javax.inject.Inject + +@Reusable +internal class MoshiErrorBodyParser @Inject constructor( + private val moshi: Moshi, +) : ErrorBodyParser { + + private val adapter by lazy { + moshi.adapter>(Types.newParameterizedType(WykopApiResponse::class.java, Any::class.java)) + } + + override suspend fun parse(body: ResponseBody) = withContext(AppDispatchers.Default) { + val source = adapter.fromJson(body.source()) + + source?.error + } +} diff --git a/data/wykop/remote/src/main/kotlin/io/github/wykopmobilny/wykop/remote/RetrofitModule.kt b/data/wykop/remote/src/main/kotlin/io/github/wykopmobilny/wykop/remote/RetrofitModule.kt index 921358eeb..f365f8e1f 100644 --- a/data/wykop/remote/src/main/kotlin/io/github/wykopmobilny/wykop/remote/RetrofitModule.kt +++ b/data/wykop/remote/src/main/kotlin/io/github/wykopmobilny/wykop/remote/RetrofitModule.kt @@ -4,6 +4,7 @@ import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides import dagger.Reusable +import io.github.wykopmobilny.api.ErrorBodyParser import okhttp3.Cache import okhttp3.Interceptor import okhttp3.OkHttpClient @@ -51,6 +52,11 @@ internal class RetrofitModule { .addConverterFactory(MoshiConverterFactory.create(moshi)) .build() + @Provides + fun errorBodyParser( + errorBodyParser: MoshiErrorBodyParser, + ): ErrorBodyParser = errorBodyParser + companion object { private const val CACHE_SIZE = 10 * 1024 * 1024L diff --git a/domain/build.gradle b/domain/build.gradle index f5aae236c..9e6f72e55 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation(projects.ui.blacklist.api) implementation(projects.ui.profile.api) implementation(projects.ui.search.api) + implementation(projects.ui.twoFactor.api) implementation(libs.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.store.core) diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/api/Api.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/api/Api.kt index 973c428ce..f4f17dfcb 100644 --- a/domain/src/main/kotlin/io/github/wykopmobilny/domain/api/Api.kt +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/api/Api.kt @@ -4,7 +4,11 @@ import com.dropbox.android.external.store4.Fetcher import com.dropbox.android.external.store4.FetcherResult import com.dropbox.android.external.store4.Store import com.dropbox.android.external.store4.fresh +import io.github.aakira.napier.Napier +import io.github.wykopmobilny.api.ErrorBodyParser import io.github.wykopmobilny.api.responses.ApiResponse +import io.github.wykopmobilny.domain.api.WykopApiCodes.TwoFactorAuthorizationRequired +import io.github.wykopmobilny.domain.errorhandling.KnownError import io.github.wykopmobilny.storage.api.LoggedUserInfo import io.github.wykopmobilny.storage.api.SessionStorage import io.github.wykopmobilny.storage.api.UserSession @@ -13,6 +17,8 @@ import retrofit2.HttpException import javax.inject.Inject internal suspend fun apiCall( + errorBodyParser: ErrorBodyParser, + onTwoFactorAuthorizationRequired: suspend () -> Unit, rawCall: suspend () -> ApiResponse, onUnauthorized: (suspend () -> Unit)?, ): FetcherResult { @@ -45,8 +51,25 @@ internal suspend fun apiCall( } .recoverCatching { failure -> if (failure is HttpException && failure.code() == 401) { - onUnauthorized?.invoke() ?: error("[${failure.code()}] ${failure.message()}") - retry() + val errorBody = failure.response()?.errorBody()?.let { errorBody -> errorBodyParser.parse(errorBody) } + + when (errorBody?.code) { + TwoFactorAuthorizationRequired -> { + onTwoFactorAuthorizationRequired() + throw KnownError.TwoFactorAuthorizationRequired("[${errorBody.code}] ${errorBody.messagePl}") + } + WykopApiCodes.InvalidUserKey, + WykopApiCodes.WrongUserSessionKey, + -> { + onUnauthorized?.invoke() ?: error("[${failure.code()}] ${failure.message()}") + retry() + } + else -> { + Napier.w(message = "Unsupported error code $errorBody") + onUnauthorized?.invoke() ?: error("[${failure.code()}] ${failure.message()}") + retry() + } + } } else { FetcherResult.Error.Exception(failure) } @@ -57,10 +80,13 @@ internal suspend fun apiCall( internal class ApiClient @Inject constructor( private val userSessionStorage: SessionStorage, private val userInfoStore: Store, + private val errorBodyParser: ErrorBodyParser, ) { suspend fun mutation(rawCall: suspend () -> ApiResponse): T { val result = apiCall( + errorBodyParser = errorBodyParser, + onTwoFactorAuthorizationRequired = { Napier.e("2FA not handled") }, rawCall = { rawCall() }, onUnauthorized = { val session = userSessionStorage.session.first() ?: error("Login required") @@ -78,6 +104,8 @@ internal class ApiClient @Inject constructor( fun fetcher(rawCall: suspend (TInput) -> ApiResponse) = Fetcher.ofResult { args -> apiCall( + errorBodyParser = errorBodyParser, + onTwoFactorAuthorizationRequired = { Napier.e("2FA not handled") }, rawCall = { rawCall(args) }, onUnauthorized = { val session = userSessionStorage.session.first() ?: error("Login required") @@ -90,4 +118,5 @@ internal class ApiClient @Inject constructor( object WykopApiCodes { const val InvalidUserKey = 11 const val WrongUserSessionKey = 12 + const val TwoFactorAuthorizationRequired = 1101 } diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/di/DomainComponent.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/di/DomainComponent.kt index 5e4f28c7b..b425e31fb 100644 --- a/domain/src/main/kotlin/io/github/wykopmobilny/domain/di/DomainComponent.kt +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/di/DomainComponent.kt @@ -21,6 +21,7 @@ import io.github.wykopmobilny.domain.settings.di.SettingsDomainComponent import io.github.wykopmobilny.domain.startup.AppConfig import io.github.wykopmobilny.domain.startup.InitializeApp import io.github.wykopmobilny.domain.styles.di.StylesDomainComponent +import io.github.wykopmobilny.domain.twofactor.di.TwoFactorAuthDomainComponent import io.github.wykopmobilny.domain.work.di.WorkDomainComponent import io.github.wykopmobilny.notification.NotificationsApi import io.github.wykopmobilny.storage.api.SettingsPreferencesApi @@ -64,7 +65,7 @@ interface DomainComponent { framework: Framework, applicationCache: ApplicationCache, work: WorkApi, - notifications: NotificationsApi + notifications: NotificationsApi, ): DomainComponent } @@ -90,5 +91,7 @@ interface DomainComponent { fun notifications(): NotificationsDomainComponent + fun twoFactor(): TwoFactorAuthDomainComponent + fun initializeApp(): InitializeApp } diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/di/StoresModule.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/di/StoresModule.kt index 01705dd7c..672221b18 100644 --- a/domain/src/main/kotlin/io/github/wykopmobilny/domain/di/StoresModule.kt +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/di/StoresModule.kt @@ -7,6 +7,8 @@ import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList import dagger.Module import dagger.Provides +import io.github.aakira.napier.Napier +import io.github.wykopmobilny.api.ErrorBodyParser import io.github.wykopmobilny.api.endpoints.LoginRetrofitApi import io.github.wykopmobilny.api.responses.LoginResponse import io.github.wykopmobilny.blacklist.api.ScraperRetrofitApi @@ -74,9 +76,12 @@ internal class StoresModule { retrofitApi: LoginRetrofitApi, storage: UserInfoStorage, appScopes: AppScopes, + errorBodyParser: ErrorBodyParser, ) = StoreBuilder.from( fetcher = Fetcher.ofResult { request: UserSession -> apiCall( + errorBodyParser = errorBodyParser, + onTwoFactorAuthorizationRequired = { Napier.e("2FA not handled") }, rawCall = { retrofitApi.getUserSessionToken(login = request.login, accountKey = request.token) }, onUnauthorized = null, ) diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/errorhandling/KnownError.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/errorhandling/KnownError.kt new file mode 100644 index 000000000..1c1b5e3c0 --- /dev/null +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/errorhandling/KnownError.kt @@ -0,0 +1,6 @@ +package io.github.wykopmobilny.domain.errorhandling + +sealed class KnownError : Throwable() { + + data class TwoFactorAuthorizationRequired(override val message: String) : KnownError() +} diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthDomainComponent.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthDomainComponent.kt new file mode 100644 index 000000000..f71b3d5eb --- /dev/null +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthDomainComponent.kt @@ -0,0 +1,7 @@ +package io.github.wykopmobilny.domain.twofactor.di + +import dagger.Subcomponent +import io.github.wykopmobilny.ui.twofactor.TwoFactorAuthDependencies + +@Subcomponent +interface TwoFactorAuthDomainComponent : TwoFactorAuthDependencies diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthScope.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthScope.kt new file mode 100644 index 000000000..1ae09018e --- /dev/null +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthScope.kt @@ -0,0 +1,6 @@ +package io.github.wykopmobilny.domain.twofactor.di + +import javax.inject.Scope + +@Scope +annotation class TwoFactorAuthScope diff --git a/ui/two-factor/android/build.gradle b/ui/two-factor/android/build.gradle new file mode 100644 index 000000000..9a9909235 --- /dev/null +++ b/ui/two-factor/android/build.gradle @@ -0,0 +1,19 @@ +plugins { + id("com.starter.library.android") + id("org.jetbrains.kotlin.kapt") +} + +android { + buildFeatures { + viewBinding true + } +} + +dependencies { + api(projects.ui.base.android) + api(projects.ui.twoFactor.api) + + implementation(projects.common.kotlinHelpers) + implementation(libs.dagger.core) + kapt(libs.dagger.compiler) +} diff --git a/ui/two-factor/android/src/main/kotlin/io/github/wykopmobilny/ui/twofactor/android/TwoFactorMainFragment.kt b/ui/two-factor/android/src/main/kotlin/io/github/wykopmobilny/ui/twofactor/android/TwoFactorMainFragment.kt new file mode 100644 index 000000000..5d6e4ef04 --- /dev/null +++ b/ui/two-factor/android/src/main/kotlin/io/github/wykopmobilny/ui/twofactor/android/TwoFactorMainFragment.kt @@ -0,0 +1,26 @@ +package io.github.wykopmobilny.ui.twofactor.android + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import io.github.wykopmobilny.ui.two_factor.android.R +import io.github.wykopmobilny.ui.two_factor.android.databinding.FragmentTwoFactorBinding +import io.github.wykopmobilny.ui.twofactor.TwoFactorAuthDependencies +import io.github.wykopmobilny.utils.InjectableViewModel +import io.github.wykopmobilny.utils.viewModelWrapperFactory + +fun twoFactorMainFragment(): Fragment = TwoFactorMainFragment() + +internal class TwoFactorMainFragment : Fragment(R.layout.fragment_two_factor) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val viewModel by viewModels> { + viewModelWrapperFactory(key = Unit) + } +// val getLinkDetails = viewModel.dependency.getLinkDetails() + + val binding = FragmentTwoFactorBinding.bind(view) + binding.toolbar.setNavigationOnClickListener { activity?.onBackPressed() } + } +} diff --git a/ui/two-factor/android/src/main/res/layout/fragment_two_factor.xml b/ui/two-factor/android/src/main/res/layout/fragment_two_factor.xml new file mode 100644 index 000000000..0a4289ad8 --- /dev/null +++ b/ui/two-factor/android/src/main/res/layout/fragment_two_factor.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/two-factor/android/src/main/res/values/strings.xml b/ui/two-factor/android/src/main/res/values/strings.xml new file mode 100644 index 000000000..a37b5d340 --- /dev/null +++ b/ui/two-factor/android/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Wymagane uwierzytelnienie + Wyglada na to że Twoje konto korzysta z uwierzytenienia dwuskładnikowego (2FA). Aby korzystać z aplikacji konieczne jest wprowadzenie kodu z aplikacji Google Authenticator + 6 cyfrowy kod + Weryfikuj + Otwórz Google Authenticator + diff --git a/ui/two-factor/api/build.gradle b/ui/two-factor/api/build.gradle new file mode 100644 index 000000000..6ba998361 --- /dev/null +++ b/ui/two-factor/api/build.gradle @@ -0,0 +1,8 @@ +plugins { + id("com.starter.library.kotlin") +} + +dependencies { + api(projects.ui.base.api) + api(libs.coroutines.core) +} diff --git a/ui/two-factor/api/src/main/kotlin/io/github/wykopmobilny/ui/twofactor/TwoFactorAuthDependencies.kt b/ui/two-factor/api/src/main/kotlin/io/github/wykopmobilny/ui/twofactor/TwoFactorAuthDependencies.kt new file mode 100644 index 000000000..942e7fe3a --- /dev/null +++ b/ui/two-factor/api/src/main/kotlin/io/github/wykopmobilny/ui/twofactor/TwoFactorAuthDependencies.kt @@ -0,0 +1,3 @@ +package io.github.wykopmobilny.ui.twofactor + +interface TwoFactorAuthDependencies From 03255e7e1d3b144d56b5a0a38ce7eb147fcc1ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Kwiecin=CC=81ski?= Date: Wed, 5 Jan 2022 22:51:41 +0100 Subject: [PATCH 3/5] 2fa UI --- .../androidTest/resources/2fa_success.json | 3 + app/src/main/AndroidManifest.xml | 2 + .../kotlin/io/github/wykopmobilny/WykopApp.kt | 31 +++++++ .../ui/adapters/LinkDetailsAdapter.kt | 2 +- .../ui/dialogs/ExceptionDialog.kt | 1 + app/src/main/res/values/strings.xml | 2 +- .../screenshots/BaseScreenshotTest.kt | 10 ++- .../api/endpoints/LoginRetrofitApi.kt | 3 +- .../domain/navigation/NavigationRequests.kt | 1 + .../twofactor/GetTwoFactorAuthDetailsQuery.kt | 81 ++++++++++++++++++ .../TwoFactorAuthViewStateStorage.kt | 22 +++++ .../di/TwoFactorAuthDomainComponent.kt | 14 ++- .../domain/utils/ResourceUtils.kt | 2 +- .../wykopmobilny/utils/ViewModelWrappers.kt | 26 +++++- .../utils/bindings/BuittonBinding.kt | 24 ++++++ .../utils/bindings/TextInputBinding.kt | 20 +++++ .../main/res/drawable/ic_open_external.xml | 12 +++ .../android/src/main/res/values/styles.xml | 46 ++++++++-- .../android/src/main/res/values/themes.xml | 2 + .../ui/base/components/ProgressButtonUi.kt | 7 ++ .../ui/base/components/TextInputUi.kt | 6 ++ .../links/details/LinkDetailsMainFragment.kt | 5 +- ui/two-factor/android/build.gradle | 1 + ...entTest_defaultState[Amoled]-1560xWrap.png | Bin 0 -> 92026 bytes ...gmentTest_defaultState[Dark]-1560xWrap.png | Bin 0 -> 90843 bytes ...mentTest_defaultState[Light]-1560xWrap.png | Bin 0 -> 94320 bytes ...entTest_withProgress[Amoled]-1560xWrap.png | Bin 0 -> 87506 bytes ...gmentTest_withProgress[Dark]-1560xWrap.png | Bin 0 -> 86272 bytes ...mentTest_withProgress[Light]-1560xWrap.png | Bin 0 -> 88548 bytes .../ui/settings/android/Factories.kt | 15 ++++ .../settings/android/TwoFactorFragmentTest.kt | 52 +++++++++++ .../android/TwoFactorMainFragment.kt | 21 ++++- .../main/res/layout/fragment_two_factor.xml | 23 ++--- .../android/src/main/res/values/strings.xml | 4 +- .../ui/twofactor/GetTwoFactorAuthDetails.kt | 15 ++++ .../ui/twofactor/TwoFactorAuthDependencies.kt | 5 +- 36 files changed, 417 insertions(+), 41 deletions(-) create mode 100644 app/src/androidTest/resources/2fa_success.json create mode 100644 domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/GetTwoFactorAuthDetailsQuery.kt create mode 100644 domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/TwoFactorAuthViewStateStorage.kt create mode 100644 ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/bindings/BuittonBinding.kt create mode 100644 ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/bindings/TextInputBinding.kt create mode 100644 ui/base/android/src/main/res/drawable/ic_open_external.xml create mode 100644 ui/base/api/src/main/kotlin/io/github/wykopmobilny/ui/base/components/ProgressButtonUi.kt create mode 100644 ui/base/api/src/main/kotlin/io/github/wykopmobilny/ui/base/components/TextInputUi.kt create mode 100644 ui/two-factor/android/screenshots/debug/io.github.wykopmobilny.ui.settings.android.TwoFactorFragmentTest_defaultState[Amoled]-1560xWrap.png create mode 100644 ui/two-factor/android/screenshots/debug/io.github.wykopmobilny.ui.settings.android.TwoFactorFragmentTest_defaultState[Dark]-1560xWrap.png create mode 100644 ui/two-factor/android/screenshots/debug/io.github.wykopmobilny.ui.settings.android.TwoFactorFragmentTest_defaultState[Light]-1560xWrap.png create mode 100644 ui/two-factor/android/screenshots/debug/io.github.wykopmobilny.ui.settings.android.TwoFactorFragmentTest_withProgress[Amoled]-1560xWrap.png create mode 100644 ui/two-factor/android/screenshots/debug/io.github.wykopmobilny.ui.settings.android.TwoFactorFragmentTest_withProgress[Dark]-1560xWrap.png create mode 100644 ui/two-factor/android/screenshots/debug/io.github.wykopmobilny.ui.settings.android.TwoFactorFragmentTest_withProgress[Light]-1560xWrap.png create mode 100644 ui/two-factor/android/src/androidTest/kotlin/io/github/wykopmobilny/ui/settings/android/Factories.kt create mode 100644 ui/two-factor/android/src/androidTest/kotlin/io/github/wykopmobilny/ui/settings/android/TwoFactorFragmentTest.kt create mode 100644 ui/two-factor/api/src/main/kotlin/io/github/wykopmobilny/ui/twofactor/GetTwoFactorAuthDetails.kt diff --git a/app/src/androidTest/resources/2fa_success.json b/app/src/androidTest/resources/2fa_success.json new file mode 100644 index 000000000..268c73f0e --- /dev/null +++ b/app/src/androidTest/resources/2fa_success.json @@ -0,0 +1,3 @@ +{ + "data": [] +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6237cb8fe..2dbed49c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -383,5 +383,7 @@ + + diff --git a/app/src/main/kotlin/io/github/wykopmobilny/WykopApp.kt b/app/src/main/kotlin/io/github/wykopmobilny/WykopApp.kt index 1882187c8..41db5b032 100755 --- a/app/src/main/kotlin/io/github/wykopmobilny/WykopApp.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/WykopApp.kt @@ -1,11 +1,15 @@ package io.github.wykopmobilny import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.os.Bundle import android.webkit.CookieManager import android.widget.Toast import androidx.core.app.ShareCompat +import androidx.core.net.toUri import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.jakewharton.threetenabp.AndroidThreeTen @@ -407,9 +411,36 @@ open class WykopApp : DaggerApplication(), ApplicationInjector, AppScopes { is InteropRequest.OpenYoutube -> context.startActivity(YoutubeActivity.createIntent(context, it.url)) is InteropRequest.ShowGif -> context.startActivity(PhotoViewActivity.createIntent(context, it.url)) is InteropRequest.ShowImage -> context.startActivity(PhotoViewActivity.createIntent(context, it.url)) + InteropRequest.OpenGoogleAuthenticator -> context.openApp("com.google.android.apps.authenticator2") } .run { } } } } + + private fun Activity.openApp(appId: String) { + if (isAppInstalled(appId)) { + startActivity(packageManager.getLaunchIntentForPackage(appId)) + } else { + openStoreListing(appId) + } + } + + private fun Activity.openStoreListing(appId: String) { + try { + startActivity(Intent(Intent.ACTION_VIEW, "market://details?id=$appId".toUri())) + } catch (ignored: ActivityNotFoundException) { + startActivity(Intent(Intent.ACTION_VIEW, "https://play.google.com/store/apps/details?id=$appId".toUri())) + } + } + + private fun Context.isAppInstalled(appId: String): Boolean = try { + packageManager.getPackageInfo(appId, PackageManager.GET_ACTIVITIES) + true + } catch (notFound: PackageManager.NameNotFoundException) { + false + } catch (throwable: Throwable) { + Napier.w(message = "Unexpected error when checking if app is installed", throwable) + false + } } diff --git a/app/src/main/kotlin/io/github/wykopmobilny/ui/adapters/LinkDetailsAdapter.kt b/app/src/main/kotlin/io/github/wykopmobilny/ui/adapters/LinkDetailsAdapter.kt index 1ee5e038c..8518f2704 100755 --- a/app/src/main/kotlin/io/github/wykopmobilny/ui/adapters/LinkDetailsAdapter.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/ui/adapters/LinkDetailsAdapter.kt @@ -140,7 +140,7 @@ class LinkDetailsAdapter @Inject constructor( } fun updateLinkComment(comment: LinkComment) { - val position = link!!.comments.indexOf(comment) + val position = link?.comments?.indexOf(comment)?.takeIf { it >= 0 } ?: return link!!.comments[position] = comment notifyItemChanged(commentsList.indexOf(comment) + 1) } diff --git a/app/src/main/kotlin/io/github/wykopmobilny/ui/dialogs/ExceptionDialog.kt b/app/src/main/kotlin/io/github/wykopmobilny/ui/dialogs/ExceptionDialog.kt index 8957aa282..8e5f78acc 100644 --- a/app/src/main/kotlin/io/github/wykopmobilny/ui/dialogs/ExceptionDialog.kt +++ b/app/src/main/kotlin/io/github/wykopmobilny/ui/dialogs/ExceptionDialog.kt @@ -24,6 +24,7 @@ fun Context.showExceptionDialog(throwable: Throwable) { .show() } when (throwable) { + is KnownError -> Napier.i("Known error", throwable) is SSLException -> Napier.e("SSL error", throwable) is IOException -> Napier.w("IO error", throwable) else -> Napier.e("Unknown error", throwable) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 726ec4d5c..8a10fa647 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -175,7 +175,7 @@ Duża nad tekstem Duża pod tekstem Otwórz w … - Otwórz 2FA + Uwierzytelnij Zapisz do mp4 Zapisano plik "Błąd podczas zapisu pliku" diff --git a/common/screenshot-test-helpers/src/main/kotlin/io/github/wykopmobilny/screenshots/BaseScreenshotTest.kt b/common/screenshot-test-helpers/src/main/kotlin/io/github/wykopmobilny/screenshots/BaseScreenshotTest.kt index b292256d9..bf734f378 100644 --- a/common/screenshot-test-helpers/src/main/kotlin/io/github/wykopmobilny/screenshots/BaseScreenshotTest.kt +++ b/common/screenshot-test-helpers/src/main/kotlin/io/github/wykopmobilny/screenshots/BaseScreenshotTest.kt @@ -23,6 +23,7 @@ import com.facebook.testing.screenshot.Screenshot import com.facebook.testing.screenshot.internal.TestNameDetector import com.google.android.material.appbar.AppBarLayout import com.google.android.material.resources.MaterialAttributes +import com.google.android.material.textfield.TextInputLayout import com.karumi.shot.ScreenshotTest import org.junit.Rule @@ -68,7 +69,7 @@ abstract class BaseScreenshotTest : ScreenshotTest { 0, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), ) - disableFlakyComponentsAndWaitForIdle(view = container) + container.disableFlakyComponents() do { container.doLayout(size) } while (container.isLayoutRequested) @@ -85,6 +86,11 @@ abstract class BaseScreenshotTest : ScreenshotTest { } } + private fun View.disableFlakyComponents() { + disableFlakyComponentsAndWaitForIdle(view = this) + allViews.filterIsInstance().forEach { it.isHintAnimationEnabled = false } + } + private fun recordScreenshot(view: View, name: String) { val snapshotName = "${TestNameDetector.getTestClass()}_$name" Screenshot @@ -101,7 +107,7 @@ abstract class BaseScreenshotTest : ScreenshotTest { } private fun View.doLayout(deviceSize: Size) { - disableFlakyComponentsAndWaitForIdle(this) + disableFlakyComponents() if (deviceSize.height <= 0) { guessUnboundedHeight(deviceSize.width) diff --git a/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/endpoints/LoginRetrofitApi.kt b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/endpoints/LoginRetrofitApi.kt index 8a21f3c3d..1a89ad357 100755 --- a/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/endpoints/LoginRetrofitApi.kt +++ b/data/wykop/api/src/main/kotlin/io/github/wykopmobilny/api/endpoints/LoginRetrofitApi.kt @@ -20,10 +20,9 @@ interface LoginRetrofitApi { @Field("accountkey", encoded = true) accountKey: String, ): WykopApiResponse - @FormUrlEncoded @POST("/login/2fa/appkey/$APP_KEY") suspend fun autorizeWith2FA( @Field("code") code: String, - ): WykopApiResponse + ): WykopApiResponse> } diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/navigation/NavigationRequests.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/navigation/NavigationRequests.kt index 163c77ab7..4ea5eec05 100644 --- a/domain/src/main/kotlin/io/github/wykopmobilny/domain/navigation/NavigationRequests.kt +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/navigation/NavigationRequests.kt @@ -40,6 +40,7 @@ sealed class InteropRequest { class ShowGif(val url: String) : InteropRequest() class OpenYoutube(val url: String) : InteropRequest() class OpenPlayer(val url: String) : InteropRequest() + object OpenGoogleAuthenticator : InteropRequest() } @Singleton diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/GetTwoFactorAuthDetailsQuery.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/GetTwoFactorAuthDetailsQuery.kt new file mode 100644 index 000000000..e744ac793 --- /dev/null +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/GetTwoFactorAuthDetailsQuery.kt @@ -0,0 +1,81 @@ +package io.github.wykopmobilny.domain.twofactor + +import io.github.wykopmobilny.api.endpoints.LoginRetrofitApi +import io.github.wykopmobilny.domain.api.ApiClient +import io.github.wykopmobilny.domain.navigation.AppRestarter +import io.github.wykopmobilny.domain.navigation.InteropRequest +import io.github.wykopmobilny.domain.navigation.InteropRequestsProvider +import io.github.wykopmobilny.domain.twofactor.di.TwoFactorAuthScope +import io.github.wykopmobilny.domain.utils.safe +import io.github.wykopmobilny.domain.utils.withResource +import io.github.wykopmobilny.ui.base.AppScopes +import io.github.wykopmobilny.ui.base.FailedAction +import io.github.wykopmobilny.ui.base.Resource +import io.github.wykopmobilny.ui.base.components.ErrorDialogUi +import io.github.wykopmobilny.ui.base.components.ProgressButtonUi +import io.github.wykopmobilny.ui.base.components.TextInputUi +import io.github.wykopmobilny.ui.twofactor.GetTwoFactorAuthDetails +import io.github.wykopmobilny.ui.twofactor.TwoFactorAuthDetailsUi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +internal class GetTwoFactorAuthDetailsQuery @Inject constructor( + private val appScopes: AppScopes, + private val viewStateStorage: TwoFactorAuthViewStateStorage, + private val api: ApiClient, + private val loginApi: LoginRetrofitApi, + private val interopRequestsProvider: InteropRequestsProvider, + private val appRestarter: AppRestarter, +) : GetTwoFactorAuthDetails { + + override fun invoke() = + viewStateStorage.state.map { viewState -> + TwoFactorAuthDetailsUi( + code = TextInputUi( + text = viewState.code, + onChanged = safeCallback { updated -> viewStateStorage.update { it.copy(code = updated) } }, + ), + verifyButton = if (viewState.generalResource.isLoading) { + ProgressButtonUi.Loading + } else { + ProgressButtonUi.Default( + onClicked = safeCallback { + withResource( + refresh = { send2FACode(viewState.code) }, + update = { resource -> viewStateStorage.update { it.copy(generalResource = resource) } }, + launch = { callback -> appScopes.safe(block = callback) }, + ) + }, + ) + }, + onOpenGoogleAuthenticatorClicked = safeCallback { interopRequestsProvider.request(InteropRequest.OpenGoogleAuthenticator) }, + errorDialog = viewState.generalResource.failedAction?.let { error -> + ErrorDialogUi( + error = error.cause, + retryAction = error.retryAction, + dismissAction = safeCallback { viewStateStorage.update { it.copy(generalResource = Resource.idle()) } }, + ) + }, + ) + } + + private suspend fun send2FACode(code: String) { + api.mutation { loginApi.autorizeWith2FA(code) } + appRestarter.restart() + } + + private fun safeCallback(function: suspend CoroutineScope.() -> Unit): () -> Unit = { + appScopes.safe { + runCatching { function() } + .onFailure { failure -> viewStateStorage.update { it.copy(generalResource = Resource.error(FailedAction(failure))) } } + } + } + + private fun safeCallback(function: suspend CoroutineScope.(T) -> Unit): (T) -> Unit = { + appScopes.safe { + runCatching { function(it) } + .onFailure { failure -> viewStateStorage.update { it.copy(generalResource = Resource.error(FailedAction(failure))) } } + } + } +} diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/TwoFactorAuthViewStateStorage.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/TwoFactorAuthViewStateStorage.kt new file mode 100644 index 000000000..7a1e80149 --- /dev/null +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/TwoFactorAuthViewStateStorage.kt @@ -0,0 +1,22 @@ +package io.github.wykopmobilny.domain.twofactor + +import io.github.wykopmobilny.domain.twofactor.di.TwoFactorAuthScope +import io.github.wykopmobilny.ui.base.Resource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@TwoFactorAuthScope +internal class TwoFactorAuthViewStateStorage @Inject constructor() { + + val state = MutableStateFlow(value = TwoFactorAuthViewState()) + + fun update(updater: (TwoFactorAuthViewState) -> TwoFactorAuthViewState) { + state.update(updater) + } +} + +data class TwoFactorAuthViewState( + val generalResource: Resource = Resource.idle(), + val code: String = "", +) diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthDomainComponent.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthDomainComponent.kt index f71b3d5eb..8714db316 100644 --- a/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthDomainComponent.kt +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/twofactor/di/TwoFactorAuthDomainComponent.kt @@ -1,7 +1,19 @@ package io.github.wykopmobilny.domain.twofactor.di +import dagger.Binds +import dagger.Module import dagger.Subcomponent +import io.github.wykopmobilny.domain.twofactor.GetTwoFactorAuthDetailsQuery +import io.github.wykopmobilny.ui.twofactor.GetTwoFactorAuthDetails import io.github.wykopmobilny.ui.twofactor.TwoFactorAuthDependencies -@Subcomponent +@TwoFactorAuthScope +@Subcomponent(modules = [TwoFactorAuthModule::class]) interface TwoFactorAuthDomainComponent : TwoFactorAuthDependencies + +@Module +internal abstract class TwoFactorAuthModule { + + @Binds + abstract fun GetTwoFactorAuthDetailsQuery.bind(): GetTwoFactorAuthDetails +} diff --git a/domain/src/main/kotlin/io/github/wykopmobilny/domain/utils/ResourceUtils.kt b/domain/src/main/kotlin/io/github/wykopmobilny/domain/utils/ResourceUtils.kt index 572b5ba40..9f1e44e95 100644 --- a/domain/src/main/kotlin/io/github/wykopmobilny/domain/utils/ResourceUtils.kt +++ b/domain/src/main/kotlin/io/github/wykopmobilny/domain/utils/ResourceUtils.kt @@ -17,7 +17,7 @@ internal suspend fun withResource( Resource.error( FailedAction( cause = failure, - retryAction = { launch { withResource(refresh, update) } }, + retryAction = { launch { withResource(refresh, update, launch) } }, ), ), ) diff --git a/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/ViewModelWrappers.kt b/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/ViewModelWrappers.kt index 22f8d9bec..18ebda8a1 100644 --- a/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/ViewModelWrappers.kt +++ b/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/ViewModelWrappers.kt @@ -6,16 +6,24 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -inline fun Fragment.viewModelWrapperFactory( +inline fun Fragment.viewModelWrapperFactory() = + object : ViewModelProvider.AndroidViewModelFactory(context?.applicationContext as Application) { + + @kotlin.Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class) = + viewModelWrapper(context?.applicationContext as Application) as T + } + +inline fun Fragment.viewModelWrapperFactoryKeyed( key: TKey, ) = object : ViewModelProvider.AndroidViewModelFactory(context?.applicationContext as Application) { @kotlin.Suppress("UNCHECKED_CAST") override fun create(modelClass: Class) = - viewModelWrapper(context?.applicationContext as Application, key) as T + viewModelWrapperKeyed(context?.applicationContext as Application, key) as T } -inline fun viewModelWrapper( +inline fun viewModelWrapperKeyed( application: Application, key: TKey, ) = object : InjectableViewModel(application) { @@ -28,6 +36,18 @@ inline fun viewModelWrapper( } } +inline fun viewModelWrapper( + application: Application, +) = object : InjectableViewModel(application) { + + override val dependency = getApplication().requireDependency() + + override fun onCleared() { + super.onCleared() + getApplication().destroyDependency() + } +} + abstract class InjectableViewModel( application: Application, ) : AndroidViewModel(application) { diff --git a/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/bindings/BuittonBinding.kt b/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/bindings/BuittonBinding.kt new file mode 100644 index 000000000..7eaa1f691 --- /dev/null +++ b/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/bindings/BuittonBinding.kt @@ -0,0 +1,24 @@ +package io.github.wykopmobilny.utils.bindings + +import android.widget.Button +import android.widget.ProgressBar +import androidx.core.view.isVisible +import io.github.wykopmobilny.ui.base.components.ProgressButtonUi +import kotlinx.coroutines.flow.Flow + +suspend fun Flow.collectProgressInput(button: Button, progress: ProgressBar) { + collect { buttonUi -> + when (buttonUi) { + ProgressButtonUi.Loading -> { + button.isVisible = false + progress.isVisible = true + button.setOnClick(null) + } + is ProgressButtonUi.Default -> { + button.isVisible = true + progress.isVisible = false + button.setOnClick(buttonUi.onClicked) + } + } + } +} diff --git a/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/bindings/TextInputBinding.kt b/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/bindings/TextInputBinding.kt new file mode 100644 index 000000000..08f841f1b --- /dev/null +++ b/ui/base/android/src/main/kotlin/io/github/wykopmobilny/utils/bindings/TextInputBinding.kt @@ -0,0 +1,20 @@ +package io.github.wykopmobilny.utils.bindings + +import android.widget.EditText +import androidx.core.widget.doAfterTextChanged +import io.github.wykopmobilny.ui.base.components.TextInputUi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +suspend fun Flow.collectUserInput(editText: EditText) = coroutineScope { + val state = stateIn(this) + editText.doAfterTextChanged { text -> + state.value.onChanged(text?.toString().orEmpty()) + } + state.map { it.text } + .filterNot { it == editText.text.toString() } + .collect(editText::setText) +} diff --git a/ui/base/android/src/main/res/drawable/ic_open_external.xml b/ui/base/android/src/main/res/drawable/ic_open_external.xml new file mode 100644 index 000000000..d6677ca31 --- /dev/null +++ b/ui/base/android/src/main/res/drawable/ic_open_external.xml @@ -0,0 +1,12 @@ + + + diff --git a/ui/base/android/src/main/res/values/styles.xml b/ui/base/android/src/main/res/values/styles.xml index 42fe47dbf..4cbda0b45 100644 --- a/ui/base/android/src/main/res/values/styles.xml +++ b/ui/base/android/src/main/res/values/styles.xml @@ -1,7 +1,8 @@ - @@ -9,11 +10,13 @@ @style/ThemeOverlay.App.Toolbar.Dark - - @@ -26,25 +29,52 @@ #00000000 - - - - - + + + + + + + + diff --git a/ui/base/android/src/main/res/values/themes.xml b/ui/base/android/src/main/res/values/themes.xml index a58ec46a6..387ed8aa7 100644 --- a/ui/base/android/src/main/res/values/themes.xml +++ b/ui/base/android/src/main/res/values/themes.xml @@ -63,6 +63,8 @@ #393939 16dp 1dp + @style/Widget.App.TextInputLayout + @style/Widget.App.Button.TextButton