From 0fd733849e7d4354b81610fd259ce4f0b588052e Mon Sep 17 00:00:00 2001 From: Filip Misztal Date: Fri, 6 Oct 2023 15:46:56 +0200 Subject: [PATCH] Feature - login prompt (#87) * feat: login prompt content provider implementation * refactor: content provider interaction logic moved to SessionInfoManager class * feat: fetching content provider authorities from the package manager; checking is session on the device exists * chore: refactored loginPrompt classes code style * refactor: move sessionInfoManager to sharedPreferencesStorage class * refactor: fix compatibility issues with PackageManager.MATCH_ALL * refactor: login prompt content provider refactor on xserxses comments * Add loginPromptManager (#73) * Add loginPromptManager * Add translations (#82) * call loginPrompt from Client (#84) * call loginPrompt from Client clean main activity Run requestLoginPrompt in background thread * Login promp tracking (#80) * Propose SchibstedAccountTracking public and internal API * Present tracking API in ExampleApp * Document API * More readable logging * Initial events for show/hide login prompt * Tracking events for clicks * Update events (#86) * Add final tracking events --------- Co-authored-by: filip-misztal Co-authored-by: bogdan-niculescu-sch <104439589+bogdan-niculescu-sch@users.noreply.github.com> * Fill in readme (#89) - apply outstanding review remark * Throw different error type if user cancels login (#83) * Throw different error type if user cancels login * Check error before state - throw NotAuthed.CancelledByUser error * Fix typo * Update webflows/src/main/java/com/schibsted/account/webflows/util/Util.kt Co-authored-by: Filip Misztal * Review remarks before merge (#90) * initial cleanup * make tracking thread safe - small review remarks * cleanup layout * code cleanup * add localized logos * Update logos * fix dialog showing check * apply review remark * Fix query period on content provider getSessions * Change DB primary key to packageName for content provider (#91) * Change db primary key to packagename for content provider * On conclict - replace with new values * user writable database for writting --------- Co-authored-by: filip-misztal * Use "use" to be more safe in case of failures + Nice syntax (#92) * Use use to be more safe * Even more idiomatic Kotlin --------- Co-authored-by: filip-misztal * Send cancel event on eid user cancel (#93) * Send cancel event on eid user cancel * Small Readme update * Login prompt crash (#94) * Pass intent via argument instead of whole client * Prevent adding twice --------- Co-authored-by: filip-misztal * add support for norsk bokmal and norsk nynorsk (#96) * add serverUrl to content provider query (#95) * check for local session before showing login prompt (#97) * check for local session before showing login prompt * apply review remark * Check also for presence - not only callback type (#98) Co-authored-by: filip-misztal * Dismiss prompt when login is initiated (#99) Co-authored-by: filip-misztal * Remove login promp on login click (#100) * Dismiss prompt when login is initiated * Better place * This is no longer needed --------- Co-authored-by: filip-misztal * add extra properties for events (#101) * add extra properties for events * Update readme and minor cleanup --------- Co-authored-by: wbaklazec-sch <105283956+wbaklazec-sch@users.noreply.github.com> Co-authored-by: bogdan-niculescu-sch <104439589+bogdan-niculescu-sch@users.noreply.github.com> Co-authored-by: filip-misztal --- README.md | 47 +++++++- app/build.gradle | 4 +- .../schibsted/account/example/ExampleApp.kt | 14 +++ .../schibsted/account/example/MainActivity.kt | 19 ++- app/src/main/res/layout/activity_main.xml | 1 + app/src/main/res/values/colors.xml | 4 +- app/src/main/res/values/styles.xml | 7 +- webflows/build.gradle | 16 ++- webflows/src/main/AndroidManifest.xml | 14 ++- .../webflows/activities/AuthResultLiveData.kt | 7 +- .../AuthorizationManagementActivity.kt | 3 + .../webflows/api/UserProfileResponse.kt | 2 +- .../account/webflows/client/Client.kt | 67 +++++++++-- .../loginPrompt/LoginPromptContentProvider.kt | 79 +++++++++++++ .../loginPrompt/LoginPromptFragment.kt | 108 ++++++++++++++++++ .../loginPrompt/LoginPromptManager.kt | 39 +++++++ .../loginPrompt/SessionInfoDatabase.kt | 79 +++++++++++++ .../loginPrompt/SessionInfoManager.kt | 70 ++++++++++++ .../webflows/persistence/SessionStorage.kt | 7 +- .../tracking/SchibstedAccountTracker.kt | 7 ++ .../tracking/SchibstedAccountTrackingEvent.kt | 42 +++++++ .../tracking/SchibstedAccountTrackingStore.kt | 43 +++++++ .../schibsted/account/webflows/util/Util.kt | 10 ++ .../res/drawable-fi/login_prompt_logos.xml | 63 ++++++++++ .../res/drawable-sv/login_prompt_logos.xml | 87 ++++++++++++++ .../main/res/drawable/login_prompt_logos.xml | 98 ++++++++++++++++ .../src/main/res/drawable/rounded_dialog.xml | 10 ++ .../src/main/res/drawable/rounded_layout.xml | 5 + webflows/src/main/res/layout/login_prompt.xml | 92 +++++++++++++++ webflows/src/main/res/values-da/strings.xml | 9 ++ webflows/src/main/res/values-fi/strings.xml | 9 ++ webflows/src/main/res/values-nb/strings.xml | 9 ++ webflows/src/main/res/values-nn/strings.xml | 9 ++ webflows/src/main/res/values-no/strings.xml | 9 ++ webflows/src/main/res/values-sv/strings.xml | 9 ++ webflows/src/main/res/values/colors.xml | 6 + webflows/src/main/res/values/strings.xml | 9 ++ webflows/src/main/res/values/styles.xml | 11 ++ 38 files changed, 1096 insertions(+), 28 deletions(-) create mode 100644 webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptContentProvider.kt create mode 100644 webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptFragment.kt create mode 100644 webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptManager.kt create mode 100644 webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/SessionInfoDatabase.kt create mode 100644 webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/SessionInfoManager.kt create mode 100644 webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTracker.kt create mode 100644 webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTrackingEvent.kt create mode 100644 webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTrackingStore.kt create mode 100644 webflows/src/main/res/drawable-fi/login_prompt_logos.xml create mode 100644 webflows/src/main/res/drawable-sv/login_prompt_logos.xml create mode 100644 webflows/src/main/res/drawable/login_prompt_logos.xml create mode 100644 webflows/src/main/res/drawable/rounded_dialog.xml create mode 100644 webflows/src/main/res/drawable/rounded_layout.xml create mode 100644 webflows/src/main/res/layout/login_prompt.xml create mode 100644 webflows/src/main/res/values-da/strings.xml create mode 100644 webflows/src/main/res/values-fi/strings.xml create mode 100644 webflows/src/main/res/values-nb/strings.xml create mode 100644 webflows/src/main/res/values-nn/strings.xml create mode 100644 webflows/src/main/res/values-no/strings.xml create mode 100644 webflows/src/main/res/values-sv/strings.xml create mode 100644 webflows/src/main/res/values/colors.xml create mode 100644 webflows/src/main/res/values/strings.xml create mode 100644 webflows/src/main/res/values/styles.xml diff --git a/README.md b/README.md index 6083c5dc..7e704c4d 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ New implementation of the Schibsted account Android SDK using the web flows via [Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs/overview/): * API documentation can be -found [here](https://pages.github.schibsted.io/spt-identity/account-sdk-android-web/). + found [here](https://pages.github.schibsted.io/spt-identity/account-sdk-android-web/). * An example implementation of the SDK can be found -[here](https://github.schibsted.io/spt-identity/account-sdk-android-web/tree/master/app/src/main/java/com/schibsted/account/example). + [here](https://github.schibsted.io/spt-identity/account-sdk-android-web/tree/master/app/src/main/java/com/schibsted/account/example). ## Getting started @@ -33,6 +33,7 @@ will help you create a client and configure the necessary data. The SDK is available via [Maven Central](https://search.maven.org/artifact/com.schibsted.account/account-sdk-android-web): + ``` implementation 'com.schibsted.account:account-sdk-android-web:' ``` @@ -217,7 +218,7 @@ by following these steps: .create() } ``` - + The SDK will automatically inject the user access token as a Bearer token in the HTTP Authorization request header. If the access token is rejected with a `401 Unauthorized` response (e.g. due to having expired), the SDK will try to use the refresh token to obtain a @@ -225,3 +226,43 @@ by following these steps: **Note:** If the refresh token request fails, due to the refresh token itself having expired or been invalidated by the user, the SDK will log the user out. + +#### Login prompt + +This is a light version of simplified-login, allowing mobile app developers integrating this +SDK to prompt users for log in if a valid session was already detected on the device. +This feature is making use of the single-sign on feature from web, allowing users to log in with +only two taps. + +**Note** that for this feature to work, both the app where the user has a valid session, and the app +that implements and +requests the login prompt need to use Android SDK Web version 6.1.0 or newer. + +**Note** that it is the calling app's responsibility to request the login prompt only if the user is +not +already logged in, or if any other specific conditions are met. + +Example: + +```kotlin +if (!user?.isLoggedIn()) { + lifecycleScope.launch { + ExampleApp.client.requestLoginPrompt(applicationContext, supportFragmentManager, true) + } +} +``` + +#### Pulse tracking + +We have also added a way of integrating the app's Pulse instance into the SDK allowing internal +events to be sent. + +**Important:** When integrating with the SDK defined events, please keep in mind that the Pulse +instance needs to map `[SchibstedAccountTrackingEvent].providerComponent` to `provider.component` +and `[SchibstedAccountTrackingEvent].deployTag` to `deploy_tag` before pushing the created events. + +This is a temporary solution and will be subject to changes in the future, but in the meanwhile +you can use the provided example from ExampleApp to connect your Pulse event transmitter, which +will be then used internally to track login-prompt flows and also send events if the login was +successful or not. + diff --git a/app/build.gradle b/app/build.gradle index 09d2346b..99e31a3b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,12 +2,12 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' android { - compileSdkVersion 32 + compileSdkVersion 34 defaultConfig { applicationId "com.schibsted.account" minSdkVersion 21 - targetSdkVersion 30 + targetSdkVersion 32 versionCode 1 versionName "1.0" diff --git a/app/src/main/java/com/schibsted/account/example/ExampleApp.kt b/app/src/main/java/com/schibsted/account/example/ExampleApp.kt index 129c1cd7..c83165b5 100644 --- a/app/src/main/java/com/schibsted/account/example/ExampleApp.kt +++ b/app/src/main/java/com/schibsted/account/example/ExampleApp.kt @@ -11,6 +11,9 @@ import com.schibsted.account.example.MainActivity.Companion.LOGIN_FAILED_EXTRA import com.schibsted.account.webflows.activities.AuthorizationManagementActivity import com.schibsted.account.webflows.client.Client import com.schibsted.account.webflows.client.ClientConfiguration +import com.schibsted.account.webflows.tracking.SchibstedAccountTrackerStore +import com.schibsted.account.webflows.tracking.SchibstedAccountTrackingEvent +import com.schibsted.account.webflows.tracking.SchibstedAccountTrackingListener import timber.log.Timber class ExampleApp : Application() { @@ -22,6 +25,17 @@ class ExampleApp : Application() { initManualClient() initAuthorizationManagement() initTimber() + initTracking() + } + + private fun initTracking() { + val listener = object : SchibstedAccountTrackingListener { + override fun onEvent(event: SchibstedAccountTrackingEvent) { + Timber.d("Tracked event ${event::class.simpleName.toString()}") + } + } + + SchibstedAccountTrackerStore.addTrackingListener(listener) } private fun initTimber() { diff --git a/app/src/main/java/com/schibsted/account/example/MainActivity.kt b/app/src/main/java/com/schibsted/account/example/MainActivity.kt index 6fb297cb..07cfabf1 100644 --- a/app/src/main/java/com/schibsted/account/example/MainActivity.kt +++ b/app/src/main/java/com/schibsted/account/example/MainActivity.kt @@ -5,11 +5,13 @@ import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope import com.schibsted.account.databinding.ActivityMainBinding import com.schibsted.account.webflows.activities.AuthResultLiveData import com.schibsted.account.webflows.activities.NotAuthed import com.schibsted.account.webflows.user.User import com.schibsted.account.webflows.util.Either +import kotlinx.coroutines.launch import timber.log.Timber class MainActivity : AppCompatActivity() { @@ -19,7 +21,6 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) if (intent.getBooleanExtra(LOGIN_FAILED_EXTRA, false)) { @@ -31,6 +32,14 @@ class MainActivity : AppCompatActivity() { observeAuthResultLiveData() } + override fun onResume() { + super.onResume() + + lifecycleScope.launch { + ExampleApp.client.requestLoginPrompt(applicationContext, supportFragmentManager, true) + } + } + private fun initializeButtons() { binding.loginButton.setOnClickListener { @@ -73,7 +82,13 @@ class MainActivity : AppCompatActivity() { } private fun startLoggedInActivity(user: User) { - startActivity(LoggedInActivity.intentWithUser(this, user, LoggedInActivity.Companion.Flow.AUTOMATIC)) + startActivity( + LoggedInActivity.intentWithUser( + this, + user, + LoggedInActivity.Companion.Flow.AUTOMATIC + ) + ) } companion object { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index cdf50696..1c631d51 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -25,4 +25,5 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="@id/loginButton" /> + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 4faecfa8..67ca5909 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,6 @@ - #6200EE - #3700B3 + #3274D4 + #2196F3 #03DAC5 \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index fac92916..5799a5d4 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,10 +1,15 @@ - + + \ No newline at end of file diff --git a/webflows/build.gradle b/webflows/build.gradle index 13b5eda4..c14c1573 100644 --- a/webflows/build.gradle +++ b/webflows/build.gradle @@ -6,14 +6,15 @@ apply plugin: 'maven-publish' apply plugin: 'signing' android { - compileSdkVersion 32 + compileSdkVersion 34 defaultConfig { minSdkVersion 21 - targetSdkVersion 30 + targetSdkVersion 33 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" + resConfigs "en", "sv", "no", "nn", "nb", "fi", "dk" } compileOptions { @@ -21,6 +22,10 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + buildFeatures { + viewBinding true + } + buildTypes { release { minifyEnabled false @@ -60,15 +65,20 @@ dependencies { api "androidx.browser:browser:1.4.0" implementation 'com.nimbusds:nimbus-jose-jwt:9.24.3' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0' //Logging implementation "com.jakewharton.timber:timber:$timber_version" + //Login prompt + implementation 'androidx.constraintlayout:constraintlayout:2.2.0-alpha12' + implementation 'com.google.android.material:material:1.9.0' + testImplementation 'junit:junit:4.13.2' testImplementation "io.mockk:mockk:${mockkVersion}" testImplementation "com.squareup.okhttp3:mockwebserver:${okHttpVersion}" - testImplementation 'org.robolectric:robolectric:4.8.2' + testImplementation 'org.robolectric:robolectric:4.9.1' testImplementation 'androidx.test.ext:junit:1.1.3' testImplementation "androidx.test.espresso:espresso-core:${espressoVersion}" testImplementation "androidx.test.espresso:espresso-intents:${espressoVersion}" diff --git a/webflows/src/main/AndroidManifest.xml b/webflows/src/main/AndroidManifest.xml index 81548162..b1a154cc 100644 --- a/webflows/src/main/AndroidManifest.xml +++ b/webflows/src/main/AndroidManifest.xml @@ -4,6 +4,15 @@ + + + + + + + + - \ No newline at end of file + diff --git a/webflows/src/main/java/com/schibsted/account/webflows/activities/AuthResultLiveData.kt b/webflows/src/main/java/com/schibsted/account/webflows/activities/AuthResultLiveData.kt index 75a734af..8b4f458b 100644 --- a/webflows/src/main/java/com/schibsted/account/webflows/activities/AuthResultLiveData.kt +++ b/webflows/src/main/java/com/schibsted/account/webflows/activities/AuthResultLiveData.kt @@ -5,6 +5,8 @@ import androidx.annotation.MainThread import androidx.lifecycle.LiveData import com.schibsted.account.webflows.client.Client import com.schibsted.account.webflows.client.LoginError +import com.schibsted.account.webflows.tracking.SchibstedAccountTracker +import com.schibsted.account.webflows.tracking.SchibstedAccountTrackingEvent import com.schibsted.account.webflows.user.User import com.schibsted.account.webflows.util.Either import com.schibsted.account.webflows.util.Either.Left @@ -61,7 +63,10 @@ class AuthResultLiveData private constructor(private val client: Client) : is Left -> update( Left( when (result.value) { - is LoginError.CancelledByUser -> NotAuthed.CancelledByUser + is LoginError.CancelledByUser -> { + SchibstedAccountTracker.track(SchibstedAccountTrackingEvent.UserLoginCanceled) + NotAuthed.CancelledByUser + } else -> NotAuthed.LoginFailed(result.value) } ) diff --git a/webflows/src/main/java/com/schibsted/account/webflows/activities/AuthorizationManagementActivity.kt b/webflows/src/main/java/com/schibsted/account/webflows/activities/AuthorizationManagementActivity.kt index bc44adfd..182ba46c 100644 --- a/webflows/src/main/java/com/schibsted/account/webflows/activities/AuthorizationManagementActivity.kt +++ b/webflows/src/main/java/com/schibsted/account/webflows/activities/AuthorizationManagementActivity.kt @@ -34,6 +34,8 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import com.schibsted.account.webflows.client.Client +import com.schibsted.account.webflows.tracking.SchibstedAccountTracker +import com.schibsted.account.webflows.tracking.SchibstedAccountTrackingEvent import com.schibsted.account.webflows.util.Either.Left import timber.log.Timber @@ -177,6 +179,7 @@ class AuthorizationManagementActivity : Activity() { private fun handleAuthorizationCanceled() { Timber.d("Authorization flow canceled by user") + SchibstedAccountTracker.track(SchibstedAccountTrackingEvent.UserLoginCanceled) AuthResultLiveData.get().update(Left(NotAuthed.CancelledByUser)) cancelIntent?.send() } diff --git a/webflows/src/main/java/com/schibsted/account/webflows/api/UserProfileResponse.kt b/webflows/src/main/java/com/schibsted/account/webflows/api/UserProfileResponse.kt index 3bcb7822..6e2e4f30 100644 --- a/webflows/src/main/java/com/schibsted/account/webflows/api/UserProfileResponse.kt +++ b/webflows/src/main/java/com/schibsted/account/webflows/api/UserProfileResponse.kt @@ -92,7 +92,7 @@ data class Address( @SerializedName("invoice") INVOICE; - override fun toString(): String = super.toString().toLowerCase(Locale.ROOT) + override fun toString(): String = super.toString().lowercase(Locale.ROOT) } } diff --git a/webflows/src/main/java/com/schibsted/account/webflows/client/Client.kt b/webflows/src/main/java/com/schibsted/account/webflows/client/Client.kt index 4c03b420..fa5c68e5 100644 --- a/webflows/src/main/java/com/schibsted/account/webflows/client/Client.kt +++ b/webflows/src/main/java/com/schibsted/account/webflows/client/Client.kt @@ -4,10 +4,13 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent -import androidx.browser.customtabs.CustomTabsService +import androidx.fragment.app.FragmentManager import com.schibsted.account.webflows.activities.AuthorizationManagementActivity import com.schibsted.account.webflows.api.HttpError import com.schibsted.account.webflows.api.SchibstedAccountApi +import com.schibsted.account.webflows.loginPrompt.LoginPromptConfig +import com.schibsted.account.webflows.loginPrompt.LoginPromptManager +import com.schibsted.account.webflows.loginPrompt.SessionInfoManager import com.schibsted.account.webflows.persistence.EncryptedSharedPrefsStorage import com.schibsted.account.webflows.persistence.MigratingSessionStorage import com.schibsted.account.webflows.persistence.SessionStorage @@ -17,18 +20,23 @@ import com.schibsted.account.webflows.persistence.StorageError import com.schibsted.account.webflows.token.TokenError import com.schibsted.account.webflows.token.TokenHandler import com.schibsted.account.webflows.token.UserTokens +import com.schibsted.account.webflows.tracking.SchibstedAccountTracker +import com.schibsted.account.webflows.tracking.SchibstedAccountTrackingEvent import com.schibsted.account.webflows.user.StoredUserSession import com.schibsted.account.webflows.user.User import com.schibsted.account.webflows.util.Either import com.schibsted.account.webflows.util.Either.Left import com.schibsted.account.webflows.util.Either.Right import com.schibsted.account.webflows.util.Util +import com.schibsted.account.webflows.util.Util.isCustomTabsSupported +import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import org.json.JSONException import org.json.JSONObject import timber.log.Timber import java.util.Date +import kotlin.coroutines.resume /** Represents a client registered with Schibsted account. */ class Client { @@ -53,7 +61,8 @@ class Client { stateStorage = StateStorage(context.applicationContext) val encryptedStorage = EncryptedSharedPrefsStorage(context.applicationContext) - val sharedPrefsStorage = SharedPrefsStorage(context.applicationContext) + val sharedPrefsStorage = + SharedPrefsStorage(context.applicationContext, configuration.serverUrl.toString()) sessionStorage = MigratingSessionStorage( newStorage = sharedPrefsStorage, @@ -98,7 +107,7 @@ class Client { authRequest: AuthRequest = AuthRequest() ): Intent { val loginUrl = generateLoginUrl(authRequest) - val intent: Intent = if (this.isCustomTabsSupported(context)) { + val intent: Intent = if (isCustomTabsSupported(context)) { buildCustomTabsIntent() .apply { intent.data = loginUrl @@ -117,7 +126,7 @@ class Client { @JvmOverloads fun launchAuth(context: Context, authRequest: AuthRequest = AuthRequest()) { val loginUrl = generateLoginUrl(authRequest) - if (this.isCustomTabsSupported(context)) { + if (isCustomTabsSupported(context)) { buildCustomTabsIntent().launchUrl(context, loginUrl) } else { val intent = Intent(Intent.ACTION_VIEW, loginUrl).addCategory(Intent.CATEGORY_BROWSABLE) @@ -136,13 +145,6 @@ class Client { return Uri.parse(loginUrl) } - private fun isCustomTabsSupported(context: Context): Boolean { - val serviceIntent = Intent(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION) - val resolveInfos = context.packageManager.queryIntentServices(serviceIntent, 0) - - return !resolveInfos.isEmpty() - } - /** * Call this with the intent received via deep link to complete the login flow. * @@ -178,6 +180,7 @@ class Client { if (stored.state != authResponse["state"]) { callback(Left(LoginError.UnsolicitedResponse)) + SchibstedAccountTracker.track(SchibstedAccountTrackingEvent.UserLoginFailed) return } @@ -195,10 +198,12 @@ class Client { storedUserSession .onSuccess { session -> sessionStorage.save(session) + SchibstedAccountTracker.track(SchibstedAccountTrackingEvent.UserLoginSuccessful) callback(Right(User(this, session.userTokens))) } .onFailure { err -> Timber.d("Token error response: $err") + SchibstedAccountTracker.track(SchibstedAccountTrackingEvent.UserLoginFailed) val oauthError = err.toOauthError() if (oauthError != null) { callback(Left(LoginError.TokenErrorResponse(oauthError))) @@ -283,6 +288,46 @@ class Client { } } + /** + * Show native login prompt if user already has a valid session on device and if no user session is found in the app. + * + * @param supportFragmentManager Activity's Fragment manager. + * @param isCancelable set if loginPrompt should be cancelable by user. + */ + @JvmOverloads + suspend fun requestLoginPrompt( + context: Context, + supportFragmentManager: FragmentManager, + isCancelable: Boolean = true + ) { + val internalSessionFound = hasSessionStorage(configuration.clientId) + + if (!internalSessionFound && userHasSessionOnDevice(context.applicationContext)) { + LoginPromptManager( + LoginPromptConfig( + this.getAuthenticationIntent(context), + isCancelable + ) + ).showLoginPromptIfAbsent(supportFragmentManager) + } + } + + private suspend fun hasSessionStorage(clientId: String) = + suspendCancellableCoroutine { continuation -> + sessionStorage.get(clientId) { result -> + result + .onSuccess { continuation.resume(it != null) } + .onFailure { continuation.resume(false) } + } + } + + private suspend fun userHasSessionOnDevice(context: Context): Boolean { + return SessionInfoManager( + context, + configuration.serverUrl.toString() + ).isUserLoggedInOnTheDevice() + } + internal companion object { const val AUTH_STATE_KEY = "AuthState" } diff --git a/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptContentProvider.kt b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptContentProvider.kt new file mode 100644 index 00000000..63a9b5b0 --- /dev/null +++ b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptContentProvider.kt @@ -0,0 +1,79 @@ +package com.schibsted.account.webflows.loginPrompt + +import android.content.ContentProvider +import android.content.ContentUris +import android.content.ContentValues +import android.content.UriMatcher +import android.database.Cursor +import android.database.sqlite.SQLiteException +import android.net.Uri +import com.schibsted.account.webflows.tracking.SchibstedAccountTracker +import com.schibsted.account.webflows.tracking.SchibstedAccountTrackingEvent + +internal class LoginPromptContentProvider : ContentProvider() { + + private lateinit var uriMatcher: UriMatcher + private lateinit var db: SessionInfoDatabase + private lateinit var contentURI: Uri + private val uriCode = 1 + + override fun getType(uri: Uri): String { + return when (uriMatcher.match(uri)) { + uriCode -> "vnd.android.cursor.dir/sessions" + else -> throw IllegalArgumentException("Unsupported URI: $uri") + } + } + + override fun onCreate(): Boolean { + context?.let { db = SessionInfoDatabase(it) } ?: return false + + val providerName = "${context?.packageName}.contentprovider" + val providerUrl = "content://$providerName/sessions" + + contentURI = Uri.parse(providerUrl) + uriMatcher = UriMatcher(UriMatcher.NO_MATCH) + uriMatcher.addURI( + providerName, "sessions", uriCode + ) + + return db != null + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? { + if (uriMatcher.match(uri) != uriCode) { + throw IllegalArgumentException("Unknown URI $uri") + } + return db?.getSessions(selectionArgs?.first() as String) + } + + override fun insert(uri: Uri, values: ContentValues?): Uri { + val rowId = db?.saveSessionTimestamp(values?.get("packageName") as String, values?.get("serverUrl") as String) + if (rowId != null) { + val uri: Uri = ContentUris.withAppendedId(contentURI, rowId) + context!!.contentResolver.notifyChange(uri, null) + SchibstedAccountTracker.track(SchibstedAccountTrackingEvent.LoginPromptContentProviderInsert) + return uri + } + throw SQLiteException("Failed to add a record into $uri") + } + + override fun update( + uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array? + ): Int { + return 0 + } + + override fun delete( + uri: Uri, selection: String?, selectionArgs: Array? + ): Int { + val rowsAffected = db?.clearSessionsForPackage(selectionArgs?.first() as String) + SchibstedAccountTracker.track(SchibstedAccountTrackingEvent.LoginPromptContentProviderDelete) + return rowsAffected ?: 0 + } +} diff --git a/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptFragment.kt b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptFragment.kt new file mode 100644 index 00000000..f0bdcfbc --- /dev/null +++ b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptFragment.kt @@ -0,0 +1,108 @@ +package com.schibsted.account.webflows.loginPrompt + +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.browser.customtabs.CustomTabsIntent +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.schibsted.account.webflows.R +import com.schibsted.account.webflows.databinding.LoginPromptBinding +import com.schibsted.account.webflows.tracking.SchibstedAccountTracker +import com.schibsted.account.webflows.tracking.SchibstedAccountTrackingEvent.* +import com.schibsted.account.webflows.util.Util +import kotlinx.coroutines.launch + +internal class LoginPromptFragment : BottomSheetDialogFragment() { + private var _binding: LoginPromptBinding? = null + private val binding get() = _binding!! + + private lateinit var loginPromptConfig: LoginPromptConfig + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + loginPromptConfig = requireArguments().getParcelable(ARG_CONFIG)!! + setStyle(STYLE_NORMAL, R.style.LoginPromptDialog) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + isCancelable = loginPromptConfig.isCancelable + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + super.onCreateView(inflater, container, savedInstanceState) + _binding = LoginPromptBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initializeButtons() + SchibstedAccountTracker.track(LoginPromptCreated) + } + + override fun onStart() { + super.onStart() + SchibstedAccountTracker.track(LoginPromptView) + } + + override fun onStop() { + super.onStop() + SchibstedAccountTracker.track(LoginPromptLeave) + } + + override fun onDestroyView() { + _binding = null + SchibstedAccountTracker.track(LoginPromptDestroyed) + super.onDestroyView() + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + SchibstedAccountTracker.track(LoginPromptClickOutside) + } + + private fun initializeButtons() { + binding.loginPromptAuth.setOnClickListener { + dismiss() + startActivity(loginPromptConfig.authIntent) + SchibstedAccountTracker.track(LoginPromptClickToLogin) + } + binding.loginPromptSkip.setOnClickListener { + SchibstedAccountTracker.track(LoginPromptClickToContinueWithoutLogin) + dismiss() + } + + binding.loginPromptPrivacy.setOnClickListener { + var loginPromptContext = this.requireContext() + val uri = Uri.parse(getString(R.string.login_prompt_privacy_url)) + if (Util.isCustomTabsSupported(loginPromptContext)) { + CustomTabsIntent.Builder().build().launchUrl(loginPromptContext, uri) + } else { + startActivity( + Intent( + Intent.ACTION_VIEW, + uri + ).addCategory(Intent.CATEGORY_BROWSABLE) + ) + } + } + } + + companion object { + const val ARG_CONFIG = "LOGIN_PROMPT_CONFIG_ARG" + } + +} diff --git a/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptManager.kt b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptManager.kt new file mode 100644 index 00000000..efaae218 --- /dev/null +++ b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/LoginPromptManager.kt @@ -0,0 +1,39 @@ +package com.schibsted.account.webflows.loginPrompt + +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import androidx.fragment.app.FragmentManager +import kotlinx.parcelize.Parcelize + + +@Parcelize +internal data class LoginPromptConfig( + val authIntent: Intent, + val isCancelable: Boolean = true +) : Parcelable + +internal class LoginPromptManager(private val loginPromptConfig: LoginPromptConfig) { + private val fragmentTag = "schibsted_account_login_prompt" + + /** + * Show login prompt. + * + * @param supportFragmentManager Calling entity's fragment manager. + */ + fun showLoginPromptIfAbsent(supportFragmentManager: FragmentManager) { + val loginPromptFragment = + supportFragmentManager.findFragmentByTag(fragmentTag) as? LoginPromptFragment + + if (loginPromptFragment == null) { + initializeLoginPrompt(loginPromptConfig).show(supportFragmentManager, fragmentTag) + } + } + + private fun initializeLoginPrompt(config: LoginPromptConfig): LoginPromptFragment = + LoginPromptFragment().apply { + arguments = Bundle().apply { + putParcelable(LoginPromptFragment.ARG_CONFIG, config) + } + } +} \ No newline at end of file diff --git a/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/SessionInfoDatabase.kt b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/SessionInfoDatabase.kt new file mode 100644 index 00000000..00865d3a --- /dev/null +++ b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/SessionInfoDatabase.kt @@ -0,0 +1,79 @@ +package com.schibsted.account.webflows.loginPrompt + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import java.util.Date + + +internal class SessionInfoDatabase(context: Context) : SQLiteOpenHelper( + context, + DATABASE_NAME, + null, + DATABASE_VERSION +) { + companion object { + const val DATABASE_NAME = "SessionInfoDB" + const val TABLE_NAME = "Sessions" + const val DATABASE_VERSION = 1 + const val CREATE_DB_TABLE = """ + CREATE TABLE IF NOT EXISTS $TABLE_NAME ( + packageName STRING PRIMARY KEY, + timestamp INTEGER NOT NULL, + serverUrl STRING NOT NULL + ) WITHOUT ROWID;""" + } + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(CREATE_DB_TABLE) + } + + override fun onUpgrade( + db: SQLiteDatabase, + oldVersion: Int, + newVersion: Int + ) { + db.execSQL("DROP TABLE IF EXISTS $TABLE_NAME") + onCreate(db) + } + + fun saveSessionTimestamp(packageName: String, serverUrl: String): Long { + val values = ContentValues().apply { + put("packageName", packageName) + put("timestamp", Date().time) + put("serverUrl", serverUrl) + } + return this.writableDatabase.insertWithOnConflict( + TABLE_NAME, + null, + values, + SQLiteDatabase.CONFLICT_REPLACE, + ) + } + + fun getSessions(serverUrl: String): Cursor? { + val projection = arrayOf("packageName", "timestamp", "serverUrl") + val selection = "timestamp > ? AND serverUrl = ?" + //get sessions the last year period + val oneYearInMilliseconds = 365 * 24 * 60 * 60 * 1000 + val arguments = arrayOf("${Date().time - (oneYearInMilliseconds)}", "${serverUrl}") + val sortOrder = "timestamp DESC" + return this.readableDatabase.query( + TABLE_NAME, + projection, + selection, + arguments, + null, + null, + sortOrder + ) + } + + fun clearSessionsForPackage(packageName: String): Int { + val selection = "packageName LIKE ?" + val arguments = arrayOf(packageName) + return this.readableDatabase.delete(TABLE_NAME, selection, arguments) + } +} diff --git a/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/SessionInfoManager.kt b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/SessionInfoManager.kt new file mode 100644 index 00000000..c658d7de --- /dev/null +++ b/webflows/src/main/java/com/schibsted/account/webflows/loginPrompt/SessionInfoManager.kt @@ -0,0 +1,70 @@ +package com.schibsted.account.webflows.loginPrompt + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Build + + +internal class SessionInfoManager(context: Context, serverUrl: String) { + private val contentResolver = context.contentResolver + private val packageName = context.packageName + private val packageManager = context.packageManager + private val serverUrl = serverUrl + + fun save() { + contentResolver.insert( + Uri.parse("content://${packageName}.contentprovider/sessions"), + ContentValues().apply { + put("packageName", packageName) + put("serverUrl", serverUrl) + }) + } + + fun clear() { + contentResolver.delete( + Uri.parse("content://${packageName}.contentprovider/sessions"), + null, + arrayOf(packageName) + ) + } + + private fun isSessionPresent(authority: String): Boolean { + return contentResolver.query( + Uri.parse("content://${authority}/sessions"), + null, + null, + arrayOf(serverUrl), + null + )?.use { + it.count > 0 + } ?: false + } + + suspend fun isUserLoggedInOnTheDevice(): Boolean { + val contentProviders: List + val intent = Intent("com.schibsted.account.LOGIN_PROMPT_CONTENT_PROVIDER") + contentProviders = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> packageManager.queryIntentContentProviders( + intent, + PackageManager.ResolveInfoFlags.of(0) + ) + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> packageManager.queryIntentContentProviders( + intent, + PackageManager.MATCH_ALL + ) + + else -> packageManager.queryIntentContentProviders(intent, 0) + } + for (contentProvider in contentProviders) { + if (isSessionPresent(contentProvider.providerInfo.authority)) { + return true + } + } + return false + } +} diff --git a/webflows/src/main/java/com/schibsted/account/webflows/persistence/SessionStorage.kt b/webflows/src/main/java/com/schibsted/account/webflows/persistence/SessionStorage.kt index 5e22a3e7..1b683907 100644 --- a/webflows/src/main/java/com/schibsted/account/webflows/persistence/SessionStorage.kt +++ b/webflows/src/main/java/com/schibsted/account/webflows/persistence/SessionStorage.kt @@ -7,6 +7,7 @@ import androidx.security.crypto.MasterKey import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonSyntaxException +import com.schibsted.account.webflows.loginPrompt.SessionInfoManager import com.schibsted.account.webflows.user.StoredUserSession import com.schibsted.account.webflows.util.Either import timber.log.Timber @@ -72,6 +73,7 @@ internal class MigratingSessionStorage( internal class EncryptedSharedPrefsStorage(context: Context) : SessionStorage { private val gson = GsonBuilder().setDateFormat("MM dd, yyyy HH:mm:ss").create() + private val prefs: SharedPreferences? by lazy { val masterKey = MasterKey.Builder(context.applicationContext) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) @@ -139,15 +141,17 @@ internal class EncryptedSharedPrefsStorage(context: Context) : SessionStorage { } } -internal class SharedPrefsStorage(context: Context) : SessionStorage { +internal class SharedPrefsStorage(context: Context, serverUrl: String) : SessionStorage { private val gson = GsonBuilder().setDateFormat("MM dd, yyyy HH:mm:ss").create() private val prefs = context.getSharedPreferences(PREFERENCE_FILENAME, Context.MODE_PRIVATE) + private val sessionInfoManager = SessionInfoManager(context, serverUrl) override fun save(session: StoredUserSession) { val editor = prefs.edit() editor.putString(session.clientId, gson.toJson(session)) editor.apply() + sessionInfoManager.save() } override fun get(clientId: String, callback: StorageReadCallback) { @@ -159,6 +163,7 @@ internal class SharedPrefsStorage(context: Context) : SessionStorage { val editor = prefs.edit() editor.remove(clientId) editor.apply() + sessionInfoManager.clear() } companion object { diff --git a/webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTracker.kt b/webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTracker.kt new file mode 100644 index 00000000..c25f8ef1 --- /dev/null +++ b/webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTracker.kt @@ -0,0 +1,7 @@ +package com.schibsted.account.webflows.tracking + +internal object SchibstedAccountTracker { + internal fun track(event: SchibstedAccountTrackingEvent) { + SchibstedAccountTrackerStore.notifyListeners(event) + } +} \ No newline at end of file diff --git a/webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTrackingEvent.kt b/webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTrackingEvent.kt new file mode 100644 index 00000000..b498214f --- /dev/null +++ b/webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTrackingEvent.kt @@ -0,0 +1,42 @@ +package com.schibsted.account.webflows.tracking + +import com.schibsted.account.webflows.BuildConfig + +const val PACKAGE_NAME = "account-sdk-android-web" +const val PROVIDER_COMPONENT = "schibsted-account" + +sealed class SchibstedAccountTrackingEvent { + /** + * This value needs to be mapped to the "provider.component" property of the Pulse event + */ + val providerComponent = PROVIDER_COMPONENT + + /** + * This value needs to be mapped to the "deploy_tag" property of the Pulse event + */ + val deployTag = "${PACKAGE_NAME}-${BuildConfig.VERSION_NAME}" + + object LoginPromptCreated : SchibstedAccountTrackingEvent() + + object LoginPromptView : SchibstedAccountTrackingEvent() + + object LoginPromptLeave : SchibstedAccountTrackingEvent() + + object LoginPromptDestroyed : SchibstedAccountTrackingEvent() + + object LoginPromptClickToLogin : SchibstedAccountTrackingEvent() + + object LoginPromptClickToContinueWithoutLogin : SchibstedAccountTrackingEvent() + + object LoginPromptClickOutside : SchibstedAccountTrackingEvent() + + object LoginPromptContentProviderInsert : SchibstedAccountTrackingEvent() + + object LoginPromptContentProviderDelete : SchibstedAccountTrackingEvent() + + object UserLoginSuccessful : SchibstedAccountTrackingEvent() + + object UserLoginFailed : SchibstedAccountTrackingEvent() + + object UserLoginCanceled : SchibstedAccountTrackingEvent() +} \ No newline at end of file diff --git a/webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTrackingStore.kt b/webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTrackingStore.kt new file mode 100644 index 00000000..bebc077a --- /dev/null +++ b/webflows/src/main/java/com/schibsted/account/webflows/tracking/SchibstedAccountTrackingStore.kt @@ -0,0 +1,43 @@ +package com.schibsted.account.webflows.tracking + +interface SchibstedAccountTrackerStore { + fun addTrackingListener(trackingListener: SchibstedAccountTrackingListener) + fun removeTrackingListener(trackingListener: SchibstedAccountTrackingListener) + + companion object : SchibstedAccountTrackerStore { + private val accountListenersList: MutableList = + mutableListOf() + + /** + * Adds listener for tracking events + */ + override fun addTrackingListener(trackingListener: SchibstedAccountTrackingListener) { + synchronized(accountListenersList) { + accountListenersList.add(trackingListener) + } + } + + /** + * Remove listener for tracking events + * This api might be useful for unit testing as mock listeners should be + * removed after all tests. + */ + override fun removeTrackingListener(trackingListener: SchibstedAccountTrackingListener) { + synchronized(accountListenersList) { + accountListenersList.remove(trackingListener) + } + } + + internal fun notifyListeners(event: SchibstedAccountTrackingEvent) { + synchronized(accountListenersList) { + accountListenersList.forEach { + it.onEvent(event) + } + } + } + } +} + +interface SchibstedAccountTrackingListener { + fun onEvent(event: SchibstedAccountTrackingEvent) +} \ No newline at end of file diff --git a/webflows/src/main/java/com/schibsted/account/webflows/util/Util.kt b/webflows/src/main/java/com/schibsted/account/webflows/util/Util.kt index d5dd90f9..a9c5d958 100644 --- a/webflows/src/main/java/com/schibsted/account/webflows/util/Util.kt +++ b/webflows/src/main/java/com/schibsted/account/webflows/util/Util.kt @@ -1,5 +1,8 @@ package com.schibsted.account.webflows.util +import android.content.Context +import android.content.Intent +import androidx.browser.customtabs.CustomTabsService import java.net.URLDecoder import kotlin.random.Random @@ -42,4 +45,11 @@ internal object Util { return "${split[0]}.${split[1]}" } + + fun isCustomTabsSupported(context: Context): Boolean { + val serviceIntent = Intent(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION) + val resolveInfos = context.packageManager.queryIntentServices(serviceIntent, 0) + + return resolveInfos.isNotEmpty() + } } diff --git a/webflows/src/main/res/drawable-fi/login_prompt_logos.xml b/webflows/src/main/res/drawable-fi/login_prompt_logos.xml new file mode 100644 index 00000000..0ab3158d --- /dev/null +++ b/webflows/src/main/res/drawable-fi/login_prompt_logos.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/webflows/src/main/res/drawable-sv/login_prompt_logos.xml b/webflows/src/main/res/drawable-sv/login_prompt_logos.xml new file mode 100644 index 00000000..38ec3778 --- /dev/null +++ b/webflows/src/main/res/drawable-sv/login_prompt_logos.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webflows/src/main/res/drawable/login_prompt_logos.xml b/webflows/src/main/res/drawable/login_prompt_logos.xml new file mode 100644 index 00000000..0b5332fc --- /dev/null +++ b/webflows/src/main/res/drawable/login_prompt_logos.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webflows/src/main/res/drawable/rounded_dialog.xml b/webflows/src/main/res/drawable/rounded_dialog.xml new file mode 100644 index 00000000..f50f65a3 --- /dev/null +++ b/webflows/src/main/res/drawable/rounded_dialog.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/webflows/src/main/res/drawable/rounded_layout.xml b/webflows/src/main/res/drawable/rounded_layout.xml new file mode 100644 index 00000000..533708e3 --- /dev/null +++ b/webflows/src/main/res/drawable/rounded_layout.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/webflows/src/main/res/layout/login_prompt.xml b/webflows/src/main/res/layout/login_prompt.xml new file mode 100644 index 00000000..c4956838 --- /dev/null +++ b/webflows/src/main/res/layout/login_prompt.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/webflows/src/main/res/values-da/strings.xml b/webflows/src/main/res/values-da/strings.xml new file mode 100644 index 00000000..933f33b9 --- /dev/null +++ b/webflows/src/main/res/values-da/strings.xml @@ -0,0 +1,9 @@ + + + Log ind + Du er allerede logget ind på en Schibsted-tjeneste og kan derfor hurtigt logge ind på denne tjeneste + Fortrolighedserklæring + Fortsæt uden at logge ind + Log ind + https://info.privacy.schibsted.com/dk/persondata-og-cookiepolitik/ + \ No newline at end of file diff --git a/webflows/src/main/res/values-fi/strings.xml b/webflows/src/main/res/values-fi/strings.xml new file mode 100644 index 00000000..6b89365f --- /dev/null +++ b/webflows/src/main/res/values-fi/strings.xml @@ -0,0 +1,9 @@ + + + Kirjaudu sisään + Voit kirjautua sisään nopeasti, koska olet jo kirjautunut Schibsted-palveluun + Tietosuojakäytännöistämme + Jatka kirjautumatta sisään + Kirjaudu sisään + https://info.privacy.schibsted.com/fi/tietosuoja-ja-evastekaytannot/ + \ No newline at end of file diff --git a/webflows/src/main/res/values-nb/strings.xml b/webflows/src/main/res/values-nb/strings.xml new file mode 100644 index 00000000..d9366c6a --- /dev/null +++ b/webflows/src/main/res/values-nb/strings.xml @@ -0,0 +1,9 @@ + + + Logg inn + Du er allerede logget inn på en Schibsted-tjeneste og kan derfor raskt logge inn på denne tjenesten + Personvernerklæring + Fortsett uten å logge inn + Logg inn + https://info.privacy.schibsted.com/no/schibsted-norge-personvernerklaering/ + \ No newline at end of file diff --git a/webflows/src/main/res/values-nn/strings.xml b/webflows/src/main/res/values-nn/strings.xml new file mode 100644 index 00000000..d9366c6a --- /dev/null +++ b/webflows/src/main/res/values-nn/strings.xml @@ -0,0 +1,9 @@ + + + Logg inn + Du er allerede logget inn på en Schibsted-tjeneste og kan derfor raskt logge inn på denne tjenesten + Personvernerklæring + Fortsett uten å logge inn + Logg inn + https://info.privacy.schibsted.com/no/schibsted-norge-personvernerklaering/ + \ No newline at end of file diff --git a/webflows/src/main/res/values-no/strings.xml b/webflows/src/main/res/values-no/strings.xml new file mode 100644 index 00000000..d9366c6a --- /dev/null +++ b/webflows/src/main/res/values-no/strings.xml @@ -0,0 +1,9 @@ + + + Logg inn + Du er allerede logget inn på en Schibsted-tjeneste og kan derfor raskt logge inn på denne tjenesten + Personvernerklæring + Fortsett uten å logge inn + Logg inn + https://info.privacy.schibsted.com/no/schibsted-norge-personvernerklaering/ + \ No newline at end of file diff --git a/webflows/src/main/res/values-sv/strings.xml b/webflows/src/main/res/values-sv/strings.xml new file mode 100644 index 00000000..35ad5bec --- /dev/null +++ b/webflows/src/main/res/values-sv/strings.xml @@ -0,0 +1,9 @@ + + + Logga in + Du är redan inloggad på en Schibsted-tjänst och kan därför snabbt logga in på denna tjänst + Personuppgiftspolicy + Fortsätt utan att logga in + Logga in + https://info.privacy.schibsted.com/se/schibsted-sverige-personuppgiftspolicy/ + \ No newline at end of file diff --git a/webflows/src/main/res/values/colors.xml b/webflows/src/main/res/values/colors.xml new file mode 100644 index 00000000..deb129e7 --- /dev/null +++ b/webflows/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #3274D4 + #35343A + #535259 + \ No newline at end of file diff --git a/webflows/src/main/res/values/strings.xml b/webflows/src/main/res/values/strings.xml new file mode 100644 index 00000000..b1d1a8b9 --- /dev/null +++ b/webflows/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Log in + You are already logged in to a Schibsted service and can therefore quickly log in to this one + Privacy policy + Continue without logging in + Log in + https://info.privacy.schibsted.com/en/privacy-and-cookie-policy-english-schibsted-sverige/ + \ No newline at end of file diff --git a/webflows/src/main/res/values/styles.xml b/webflows/src/main/res/values/styles.xml new file mode 100644 index 00000000..786d5609 --- /dev/null +++ b/webflows/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file