diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 1dd27e5..57dcdb6 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -118,7 +118,7 @@ diff --git a/android/src/main/java/com/frontegg/android/AuthenticationActivity.kt b/android/src/main/java/com/frontegg/android/AuthenticationActivity.kt index e8b7596..8c19a5e 100644 --- a/android/src/main/java/com/frontegg/android/AuthenticationActivity.kt +++ b/android/src/main/java/com/frontegg/android/AuthenticationActivity.kt @@ -8,11 +8,13 @@ import android.net.Uri import android.os.Bundle import android.util.Log import androidx.browser.customtabs.CustomTabsIntent +import com.frontegg.android.services.FronteggAuthService +import com.frontegg.android.services.FronteggInnerStorage import com.frontegg.android.utils.AuthorizeUrlGenerator class AuthenticationActivity : Activity() { - - var customTabLaunched = false + private val storage = FronteggInnerStorage() + private var customTabLaunched = false private fun startAuth(url: String) { val builder = CustomTabsIntent.Builder() builder.setShowTitle(true) @@ -58,10 +60,10 @@ class AuthenticationActivity : Activity() { val code = intent.data?.getQueryParameter("code") if (code != null) { Log.d(TAG, "Got intent with oauth callback") - FronteggAuth.instance.isLoading.value = true + FronteggAuthService.instance.isLoading.value = true - FronteggAuth.instance.handleHostedLoginCallback(code, null, this) - if (FronteggApp.getInstance().useChromeCustomTabs && FronteggApp.getInstance().isEmbeddedMode) { + FronteggAuthService.instance.handleHostedLoginCallback(code, null, this) + if (storage.useChromeCustomTabs && storage.isEmbeddedMode) { EmbeddedAuthActivity.afterAuthentication(this) } else { setResult(RESULT_OK) diff --git a/android/src/main/java/com/frontegg/android/EmbeddedAuthActivity.kt b/android/src/main/java/com/frontegg/android/EmbeddedAuthActivity.kt index 4dbebfc..4af7674 100644 --- a/android/src/main/java/com/frontegg/android/EmbeddedAuthActivity.kt +++ b/android/src/main/java/com/frontegg/android/EmbeddedAuthActivity.kt @@ -8,14 +8,16 @@ import android.view.View import android.widget.LinearLayout import com.frontegg.android.embedded.FronteggNativeBridge import com.frontegg.android.embedded.FronteggWebView +import com.frontegg.android.services.FronteggAuthService +import com.frontegg.android.services.FronteggInnerStorage import com.frontegg.android.utils.AuthorizeUrlGenerator import com.frontegg.android.utils.NullableObject import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.functions.Consumer class EmbeddedAuthActivity : Activity() { - - lateinit var webView: FronteggWebView + private val storage = FronteggInnerStorage() + private lateinit var webView: FronteggWebView private var webViewUrl: String? = null private var directLoginLaunchedDone: Boolean = false private var directLoginLaunched: Boolean = false @@ -36,8 +38,8 @@ class EmbeddedAuthActivity : Activity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putBoolean(DIRECT_LOGIN_ACTION_LAUNCHED, this.directLoginLaunched); - outState.putBoolean(DIRECT_LOGIN_ACTION_LAUNCHED_DONE, this.directLoginLaunchedDone); + outState.putBoolean(DIRECT_LOGIN_ACTION_LAUNCHED, this.directLoginLaunched) + outState.putBoolean(DIRECT_LOGIN_ACTION_LAUNCHED_DONE, this.directLoginLaunchedDone) } private fun consumeIntent(intent: Intent) { @@ -124,7 +126,7 @@ class EmbeddedAuthActivity : Activity() { } private fun navigateToAuthenticated() { - val mainActivityClass = FronteggApp.getInstance().mainActivityClass + val mainActivityClass = storage.mainActivityClass if (mainActivityClass != null) { val intent = Intent(this, mainActivityClass) startActivity(intent) @@ -144,8 +146,8 @@ class EmbeddedAuthActivity : Activity() { if (directLoginLaunchedDone) { onAuthFinishedCallback?.invoke() onAuthFinishedCallback = null - FronteggAuth.instance.isLoading.value = false - FronteggAuth.instance.showLoader.value = false + FronteggAuthService.instance.isLoading.value = false + FronteggAuthService.instance.showLoader.value = false setResult(RESULT_OK) finish() return diff --git a/android/src/main/java/com/frontegg/android/FronteggApp.kt b/android/src/main/java/com/frontegg/android/FronteggApp.kt index 48783ee..f250d6c 100644 --- a/android/src/main/java/com/frontegg/android/FronteggApp.kt +++ b/android/src/main/java/com/frontegg/android/FronteggApp.kt @@ -4,57 +4,31 @@ import android.annotation.SuppressLint import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager.MATCH_ALL -import android.os.Handler -import android.util.Log -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.ProcessLifecycleOwner import com.frontegg.android.exceptions.FronteggException import com.frontegg.android.exceptions.FronteggException.Companion.FRONTEGG_APP_MUST_BE_INITIALIZED import com.frontegg.android.regions.RegionConfig -import com.frontegg.android.services.* -import java.time.Instant +import com.frontegg.android.services.CredentialManager +import com.frontegg.android.services.FronteggAppService -class FronteggApp private constructor( - val context: Context, - var baseUrl: String, - var clientId: String, - var applicationId: String?, - val isEmbeddedMode: Boolean = true, - val regions: List = listOf(), - val selectedRegion: RegionConfig? = null, - var handleLoginWithSocialLogin: Boolean = true, - var customUserAgent: String? = null, - var handleLoginWithSSO: Boolean = false, - var shouldPromptSocialLoginConsent: Boolean = true, - val useAssetsLinks: Boolean = false, - var useChromeCustomTabs: Boolean = false, - var mainActivityClass: Class<*>? = null -) { - val credentialManager: CredentialManager = CredentialManager(context) - val auth: FronteggAuth = - FronteggAuth(baseUrl, clientId, applicationId, credentialManager, regions, selectedRegion) - val packageName: String = context.packageName - var appInForeground = true - - var lastJobStart: Long = Instant.now().toEpochMilli(); +interface FronteggApp { + val auth: FronteggAuth companion object { @SuppressLint("StaticFieldLeak") private var instance: FronteggApp? = null - public val TAG: String = FronteggApp::class.java.simpleName + val TAG: String = FronteggApp::class.java.simpleName - public fun getInstance(): FronteggApp { + fun getInstance(): FronteggApp { if (instance == null) { throw FronteggException(FRONTEGG_APP_MUST_BE_INITIALIZED) } return instance!! } - public fun init( + fun init( fronteggDomain: String, clientId: String, context: Context, @@ -71,9 +45,7 @@ class FronteggApp private constructor( val isEmbeddedMode = isActivityEnabled(context, EmbeddedAuthActivity::class.java.name) - - - instance = FronteggApp( + instance = FronteggAppService( context = context, baseUrl = baseUrl, clientId = clientId, @@ -83,13 +55,9 @@ class FronteggApp private constructor( useChromeCustomTabs = useChromeCustomTabs, mainActivityClass = mainActivityClass ) - - Handler(context.mainLooper).post { - ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleEventObserver) - } } - public fun initWithRegions( + fun initWithRegions( regions: List, context: Context, useAssetsLinks: Boolean = false, @@ -103,7 +71,7 @@ class FronteggApp private constructor( val regionConfig = regions.find { it.key == selectedRegion } if (regionConfig != null) { - val newInstance = FronteggApp( + val newInstance = FronteggAppService( context = context, baseUrl = regionConfig.baseUrl, clientId = regionConfig.clientId, @@ -119,7 +87,7 @@ class FronteggApp private constructor( return newInstance } } - val newInstance = FronteggApp( + val newInstance = FronteggAppService( context = context, baseUrl = "", clientId = "", @@ -134,24 +102,6 @@ class FronteggApp private constructor( return newInstance } - private var lifecycleEventObserver = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_STOP -> { - Log.d(TAG, "ON_STOP") - getInstance().appInForeground = false - getInstance().auth.refreshTokenWhenNeeded() - } - - Lifecycle.Event.ON_START -> { - Log.d(TAG, "ON_START") - getInstance().appInForeground = true - getInstance().auth.refreshTokenWhenNeeded() - } - - else -> {} - } - } - private fun isActivityEnabled(context: Context, activityClassName: String): Boolean { return try { val componentName = ComponentName(context, activityClassName) @@ -163,27 +113,5 @@ class FronteggApp private constructor( } } - fun initWithRegion(regionKey: String) { - if (this.regions.isEmpty()) { - throw RuntimeException("illegal state. Frontegg.plist does not contains regions array") - } - - val keys = this.regions.joinToString(",") { it.key } - - val config = regions.find { it.key == regionKey } - ?: throw RuntimeException("invalid region key ${regionKey}. available regions: $keys") - - - credentialManager.saveSelectedRegion(regionKey) - - this.baseUrl = config.baseUrl - this.clientId = config.clientId - this.applicationId = config.applicationId - this.auth.reinitWithRegion(config) - - - Log.i(TAG, "Frontegg Initialized successfully (region: ${regionKey})") - } - - -} \ No newline at end of file + fun initWithRegion(regionKey: String) +} diff --git a/android/src/main/java/com/frontegg/android/FronteggAuth.kt b/android/src/main/java/com/frontegg/android/FronteggAuth.kt index db5004d..26e75c1 100644 --- a/android/src/main/java/com/frontegg/android/FronteggAuth.kt +++ b/android/src/main/java/com/frontegg/android/FronteggAuth.kt @@ -1,406 +1,44 @@ package com.frontegg.android -import android.annotation.SuppressLint import android.app.Activity -import android.app.job.JobInfo -import android.app.job.JobScheduler -import android.content.ComponentName -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.webkit.CookieManager -import android.webkit.WebView import com.frontegg.android.models.User import com.frontegg.android.regions.RegionConfig -import com.frontegg.android.services.Api -import com.frontegg.android.services.CredentialManager -import com.frontegg.android.services.RefreshTokenService -import com.frontegg.android.utils.AuthorizeUrlGenerator -import com.frontegg.android.utils.Constants -import com.frontegg.android.utils.CredentialKeys -import com.frontegg.android.utils.JWTHelper -import com.frontegg.android.utils.ObservableValue -import io.reactivex.rxjava3.core.Observable -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import java.time.Instant -import java.util.Timer -import java.util.TimerTask -import kotlin.concurrent.schedule +import com.frontegg.android.utils.ReadOnlyObservableValue -@SuppressLint("CheckResult") -@OptIn(DelicateCoroutinesApi::class) -class FronteggAuth( - var baseUrl: String, - var clientId: String, - var applicationId: String?, - val credentialManager: CredentialManager, - val regions: List, - var selectedRegion: RegionConfig? -) { +interface FronteggAuth { + val accessToken: ReadOnlyObservableValue + val refreshToken: ReadOnlyObservableValue + val user: ReadOnlyObservableValue + val isAuthenticated: ReadOnlyObservableValue + val isLoading: ReadOnlyObservableValue + val initializing: ReadOnlyObservableValue + val showLoader: ReadOnlyObservableValue + val refreshingToken: ReadOnlyObservableValue + val isMultiRegion: Boolean + val selectedRegion: RegionConfig? - companion object { - private val TAG = FronteggAuth::class.java.simpleName - const val JOB_ID = 1234 // Unique ID for the JobService - - val instance: FronteggAuth - get() { - return FronteggApp.getInstance().auth - } - - } - - var accessToken: ObservableValue = ObservableValue(null) - var refreshToken: ObservableValue = ObservableValue(null) - val user: ObservableValue = ObservableValue(null) - val isAuthenticated: ObservableValue = ObservableValue(false) - val isLoading: ObservableValue = ObservableValue(true) - val initializing: ObservableValue = ObservableValue(true) - val showLoader: ObservableValue = ObservableValue(true) - val refreshingToken: ObservableValue = ObservableValue(false) - var pendingAppLink: String? = null - val isMultiRegion: Boolean = regions.isNotEmpty() - var refreshTokenJob: JobInfo? = null; - var timerTask: TimerTask? = null; - private var _api: Api? = null - - init { - - if (!isMultiRegion || selectedRegion !== null) { - this.initializeSubscriptions() - } - } - - val api: Api - get() = (if (this._api == null) { - this._api = Api(this.baseUrl, this.clientId, this.applicationId, credentialManager) - this._api - } else { - this._api - })!! - - - fun reinitWithRegion(region: RegionConfig) { - selectedRegion = region - - this.baseUrl = region.baseUrl - this.clientId = region.clientId - this.applicationId = region.applicationId - this._api = null - - this.initializeSubscriptions() - } - - - fun initializeSubscriptions() { - Observable.merge( - isLoading.observable, - isAuthenticated.observable, - initializing.observable, - ).subscribe { - showLoader.value = initializing.value || (!isAuthenticated.value && isLoading.value) - } - - GlobalScope.launch(Dispatchers.IO) { - - val accessTokenSaved = credentialManager.get(CredentialKeys.ACCESS_TOKEN) - val refreshTokenSaved = credentialManager.get(CredentialKeys.REFRESH_TOKEN) - - if (accessTokenSaved != null && refreshTokenSaved != null) { - accessToken.value = accessTokenSaved - refreshToken.value = refreshTokenSaved - - if (!refreshTokenIfNeeded()) { - accessToken.value = null - refreshToken.value = null - initializing.value = false - isLoading.value = false - } - - } else { - initializing.value = false - isLoading.value = false - } - } - } - - fun sendRefreshToken(): Boolean { - val refreshToken = this.refreshToken.value ?: return false - this.refreshingToken.value = true - try { - - val data = api.refreshToken(refreshToken) - return if (data != null) { - setCredentials(data.access_token, data.refresh_token) - true - } else { - Log.e(TAG, "Failed to refresh token, data = null") - false - } - } finally { - this.refreshingToken.value = false - } - } - - fun refreshTokenWhenNeeded() { - val accessToken = this.accessToken.value - - if (this.refreshToken.value == null) { - return - } - - - cancelLastTimer() - - if (accessToken == null) { - // when we have valid refreshToken without accessToken => failed to refresh in background - GlobalScope.launch(Dispatchers.IO) { - sendRefreshToken() - } - return; - } - - val decoded = JWTHelper.decode(accessToken) - if (decoded.exp > 0) { - val offset = calculateTimerOffset(decoded.exp) - if (offset <= 0) { - Log.d(TAG, "Refreshing Token...") - GlobalScope.launch(Dispatchers.IO) { - sendRefreshToken() - } - } else { - Log.d(TAG, "Schedule Refreshing Token for $offset") - this.scheduleTimer(offset) - } - } - } - - fun refreshTokenIfNeeded(): Boolean { - - Log.d(TAG, "refreshTokenIfNeeded()") - - return try { - this.sendRefreshToken() - } catch (e: Exception) { - Log.e(TAG, "Failed to send refresh token request", e) - false - } - } - - private fun setCredentials(accessToken: String, refreshToken: String) { - - if (credentialManager.save(CredentialKeys.REFRESH_TOKEN, refreshToken) - && credentialManager.save(CredentialKeys.ACCESS_TOKEN, accessToken) - ) { - - val decoded = JWTHelper.decode(accessToken) - val user = api.me() - - this.refreshToken.value = refreshToken - this.accessToken.value = accessToken - this.user.value = user - this.isAuthenticated.value = true - this.pendingAppLink = null - - // Cancel previous job if it exists - this.cancelLastTimer() + fun login(activity: Activity, loginHint: String? = null) + fun logout(callback: () -> Unit = {}) - if (decoded.exp > 0) { - val offset = calculateTimerOffset(decoded.exp) - Log.d(TAG, "setCredentials, schedule for $offset") - - this.scheduleTimer(offset) - } - } else { - this.refreshToken.value = null - this.accessToken.value = null - this.user.value = null - this.isAuthenticated.value = false - } - - this.isLoading.value = false - this.initializing.value = false - } - - private fun cancelLastTimer() { - Log.d(TAG, "Cancel Last Timer") - if(timerTask!= null){ - timerTask?.cancel() - timerTask = null - } - if(refreshTokenJob!= null) { - val context = FronteggApp.getInstance().context - val jobScheduler = - context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler - jobScheduler.cancel(JOB_ID) - this.refreshTokenJob = null - } - } - - fun scheduleTimer(offset: Long) { - FronteggApp.getInstance().lastJobStart = Instant.now().toEpochMilli() - if(FronteggApp.getInstance().appInForeground){ - Log.d(TAG, "[foreground] Start Timer task (${offset} ms)") - - this.timerTask = Timer().schedule(offset) { - Log.d(TAG, "[foreground] Job started, (${Instant.now().toEpochMilli()-FronteggApp.getInstance().lastJobStart} ms)") - refreshTokenIfNeeded() - } - - }else { - Log.d(TAG, "[background] Start Job Scheduler task (${offset} ms)") - val context = FronteggApp.getInstance().context - val jobScheduler = - context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler - // Schedule the job - val jobInfo = JobInfo.Builder( - JOB_ID, ComponentName(context, RefreshTokenService::class.java) - ) - .setMinimumLatency(offset / 2) // Schedule the job to run after the offset - .setOverrideDeadline(offset) // Add a buffer to the deadline - .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) // Require network - .setBackoffCriteria(10000, JobInfo.BACKOFF_POLICY_LINEAR) - .build() - this.refreshTokenJob = jobInfo - jobScheduler.schedule(jobInfo) - } - } - - fun handleHostedLoginCallback( - code: String, - webView: WebView? = null, - activity: Activity? = null, + fun directLoginAction( + activity: Activity, + type: String, + data: String, callback: (() -> Unit)? = null - ): Boolean { - - val codeVerifier = credentialManager.getCodeVerifier() - val redirectUrl = Constants.oauthCallbackUrl(baseUrl) + ) - if (codeVerifier == null) { - return false - } + fun switchTenant(tenantId: String, callback: (Boolean) -> Unit = {}) - GlobalScope.launch(Dispatchers.IO) { - val data = api.exchangeToken(code, redirectUrl, codeVerifier) - if (data != null) { - setCredentials(data.access_token, data.refresh_token) - callback?.invoke() - } else { - Log.e(TAG, "Failed to exchange token") - if (webView != null) { - val authorizeUrl = AuthorizeUrlGenerator() - val url = authorizeUrl.generate() - Handler(Looper.getMainLooper()).post { - webView.loadUrl(url.first) - } - } else if (activity != null && callback == null) { - login(activity) - } - - } - } - - return true - } - - private fun getDomainCookie(siteName: String): String? { - val cookieManager = CookieManager.getInstance() - return cookieManager.getCookie(siteName); - } - - - fun logout(callback: () -> Unit = {}) { - isLoading.value = true - this.cancelLastTimer() - - GlobalScope.launch(Dispatchers.IO) { - - val logoutCookies = getDomainCookie(baseUrl) - val logoutAccessToken = accessToken.value - - if (logoutCookies != null && - logoutAccessToken != null && - FronteggApp.getInstance().isEmbeddedMode - ) { - api.logout(logoutCookies, logoutAccessToken) - } + fun refreshTokenIfNeeded(): Boolean - isAuthenticated.value = false - accessToken.value = null - refreshToken.value = null - user.value = null - credentialManager.clear() - - val handler = Handler(Looper.getMainLooper()) - handler.post { - isLoading.value = false - callback() - } - - } - - } - - fun login(activity: Activity, loginHint: String? = null) { - if (FronteggApp.getInstance().isEmbeddedMode) { - EmbeddedAuthActivity.authenticate(activity, loginHint) - } else { - AuthenticationActivity.authenticate(activity, loginHint) - } - } - - fun directLoginAction(activity: Activity, type: String, data: String, callback: (() -> Unit)? = null) { - if (FronteggApp.getInstance().isEmbeddedMode) { - EmbeddedAuthActivity.directLoginAction(activity, type, data, callback) - } else { - Log.w(TAG, "Direct login action is not supported in non-embedded mode") - } - } - - - fun switchTenant(tenantId: String, callback: (Boolean) -> Unit = {}) { - Log.d(TAG, "switchTenant()") - GlobalScope.launch(Dispatchers.IO) { - val handler = Handler(Looper.getMainLooper()) - - isLoading.value = true - try { - api.switchTenant(tenantId) - } catch (e: Exception) { - Log.e(TAG, "Failed to send switch tenant request", e) - handler.post { - isLoading.value = false - callback(false) - } - return@launch - } - - val success = refreshTokenIfNeeded() - - handler.post { - isLoading.value = false - callback(success) + companion object { + val instance: FronteggAuth + get() { + return FronteggApp.getInstance().auth } - } - } - - private fun calculateTimerOffset(expirationTime: Long): Long { - val now: Long = Instant.now().toEpochMilli() - val remainingTime = (expirationTime * 1000) - now - - val minRefreshWindow = 20000 // minimum 20 seconds before exp - val adaptiveRefreshTime = remainingTime * 0.8 // 80% of remaining time - - return if (remainingTime > minRefreshWindow) { - adaptiveRefreshTime.toLong() - } else { - (remainingTime - minRefreshWindow).coerceAtLeast(0) - } } } + diff --git a/android/src/main/java/com/frontegg/android/embedded/FronteggNativeBridge.kt b/android/src/main/java/com/frontegg/android/embedded/FronteggNativeBridge.kt index 96adcd7..c1fe604 100644 --- a/android/src/main/java/com/frontegg/android/embedded/FronteggNativeBridge.kt +++ b/android/src/main/java/com/frontegg/android/embedded/FronteggNativeBridge.kt @@ -9,9 +9,8 @@ import android.util.Log import android.webkit.JavascriptInterface import androidx.browser.customtabs.CustomTabsIntent import com.frontegg.android.EmbeddedAuthActivity -import com.frontegg.android.FronteggApp +import com.frontegg.android.services.FronteggInnerStorage import com.frontegg.android.utils.AuthorizeUrlGenerator -import com.google.androidbrowserhelper.trusted.LauncherActivity import org.json.JSONException import org.json.JSONObject @@ -42,7 +41,7 @@ class FronteggNativeBridge(val context: Context) { val authorizationUrl = Uri.parse(generatedUrl.first) - if (FronteggApp.getInstance().useChromeCustomTabs) { + if (FronteggInnerStorage().useChromeCustomTabs) { val customTabsIntent = CustomTabsIntent.Builder().setShowTitle(false).build() customTabsIntent.intent.setPackage("com.android.chrome"); customTabsIntent.intent.setData(authorizationUrl) diff --git a/android/src/main/java/com/frontegg/android/embedded/FronteggWebClient.kt b/android/src/main/java/com/frontegg/android/embedded/FronteggWebClient.kt index a4d25f5..28f5fec 100644 --- a/android/src/main/java/com/frontegg/android/embedded/FronteggWebClient.kt +++ b/android/src/main/java/com/frontegg/android/embedded/FronteggWebClient.kt @@ -15,8 +15,8 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient -import com.frontegg.android.FronteggApp -import com.frontegg.android.FronteggAuth +import com.frontegg.android.services.FronteggAuthService +import com.frontegg.android.services.FronteggInnerStorage import com.frontegg.android.utils.AuthorizeUrlGenerator import com.frontegg.android.utils.Constants import com.frontegg.android.utils.Constants.Companion.loginRoutes @@ -38,13 +38,14 @@ class FronteggWebClient(val context: Context) : WebViewClient() { private val TAG = FronteggWebClient::class.java.simpleName } - var webViewStatusCode: Int = 200 - var lastErrorResponse: WebResourceResponse? = null + private var webViewStatusCode: Int = 200 + private var lastErrorResponse: WebResourceResponse? = null + private val storage = FronteggInnerStorage() override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) Log.d(TAG, "onPageStarted $url") - FronteggAuth.instance.isLoading.value = true + FronteggAuthService.instance.isLoading.value = true } override fun onPageFinished(view: WebView?, url: String?) { @@ -52,18 +53,18 @@ class FronteggWebClient(val context: Context) : WebViewClient() { Log.d(TAG, "onPageFinished $url") when (getOverrideUrlType(Uri.parse(url))) { OverrideUrlType.HostedLoginCallback -> - FronteggAuth.instance.isLoading.value = true + FronteggAuthService.instance.isLoading.value = true OverrideUrlType.Unknown, OverrideUrlType.loginRoutes -> - FronteggAuth.instance.isLoading.value = false + FronteggAuthService.instance.isLoading.value = false else -> - FronteggAuth.instance.isLoading.value = true + FronteggAuthService.instance.isLoading.value = true } if (url?.startsWith("data:text/html,") == true) { - FronteggAuth.instance.isLoading.value = false + FronteggAuthService.instance.isLoading.value = false return } @@ -73,16 +74,16 @@ class FronteggWebClient(val context: Context) : WebViewClient() { webViewStatusCode = 200 lastErrorResponse = null } else { - val fronteggApp = FronteggApp.getInstance() + val nativeModuleFunctions = JSONObject() nativeModuleFunctions.put( "loginWithSocialLogin", - fronteggApp.handleLoginWithSocialLogin + storage.handleLoginWithSocialLogin ) - nativeModuleFunctions.put("loginWithSSO", fronteggApp.handleLoginWithSSO) + nativeModuleFunctions.put("loginWithSSO", storage.handleLoginWithSSO) nativeModuleFunctions.put( "shouldPromptSocialLoginConsent", - fronteggApp.shouldPromptSocialLoginConsent + storage.shouldPromptSocialLoginConsent ) val jsObject = nativeModuleFunctions.toString() view?.evaluateJavascript("window.FronteggNativeBridgeFunctions = ${jsObject};", null) @@ -136,7 +137,7 @@ class FronteggWebClient(val context: Context) : WebViewClient() { } val status = errorResponse?.statusCode - if (url.startsWith("${FronteggAuth.instance.baseUrl}/oauth/authorize")) { + if (url.startsWith("${storage.baseUrl}/oauth/authorize")) { val reloadScript = "setTimeout(()=>window.location.href=\"${url.replace("\"", "\\\"")}\", 4000)" val jsCode = "(function(){\n" + @@ -145,7 +146,7 @@ class FronteggWebClient(val context: Context) : WebViewClient() { " document.body.appendChild(script)\n" + " })()" view.evaluateJavascript(jsCode, null) - FronteggAuth.instance.isLoading.value = false + FronteggAuthService.instance.isLoading.value = false return } @@ -194,7 +195,7 @@ class FronteggWebClient(val context: Context) : WebViewClient() { return } val requestUrl = request.url - val authorizeUrlPrefix = "${FronteggAuth.instance.baseUrl}/oauth/authorize" + val authorizeUrlPrefix = "${storage.baseUrl}/oauth/authorize" if (view.url == requestUrl.toString() || requestUrl.toString().startsWith(authorizeUrlPrefix) ) { @@ -252,12 +253,12 @@ class FronteggWebClient(val context: Context) : WebViewClient() { private fun getOverrideUrlType(url: Uri): OverrideUrlType { val urlPath = url.path - val hostedLoginCallback = Constants.oauthCallbackUrl(FronteggApp.getInstance().baseUrl); + val hostedLoginCallback = Constants.oauthCallbackUrl(storage.baseUrl); if (url.toString().startsWith(hostedLoginCallback)) { return OverrideUrlType.HostedLoginCallback } - if (urlPath != null && url.toString().startsWith(FronteggApp.getInstance().baseUrl)) { + if (urlPath != null && url.toString().startsWith(storage.baseUrl)) { if (isSocialLoginPath(urlPath)) { return OverrideUrlType.SocialOauthPreLogin @@ -349,11 +350,11 @@ class FronteggWebClient(val context: Context) : WebViewClient() { Log.d(TAG, "redirectUri exist, forward navigation to webView") return false } - val baseUrl = FronteggApp.getInstance().baseUrl + val baseUrl = storage.baseUrl val oauthRedirectUri = socialLoginRedirectUrl(baseUrl) val newUri = setUriParameter(uri, "redirectUri", oauthRedirectUri) - FronteggAuth.instance.isLoading.value = true + FronteggAuthService.instance.isLoading.value = true try { GlobalScope.launch(Dispatchers.IO) { val requestBuilder = Request.Builder() @@ -373,7 +374,7 @@ class FronteggWebClient(val context: Context) : WebViewClient() { handler.post { val browserIntent = Intent(Intent.ACTION_VIEW, socialLoginUrl) context.startActivity(browserIntent) - FronteggAuth.instance.isLoading.value = false + FronteggAuthService.instance.isLoading.value = false } } } catch (e: Exception) { @@ -409,7 +410,7 @@ class FronteggWebClient(val context: Context) : WebViewClient() { return false } - if (FronteggAuth.instance.handleHostedLoginCallback(code, webView)) { + if (FronteggAuthService.instance.handleHostedLoginCallback(code, webView)) { return true; } diff --git a/android/src/main/java/com/frontegg/android/embedded/FronteggWebView.kt b/android/src/main/java/com/frontegg/android/embedded/FronteggWebView.kt index 2fd933a..82440ef 100644 --- a/android/src/main/java/com/frontegg/android/embedded/FronteggWebView.kt +++ b/android/src/main/java/com/frontegg/android/embedded/FronteggWebView.kt @@ -6,13 +6,11 @@ import android.content.pm.PackageManager import android.util.AttributeSet import android.webkit.CookieManager import android.webkit.WebView -import com.frontegg.android.FronteggApp -import com.frontegg.android.utils.AuthorizeUrlGenerator -import okhttp3.internal.userAgent -import java.util.* +import com.frontegg.android.services.FronteggInnerStorage open class FronteggWebView : WebView { + private val storage = FronteggInnerStorage() constructor(context: Context) : super(context) { initView(context) @@ -47,7 +45,7 @@ open class FronteggWebView : WebView { settings.domStorageEnabled = true settings.safeBrowsingEnabled = true - if (!FronteggApp.getInstance().handleLoginWithSocialLogin) { + if (!storage.handleLoginWithSocialLogin) { // Note: Using a custom User-Agent to facilitate Google authentication within an // in-app WebView is not generally recommended. // This approach can lead to a segregated session that does not share @@ -56,7 +54,7 @@ open class FronteggWebView : WebView { // requirements or to maintain separate session states, // but be aware of potential authentication and session management issues. - val userAgent = FronteggApp.getInstance().customUserAgent ?: getChromeUserAgent() + val userAgent = storage.customUserAgent ?: getChromeUserAgent() settings.userAgentString = userAgent } diff --git a/android/src/main/java/com/frontegg/android/services/Api.kt b/android/src/main/java/com/frontegg/android/services/Api.kt index 7b53453..20ad384 100644 --- a/android/src/main/java/com/frontegg/android/services/Api.kt +++ b/android/src/main/java/com/frontegg/android/services/Api.kt @@ -17,21 +17,24 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.io.IOException -import java.lang.Exception open class Api( - private var baseUrl: String, - private var clientId: String, - private var applicationId: String?, + private var credentialManager: CredentialManager ) { private var httpClient: OkHttpClient = OkHttpClient() + private val storage = FronteggInnerStorage() + private val baseUrl: String + get() = storage.baseUrl + private val clientId: String + get() = storage.clientId + private val applicationId: String? + get() = storage.applicationId companion object { val TAG: String = Api::class.java.simpleName } - private fun prepareHeaders(additionalHeaders: Map = mapOf()): Headers { val headers: MutableMap = mutableMapOf( diff --git a/android/src/main/java/com/frontegg/android/services/FronteggAppLifecycle.kt b/android/src/main/java/com/frontegg/android/services/FronteggAppLifecycle.kt new file mode 100644 index 0000000..62d44d4 --- /dev/null +++ b/android/src/main/java/com/frontegg/android/services/FronteggAppLifecycle.kt @@ -0,0 +1,42 @@ +package com.frontegg.android.services + +import android.content.Context +import android.os.Handler +import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ProcessLifecycleOwner +import com.frontegg.android.FronteggApp +import com.frontegg.android.utils.FronteggCallback + +class FronteggAppLifecycle( + context: Context, +) { + var appInForeground = true + val startApp = FronteggCallback() + val stopApp = FronteggCallback() + + init { + Handler(context.mainLooper).post { + ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleEventObserver) + } + } + + private var lifecycleEventObserver = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> { + Log.d(FronteggApp.TAG, "ON_STOP") + appInForeground = false + stopApp.trigger() + } + + Lifecycle.Event.ON_START -> { + Log.d(FronteggApp.TAG, "ON_START") + appInForeground = true + startApp.trigger() + } + + else -> {} + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/frontegg/android/services/FronteggAppService.kt b/android/src/main/java/com/frontegg/android/services/FronteggAppService.kt new file mode 100644 index 0000000..a190b23 --- /dev/null +++ b/android/src/main/java/com/frontegg/android/services/FronteggAppService.kt @@ -0,0 +1,85 @@ +package com.frontegg.android.services + +import android.content.Context +import android.util.Log +import com.frontegg.android.FronteggApp +import com.frontegg.android.FronteggAuth +import com.frontegg.android.regions.RegionConfig + +class FronteggAppService( + private val context: Context, + private var baseUrl: String, + private var clientId: String, + private var applicationId: String?, + private val isEmbeddedMode: Boolean = true, + private val regions: List = listOf(), + private var selectedRegion: RegionConfig? = null, + private val handleLoginWithSocialLogin: Boolean = true, + private val customUserAgent: String? = null, + private val handleLoginWithSSO: Boolean = false, + private val shouldPromptSocialLoginConsent: Boolean = true, + private val useAssetsLinks: Boolean = false, + private var useChromeCustomTabs: Boolean = false, + private var mainActivityClass: Class<*>? = null +) : FronteggApp { + + private val storage = FronteggInnerStorage() + private val credentialManager = CredentialManager(context) + private val appLifecycle = FronteggAppLifecycle(context) + private val refreshTokenManager = FronteggRefreshTokenTimer(context, appLifecycle) + + override val auth: FronteggAuth + + init { + fillStorage() + auth = + FronteggAuthService( + credentialManager, + appLifecycle, + refreshTokenManager + ) + } + + private fun fillStorage() { + storage.fill( + baseUrl, + clientId, + applicationId, + isEmbeddedMode, + regions, + selectedRegion, + handleLoginWithSocialLogin, + customUserAgent, + handleLoginWithSSO, + shouldPromptSocialLoginConsent, + useAssetsLinks, + useChromeCustomTabs, + mainActivityClass, + context.packageName, + ) + } + + override fun initWithRegion(regionKey: String) { + if (this.regions.isEmpty()) { + throw RuntimeException("illegal state. Frontegg.plist does not contains regions array") + } + + val keys = this.regions.joinToString(",") { it.key } + + val config = regions.find { it.key == regionKey } + ?: throw RuntimeException("invalid region key ${regionKey}. available regions: $keys") + + + credentialManager.saveSelectedRegion(regionKey) + + this.baseUrl = config.baseUrl + this.clientId = config.clientId + this.applicationId = config.applicationId + this.selectedRegion = config + fillStorage() + + FronteggAuthService.instance.reinitWithRegion() + + Log.i(FronteggApp.TAG, "Frontegg Initialized successfully (region: ${regionKey})") + } +} \ No newline at end of file diff --git a/android/src/main/java/com/frontegg/android/services/FronteggAuthService.kt b/android/src/main/java/com/frontegg/android/services/FronteggAuthService.kt new file mode 100644 index 0000000..122ae0f --- /dev/null +++ b/android/src/main/java/com/frontegg/android/services/FronteggAuthService.kt @@ -0,0 +1,354 @@ +package com.frontegg.android.services + +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.webkit.CookieManager +import android.webkit.WebView +import com.frontegg.android.AuthenticationActivity +import com.frontegg.android.EmbeddedAuthActivity +import com.frontegg.android.FronteggApp +import com.frontegg.android.FronteggAuth +import com.frontegg.android.models.User +import com.frontegg.android.regions.RegionConfig +import com.frontegg.android.utils.AuthorizeUrlGenerator +import com.frontegg.android.utils.Constants +import com.frontegg.android.utils.CredentialKeys +import com.frontegg.android.utils.JWTHelper +import com.frontegg.android.utils.ObservableValue +import com.frontegg.android.utils.calculateTimerOffset +import io.reactivex.rxjava3.core.Observable +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@OptIn(DelicateCoroutinesApi::class) +@SuppressLint("CheckResult") +class FronteggAuthService( + val credentialManager: CredentialManager, + appLifecycle: FronteggAppLifecycle, + val refreshTokenManager: FronteggRefreshTokenTimer +) : FronteggAuth { + + companion object { + private val TAG = FronteggAuth::class.java.simpleName + val instance: FronteggAuthService + get() { + return FronteggApp.getInstance().auth as FronteggAuthService + } + + } + + override var accessToken: ObservableValue = ObservableValue(null) + override var refreshToken: ObservableValue = ObservableValue(null) + override val user: ObservableValue = ObservableValue(null) + override val isAuthenticated: ObservableValue = ObservableValue(false) + override val isLoading: ObservableValue = ObservableValue(true) + override val initializing: ObservableValue = ObservableValue(true) + override val showLoader: ObservableValue = ObservableValue(true) + override val refreshingToken: ObservableValue = ObservableValue(false) + + private val api = Api(credentialManager) + private val storage = FronteggInnerStorage() + private var pendingAppLink: String? = null + override val isMultiRegion: Boolean = regions.isNotEmpty() + + private val baseUrl: String + get() = storage.baseUrl + private val clientId: String + get() = storage.clientId + private val applicationId: String? + get() = storage.applicationId + private val regions: List + get() = storage.regions + override val selectedRegion: RegionConfig? + get() = storage.selectedRegion + private val isEmbeddedMode: Boolean + get() = storage.isEmbeddedMode + + + init { + + if (!isMultiRegion || selectedRegion !== null) { + this.initializeSubscriptions() + } + + appLifecycle.startApp.addCallback { + refreshTokenWhenNeeded() + } + + appLifecycle.stopApp.addCallback { + refreshTokenWhenNeeded() + } + + refreshTokenManager.refreshTokenIfNeeded.addCallback { + refreshTokenIfNeeded() + } + } + + + override fun login(activity: Activity, loginHint: String?) { + if (isEmbeddedMode) { + EmbeddedAuthActivity.authenticate(activity, loginHint) + } else { + AuthenticationActivity.authenticate(activity, loginHint) + } + } + + override fun logout(callback: () -> Unit) { + isLoading.value = true + refreshTokenManager.cancelLastTimer() + + GlobalScope.launch(Dispatchers.IO) { + + val logoutCookies = getDomainCookie(baseUrl) + val logoutAccessToken = accessToken.value + + if (logoutCookies != null && + logoutAccessToken != null && + isEmbeddedMode + ) { + api.logout(logoutCookies, logoutAccessToken) + } + + isAuthenticated.value = false + accessToken.value = null + refreshToken.value = null + user.value = null + credentialManager.clear() + + val handler = Handler(Looper.getMainLooper()) + handler.post { + isLoading.value = false + callback() + } + + } + + } + + + override fun directLoginAction( + activity: Activity, + type: String, + data: String, + callback: (() -> Unit)? + ) { + if (isEmbeddedMode) { + EmbeddedAuthActivity.directLoginAction(activity, type, data, callback) + } else { + Log.w(TAG, "Direct login action is not supported in non-embedded mode") + } + } + + + override fun switchTenant(tenantId: String, callback: (Boolean) -> Unit) { + Log.d(TAG, "switchTenant()") + GlobalScope.launch(Dispatchers.IO) { + val handler = Handler(Looper.getMainLooper()) + + isLoading.value = true + try { + api.switchTenant(tenantId) + } catch (e: Exception) { + Log.e(TAG, "Failed to send switch tenant request", e) + handler.post { + isLoading.value = false + callback(false) + } + return@launch + } + + val success = refreshTokenIfNeeded() + + handler.post { + isLoading.value = false + callback(success) + } + } + } + + override fun refreshTokenIfNeeded(): Boolean { + + Log.d(TAG, "refreshTokenIfNeeded()") + + return try { + this.sendRefreshToken() + } catch (e: Exception) { + Log.e(TAG, "Failed to send refresh token request", e) + false + } + } + + + fun reinitWithRegion() { + this.initializeSubscriptions() + } + + + private fun setCredentials(accessToken: String, refreshToken: String) { + + if (credentialManager.save(CredentialKeys.REFRESH_TOKEN, refreshToken) + && credentialManager.save(CredentialKeys.ACCESS_TOKEN, accessToken) + ) { + + val decoded = JWTHelper.decode(accessToken) + val user = api.me() + + this.refreshToken.value = refreshToken + this.accessToken.value = accessToken + this.user.value = user + this.isAuthenticated.value = true + this.pendingAppLink = null + + // Cancel previous job if it exists + refreshTokenManager.cancelLastTimer() + + + if (decoded.exp > 0) { + val offset = decoded.exp.calculateTimerOffset() + Log.d(TAG, "setCredentials, schedule for $offset") + + refreshTokenManager.scheduleTimer(offset) + } + } else { + this.refreshToken.value = null + this.accessToken.value = null + this.user.value = null + this.isAuthenticated.value = false + } + + this.isLoading.value = false + this.initializing.value = false + } + + fun handleHostedLoginCallback( + code: String, + webView: WebView? = null, + activity: Activity? = null, + callback: (() -> Unit)? = null, + ): Boolean { + + val codeVerifier = credentialManager.getCodeVerifier() + val redirectUrl = Constants.oauthCallbackUrl(baseUrl) + + if (codeVerifier == null) { + return false + } + + GlobalScope.launch(Dispatchers.IO) { + val data = api.exchangeToken(code, redirectUrl, codeVerifier) + if (data != null) { + setCredentials(data.access_token, data.refresh_token) + callback?.invoke() + } else { + Log.e(TAG, "Failed to exchange token") + if (webView != null) { + val authorizeUrl = AuthorizeUrlGenerator() + val url = authorizeUrl.generate() + Handler(Looper.getMainLooper()).post { + webView.loadUrl(url.first) + } + } else if (activity != null && callback == null) { + login(activity) + } + + } + } + + return true + } + + private fun getDomainCookie(siteName: String): String? { + val cookieManager = CookieManager.getInstance() + return cookieManager.getCookie(siteName); + } + + + private fun initializeSubscriptions() { + Log.d(TAG, "initializeSubscriptions") + Observable.merge( + isLoading.observable, + isAuthenticated.observable, + initializing.observable, + ).subscribe { + showLoader.value = initializing.value || (!isAuthenticated.value && isLoading.value) + } + + GlobalScope.launch(Dispatchers.IO) { + + val accessTokenSaved = credentialManager.get(CredentialKeys.ACCESS_TOKEN) + val refreshTokenSaved = credentialManager.get(CredentialKeys.REFRESH_TOKEN) + + if (accessTokenSaved != null && refreshTokenSaved != null) { + accessToken.value = accessTokenSaved + refreshToken.value = refreshTokenSaved + + if (!refreshTokenIfNeeded()) { + accessToken.value = null + refreshToken.value = null + initializing.value = false + isLoading.value = false + } + + } else { + initializing.value = false + isLoading.value = false + } + } + } + + fun sendRefreshToken(): Boolean { + val refreshToken = this.refreshToken.value ?: return false + this.refreshingToken.value = true + try { + + val data = api.refreshToken(refreshToken) + return if (data != null) { + setCredentials(data.access_token, data.refresh_token) + true + } else { + Log.e(TAG, "Failed to refresh token, data = null") + false + } + } finally { + this.refreshingToken.value = false + } + } + + private fun refreshTokenWhenNeeded() { + val accessToken = this.accessToken.value + + if (this.refreshToken.value == null) { + return + } + + + refreshTokenManager.cancelLastTimer() + + if (accessToken == null) { + // when we have valid refreshToken without accessToken => failed to refresh in background + GlobalScope.launch(Dispatchers.IO) { + sendRefreshToken() + } + return; + } + + val decoded = JWTHelper.decode(accessToken) + if (decoded.exp > 0) { + val offset = decoded.exp.calculateTimerOffset() + if (offset <= 0) { + Log.d(TAG, "Refreshing Token...") + GlobalScope.launch(Dispatchers.IO) { + sendRefreshToken() + } + } else { + Log.d(TAG, "Schedule Refreshing Token for $offset") + refreshTokenManager.scheduleTimer(offset) + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/frontegg/android/services/FronteggInnerStorage.kt b/android/src/main/java/com/frontegg/android/services/FronteggInnerStorage.kt new file mode 100644 index 0000000..9e224e3 --- /dev/null +++ b/android/src/main/java/com/frontegg/android/services/FronteggInnerStorage.kt @@ -0,0 +1,74 @@ +package com.frontegg.android.services + +import android.util.Log +import com.frontegg.android.regions.RegionConfig + +class FronteggInnerStorage { + val baseUrl: String + get() = data["baseUrl"] as String + val clientId: String + get() = data["clientId"] as String + val applicationId: String? + get() = data["applicationId"] as String? + + val isEmbeddedMode: Boolean + get() = data["isEmbeddedMode"] as Boolean + val regions: List + get() { + return data["regions"] as List? ?: return listOf() + } + val selectedRegion: RegionConfig? + get() = data["selectedRegion"] as RegionConfig? + val handleLoginWithSocialLogin: Boolean + get() = data["handleLoginWithSocialLogin"] as Boolean + val customUserAgent: String? + get() = data["customUserAgent"] as String? + val handleLoginWithSSO: Boolean + get() = data["handleLoginWithSSO"] as Boolean + val shouldPromptSocialLoginConsent: Boolean + get() = data["shouldPromptSocialLoginConsent"] as Boolean + val useAssetsLinks: Boolean + get() = data["useAssetsLinks"] as Boolean + val useChromeCustomTabs: Boolean + get() = data["useChromeCustomTabs"] as Boolean + val mainActivityClass: Class<*>? + get() = data["mainActivityClass"] as Class<*>? + val packageName: String + get() = data["packageName"] as String + + fun fill( + baseUrl: String, + clientId: String, + applicationId: String?, + isEmbeddedMode: Boolean = true, + regions: List = listOf(), + selectedRegion: RegionConfig? = null, + handleLoginWithSocialLogin: Boolean = true, + customUserAgent: String? = null, + handleLoginWithSSO: Boolean = false, + shouldPromptSocialLoginConsent: Boolean = true, + useAssetsLinks: Boolean = false, + useChromeCustomTabs: Boolean = false, + mainActivityClass: Class<*>? = null, + packageName: String + ) { + data["baseUrl"] = baseUrl + data["clientId"] = clientId + data["applicationId"] = applicationId + data["isEmbeddedMode"] = isEmbeddedMode + data["regions"] = regions + data["selectedRegion"] = selectedRegion + data["handleLoginWithSocialLogin"] = handleLoginWithSocialLogin + data["customUserAgent"] = customUserAgent + data["handleLoginWithSSO"] = handleLoginWithSSO + data["shouldPromptSocialLoginConsent"] = shouldPromptSocialLoginConsent + data["useAssetsLinks"] = useAssetsLinks + data["useChromeCustomTabs"] = useChromeCustomTabs + data["mainActivityClass"] = mainActivityClass + data["packageName"] = packageName + } + + companion object { + private val data = mutableMapOf() + } +} \ No newline at end of file diff --git a/android/src/main/java/com/frontegg/android/services/FronteggRefreshTokenTimer.kt b/android/src/main/java/com/frontegg/android/services/FronteggRefreshTokenTimer.kt new file mode 100644 index 0000000..3818843 --- /dev/null +++ b/android/src/main/java/com/frontegg/android/services/FronteggRefreshTokenTimer.kt @@ -0,0 +1,77 @@ +package com.frontegg.android.services + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context +import android.util.Log +import com.frontegg.android.utils.FronteggCallback +import java.time.Instant +import java.util.Timer +import java.util.TimerTask +import kotlin.concurrent.schedule + +class FronteggRefreshTokenTimer( + private val context: Context, + private val appLifecycle: FronteggAppLifecycle +) { + val refreshTokenIfNeeded = FronteggCallback() + + private var refreshTokenJob: JobInfo? = null + private var timerTask: TimerTask? = null + + fun cancelLastTimer() { + Log.d(TAG, "Cancel Last Timer") + if (timerTask != null) { + timerTask?.cancel() + timerTask = null + } + if (refreshTokenJob != null) { + + val jobScheduler = + context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + jobScheduler.cancel(JOB_ID) + this.refreshTokenJob = null + } + } + + fun scheduleTimer(offset: Long) { + lastJobStart = Instant.now().toEpochMilli() + if (appLifecycle.appInForeground) { + Log.d(TAG, "[foreground] Start Timer task (${offset} ms)") + + this.timerTask = Timer().schedule(offset) { + Log.d( + TAG, + "[foreground] Job started, (${ + Instant.now().toEpochMilli() - lastJobStart + } ms)" + ) + refreshTokenIfNeeded.trigger() + } + + } else { + Log.d(TAG, "[background] Start Job Scheduler task (${offset} ms)") + val jobScheduler = + context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + // Schedule the job + val jobInfo = JobInfo.Builder( + JOB_ID, ComponentName(context, RefreshTokenJobService::class.java) + ) + .setMinimumLatency(offset / 2) // Schedule the job to run after the offset + .setOverrideDeadline(offset) // Add a buffer to the deadline + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) // Require network + .setBackoffCriteria(10000, JobInfo.BACKOFF_POLICY_LINEAR) + .build() + this.refreshTokenJob = jobInfo + jobScheduler.schedule(jobInfo) + } + } + + companion object { + private val TAG = FronteggRefreshTokenTimer::class.java.simpleName + const val JOB_ID = 1234 // Unique ID for the JobService + var lastJobStart: Long = Instant.now().toEpochMilli() + } + +} \ No newline at end of file diff --git a/android/src/main/java/com/frontegg/android/services/RefreshTokenService.kt b/android/src/main/java/com/frontegg/android/services/RefreshTokenJobService.kt similarity index 64% rename from android/src/main/java/com/frontegg/android/services/RefreshTokenService.kt rename to android/src/main/java/com/frontegg/android/services/RefreshTokenJobService.kt index 80f44fb..7962ca3 100644 --- a/android/src/main/java/com/frontegg/android/services/RefreshTokenService.kt +++ b/android/src/main/java/com/frontegg/android/services/RefreshTokenJobService.kt @@ -4,19 +4,23 @@ package com.frontegg.android.services import android.app.job.JobParameters import android.app.job.JobService import android.util.Log -import com.frontegg.android.FronteggApp -import com.frontegg.android.FronteggAuth import java.net.SocketTimeoutException import java.time.Instant -class RefreshTokenService : JobService() { +class RefreshTokenJobService : JobService() { + companion object { - private val TAG = RefreshTokenService::class.java.simpleName + private val TAG = RefreshTokenJobService::class.java.simpleName } override fun onStartJob(params: JobParameters?): Boolean { - Log.d(TAG, "Job started, (${Instant.now().toEpochMilli() - FronteggApp.getInstance().lastJobStart} ms)") + Log.d( + TAG, + "Job started, (${ + Instant.now().toEpochMilli() - FronteggRefreshTokenTimer.lastJobStart + } ms)" + ) performBackgroundTask(params) return true } @@ -32,19 +36,19 @@ class RefreshTokenService : JobService() { Thread { var isError = false try { - FronteggAuth.instance.sendRefreshToken() + FronteggAuthService.instance.sendRefreshToken() Log.d(TAG, "Job finished") } catch (e: Exception) { Log.e(TAG, "Job unknown error occurred", e) // Catch unhandled exception - FronteggAuth.instance.accessToken.value = null - FronteggAuth.instance.isLoading.value = true + FronteggAuthService.instance.accessToken.value = null + FronteggAuthService.instance.isLoading.value = true isError = true if (e is SocketTimeoutException) { - FronteggAuth.instance.scheduleTimer(20000) + FronteggAuthService.instance.refreshTokenManager.scheduleTimer(20000) } } finally { - FronteggApp.getInstance().lastJobStart = Instant.now().toEpochMilli() + FronteggRefreshTokenTimer.lastJobStart = Instant.now().toEpochMilli() // Notify the job manager that the job has been completed jobFinished(params, isError) } diff --git a/android/src/main/java/com/frontegg/android/utils/AuthorizeUrlGenerator.kt b/android/src/main/java/com/frontegg/android/utils/AuthorizeUrlGenerator.kt index fc1f073..d954fcc 100644 --- a/android/src/main/java/com/frontegg/android/utils/AuthorizeUrlGenerator.kt +++ b/android/src/main/java/com/frontegg/android/utils/AuthorizeUrlGenerator.kt @@ -2,7 +2,8 @@ package com.frontegg.android.utils import android.net.Uri import android.util.Log -import com.frontegg.android.FronteggApp +import com.frontegg.android.services.FronteggAuthService +import com.frontegg.android.services.FronteggInnerStorage import java.security.MessageDigest import java.util.Base64 @@ -11,9 +12,13 @@ class AuthorizeUrlGenerator { private val TAG = AuthorizeUrlGenerator::class.java.simpleName } - private var clientId: String = FronteggApp.getInstance().clientId - private var applicationId: String? = FronteggApp.getInstance().applicationId - private var baseUrl: String = FronteggApp.getInstance().baseUrl + private var storage = FronteggInnerStorage() + private val clientId: String + get() = storage.clientId + private val applicationId: String? + get() = storage.applicationId + private val baseUrl: String + get() = storage.baseUrl private fun createRandomString(length: Int = 16): String { val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') @@ -42,7 +47,7 @@ class AuthorizeUrlGenerator { preserveCodeVerifier: Boolean? = false ): Pair { val nonce = createRandomString() - val credentialManager = FronteggApp.getInstance().credentialManager + val credentialManager = FronteggAuthService.instance.credentialManager val codeVerifier: String = if (preserveCodeVerifier == true) { @@ -89,8 +94,6 @@ class AuthorizeUrlGenerator { .build().toString() return Pair(authorizeUrl, codeVerifier) - - } } diff --git a/android/src/main/java/com/frontegg/android/utils/Constants.kt b/android/src/main/java/com/frontegg/android/utils/Constants.kt index d494b37..171cfa4 100644 --- a/android/src/main/java/com/frontegg/android/utils/Constants.kt +++ b/android/src/main/java/com/frontegg/android/utils/Constants.kt @@ -1,6 +1,6 @@ package com.frontegg.android.utils -import com.frontegg.android.FronteggApp +import com.frontegg.android.services.FronteggInnerStorage class ApiConstants { @@ -28,9 +28,9 @@ class Constants { fun oauthCallbackUrl(baseUrl: String): String { val host = baseUrl.substring("https://".length) - val app = FronteggApp.getInstance(); - val packageName = app.packageName - val useAssetsLinks = app.useAssetsLinks + val storage = FronteggInnerStorage(); + val packageName = storage.packageName + val useAssetsLinks = storage.useAssetsLinks return if (useAssetsLinks) { "https://${host}/oauth/account/redirect/android/${packageName}" } else { diff --git a/android/src/main/java/com/frontegg/android/utils/FronteggCallback.kt b/android/src/main/java/com/frontegg/android/utils/FronteggCallback.kt new file mode 100644 index 0000000..1af038e --- /dev/null +++ b/android/src/main/java/com/frontegg/android/utils/FronteggCallback.kt @@ -0,0 +1,19 @@ +package com.frontegg.android.utils + +class FronteggCallback { + private val callbacks = mutableListOf<() -> Unit>() + + fun addCallback(callback: () -> Unit) { + callbacks.add(callback) + } + + fun removeCallback(callback: () -> Unit) { + callbacks.remove(callback) + } + + fun trigger() { + for (callback in callbacks) { + callback() + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/frontegg/android/utils/ObservableValue.kt b/android/src/main/java/com/frontegg/android/utils/ObservableValue.kt index 521edd6..60ade94 100644 --- a/android/src/main/java/com/frontegg/android/utils/ObservableValue.kt +++ b/android/src/main/java/com/frontegg/android/utils/ObservableValue.kt @@ -1,35 +1,37 @@ package com.frontegg.android.utils -import android.os.Handler -import android.os.Looper import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.functions.Consumer import io.reactivex.rxjava3.subjects.PublishSubject class NullableObject(var value: K) -class ObservableValue(value: T) { +abstract class ReadOnlyObservableValue(value: T) { val observable: PublishSubject> = PublishSubject.create() - private var nullableObject: NullableObject - var value: T - set(newValue) { - nullableObject.value = newValue - observable.onNext(nullableObject) - } + protected var nullableObject: NullableObject = NullableObject(value) + open val value: T get() { return nullableObject.value } - init { - this.nullableObject = NullableObject(value) - } - fun subscribe(onNext: Consumer>): Disposable { observable.subscribe() val disposable = observable.subscribe(onNext) onNext.accept(nullableObject) return disposable } +} + +class ObservableValue(value: T) : ReadOnlyObservableValue(value) { + + override var value: T + get() { + return nullableObject.value + } + set(newValue) { + nullableObject.value = newValue + observable.onNext(nullableObject) + } } \ No newline at end of file diff --git a/android/src/main/java/com/frontegg/android/utils/extensions.kt b/android/src/main/java/com/frontegg/android/utils/extensions.kt new file mode 100644 index 0000000..573f13d --- /dev/null +++ b/android/src/main/java/com/frontegg/android/utils/extensions.kt @@ -0,0 +1,18 @@ +package com.frontegg.android.utils + +import java.time.Instant + +fun Long.calculateTimerOffset(): Long { + val expirationTime = this + val now: Long = Instant.now().toEpochMilli() + val remainingTime = (expirationTime * 1000) - now + + val minRefreshWindow = 20000 // minimum 20 seconds before exp + val adaptiveRefreshTime = remainingTime * 0.8 // 80% of remaining time + + return if (remainingTime > minRefreshWindow) { + adaptiveRefreshTime.toLong() + } else { + (remainingTime - minRefreshWindow).coerceAtLeast(0) + } +} \ No newline at end of file