diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/notifications/CustomUriNotification.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/notifications/CustomUriNotification.kt new file mode 100644 index 000000000..19b13f212 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/notifications/CustomUriNotification.kt @@ -0,0 +1,71 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.features.customuri.notifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE +import android.content.Intent +import android.util.Log +import androidx.core.app.NotificationCompat +import dev.firezone.android.R +import dev.firezone.android.core.presentation.MainActivity + +object CustomUriNotification { + private const val CHANNEL_ID = "firezone-authentication-status" + private const val CHANNEL_NAME = "firezone-authentication-status" + private const val CHANNEL_DESCRIPTION = "Firezone authentication status" + const val ID = 1338 + private const val TAG: String = "CustomUriNotification" + + fun update( + context: Context, + status: StatusType, + ): NotificationCompat.Builder { + Log.d(TAG, "update") + val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + + val chan = + NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ) + chan.description = CHANNEL_DESCRIPTION + manager.createNotificationChannel(chan) + + val notificationBuilder = + NotificationCompat.Builder(context, CHANNEL_ID) + .setContentIntent(configIntent(context)) + return status.applySettings(notificationBuilder) + } + + private fun configIntent(context: Context): PendingIntent { + Log.d(TAG, "configIntent") + val intent = Intent(context, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + data class Error(val message: String) : StatusType() { + override fun applySettings(builder: NotificationCompat.Builder) = + builder.apply { + setSmallIcon(R.drawable.ic_firezone_logo) + setContentTitle("Authentication Error") + setContentText(message) + setStyle(NotificationCompat.BigTextStyle().bigText(message)) + setPriority(NotificationCompat.PRIORITY_HIGH) + setAutoCancel(true) + } + } + + sealed class StatusType { + abstract fun applySettings(builder: NotificationCompat.Builder): NotificationCompat.Builder + } +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriHandlerActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriHandlerActivity.kt index 99909f9d8..2f9aeb29f 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriHandlerActivity.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriHandlerActivity.kt @@ -1,6 +1,8 @@ /* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ package dev.firezone.android.features.customuri.ui +import android.app.NotificationManager +import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels @@ -9,6 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint import dev.firezone.android.R import dev.firezone.android.core.presentation.MainActivity import dev.firezone.android.databinding.ActivityCustomUriHandlerBinding +import dev.firezone.android.features.customuri.notifications.CustomUriNotification import dev.firezone.android.tunnel.TunnelService @AndroidEntryPoint @@ -33,6 +36,10 @@ class CustomUriHandlerActivity : AppCompatActivity(R.layout.activity_custom_uri_ Intent(this, MainActivity::class.java), ) } + is CustomUriViewModel.ViewAction.AuthFlowError -> { + notifyError("Errors occurred during authentication:\n${action.errors.joinToString(separator = "\n")}") + startActivity(Intent(this, MainActivity::class.java)) + } else -> { throw IllegalStateException("Unknown action: $action") } @@ -41,4 +48,10 @@ class CustomUriHandlerActivity : AppCompatActivity(R.layout.activity_custom_uri_ finish() } } + + private fun notifyError(message: String) { + val notification = CustomUriNotification.update(this, CustomUriNotification.Error(message)).build() + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(CustomUriNotification.ID, notification) + } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriViewModel.kt index 4eff6939c..20c93cbc4 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriViewModel.kt @@ -14,7 +14,6 @@ import dev.firezone.android.core.data.Repository import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch -import java.lang.IllegalStateException import javax.inject.Inject @HiltViewModel @@ -28,39 +27,47 @@ internal class CustomUriViewModel fun parseCustomUri(intent: Intent) { viewModelScope.launch { + val accumulatedErrors = mutableListOf() + val error = { msg: String -> + accumulatedErrors += msg + Firebase.crashlytics.log(msg) + Log.e(TAG, msg) + } + when (intent.data?.host) { PATH_CALLBACK -> { intent.data?.getQueryParameter(QUERY_ACTOR_NAME)?.let { actorName -> - Log.d("CustomUriViewModel", "Found actor name: $actorName") + Log.d(TAG, "Found actor name: $actorName") repo.saveActorName(actorName).collect() } intent.data?.getQueryParameter(QUERY_CLIENT_STATE)?.let { state -> if (repo.validateState(state).firstOrNull() == true) { - Log.d("CustomUriViewModel", "Valid state parameter. Continuing to save state...") + Log.d(TAG, "Valid state parameter. Continuing to save state...") } else { - throw IllegalStateException("Invalid state parameter $state! Authentication will not succeed...") + error("Invalid state parameter $state") } - intent.data?.getQueryParameter(QUERY_CLIENT_AUTH_FRAGMENT)?.let { fragment -> - if (fragment.isNotBlank()) { - Log.d("CustomUriViewModel", "Found valid auth fragment in response") + } + intent.data?.getQueryParameter(QUERY_CLIENT_AUTH_FRAGMENT)?.let { fragment -> + if (fragment.isNotBlank()) { + Log.d(TAG, "Found valid auth fragment in response") - // Save token, then clear nonce and state since we don't - // need to keep them around anymore - repo.saveToken(fragment).collect() - repo.clearNonce() - repo.clearState() - - actionMutableLiveData.postValue(ViewAction.AuthFlowComplete) - } else { - throw IllegalStateException("Invalid auth fragment $fragment! Authentication will not succeed...") - } + // Save token, then clear nonce and state since we don't + // need to keep them around anymore + repo.saveToken(fragment).collect() + repo.clearNonce() + repo.clearState() + } else { + error("Auth fragment was empty") } } } - else -> { - Firebase.crashlytics.log("Unknown path segment: ${intent.data?.lastPathSegment}") - Log.e("CustomUriViewModel", "Unknown path segment: ${intent.data?.lastPathSegment}") - } + else -> error("Unknown path segment: ${intent.data?.lastPathSegment}") + } + if (accumulatedErrors.isNotEmpty()) { + actionMutableLiveData.postValue(ViewAction.AuthFlowError(accumulatedErrors)) + } else { + Log.d(TAG, "Auth flow complete") + actionMutableLiveData.postValue(ViewAction.AuthFlowComplete) } } } @@ -70,9 +77,15 @@ internal class CustomUriViewModel private const val QUERY_CLIENT_STATE = "state" private const val QUERY_CLIENT_AUTH_FRAGMENT = "fragment" private const val QUERY_ACTOR_NAME = "actor_name" + + private const val TAG = "CustomUriViewModel" } internal sealed class ViewAction { - object AuthFlowComplete : ViewAction() + data object AuthFlowComplete : ViewAction() + + data class AuthFlowError(val errors: Iterable) : ViewAction() { + constructor(vararg errors: String) : this(errors.toList()) + } } }