From 8be81649b2ddc035674685b2a8a11743691a6bfa Mon Sep 17 00:00:00 2001 From: Infi Date: Fri, 19 Jan 2024 23:48:39 +0100 Subject: [PATCH] feat: leverage android 12 splash screen API Signed-off-by: Infi --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 2 +- .../chat/revolt/activities/MainActivity.kt | 219 +++++++++++++++++- .../main/java/chat/revolt/api/RevoltAPI.kt | 6 +- .../chat/revolt/api/internals/Extensions.kt | 11 + .../java/chat/revolt/api/routes/user/User.kt | 4 +- .../screens/DefaultDestinationScreen.kt | 47 ++++ .../revolt/screens/chat/ChatRouterScreen.kt | 7 +- .../screens/register/OnboardingScreen.kt | 11 +- app/src/main/res/values-night/colors.xml | 1 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/themes.xml | 9 +- 12 files changed, 300 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/chat/revolt/api/internals/Extensions.kt create mode 100644 app/src/main/java/chat/revolt/screens/DefaultDestinationScreen.kt diff --git a/app/build.gradle b/app/build.gradle index 7d9dd955..4303f0a0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -236,6 +236,7 @@ dependencies { implementation "androidx.webkit:webkit:1.9.0" implementation "androidx.datastore:datastore-preferences:1.1.0-alpha07" implementation "androidx.datastore:datastore:1.1.0-alpha07" + implementation "androidx.core:core-splashscreen:1.0.1" // Libraries used for legacy View-based UI implementation "androidx.constraintlayout:constraintlayout:2.2.0-alpha13" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3117e179..65796797 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,7 +43,7 @@ android:name=".activities.MainActivity" android:exported="true" android:windowSoftInputMode="adjustResize" - android:theme="@style/Theme.Revolt"> + android:theme="@style/Theme.Revolt.Starting"> diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index dafe98a3..ce4f24f0 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -1,7 +1,14 @@ package chat.revolt.activities +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.os.Bundle +import android.util.Log +import android.widget.Toast import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.EaseInOutExpo import androidx.compose.animation.core.FiniteAnimationSpec @@ -14,20 +21,31 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.dialog import androidx.navigation.compose.rememberNavController import chat.revolt.BuildConfig +import chat.revolt.R +import chat.revolt.RevoltApplication +import chat.revolt.api.RevoltAPI +import chat.revolt.api.RevoltHttp +import chat.revolt.api.routes.onboard.needsOnboarding import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.SyncedSettings import chat.revolt.ndk.NativeLibraries +import chat.revolt.persistence.KVStorage +import chat.revolt.screens.DefaultDestinationScreen import chat.revolt.screens.SplashScreen import chat.revolt.screens.about.AboutScreen import chat.revolt.screens.about.AttributionScreen @@ -50,11 +68,151 @@ import chat.revolt.screens.settings.ProfileSettingsScreen import chat.revolt.screens.settings.SessionSettingsScreen import chat.revolt.screens.settings.SettingsScreen import chat.revolt.ui.theme.RevoltTheme +import com.google.android.material.color.DynamicColors import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import io.ktor.client.request.get import io.sentry.android.core.SentryAndroid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +@SuppressLint("StaticFieldLeak") +class MainActivityViewModel @Inject constructor( + private val kvStorage: KVStorage, + @ApplicationContext private val context: Context +) : ViewModel() { + val nextDestination = MutableStateFlow(null) + var isConnected = MutableStateFlow(false) + val isReady = MutableStateFlow(false) + + private fun hasInternetConnection(): Boolean { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val network = connectivityManager.activeNetwork ?: return false + val capabilities = + connectivityManager.getNetworkCapabilities(network) ?: return false + + return when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> true + else -> false + } + } + + private suspend fun canReachRevolt(): Boolean { + val res = RevoltHttp.get("/") + return res.status.value == 200 + } + + private suspend fun startWithDestination(destination: String) { + nextDestination.emit(destination) + isReady.emit(true) + } + + private suspend fun startWithoutDestination() { + isReady.emit(true) + } + + fun checkLoggedInState() { + viewModelScope.launch { + Log.d("MainActivity", "Checking logged in state") + + isConnected.emit(hasInternetConnection()) + + Log.d("MainActivity", "Checking if we can reach Revolt") + + if (!isConnected.value) return@launch startWithoutDestination() + + Log.d("MainActivity", "We can reach Revolt, checking if we're logged in") + + val token = kvStorage.get("sessionToken") + ?: return@launch startWithDestination("login/greeting") + val id = kvStorage.get("sessionId") ?: "" + + Log.d( + "MainActivity", + "We have a session token, checking if it's valid and if we can still reach Revolt" + ) + + val canReachRevolt = canReachRevolt() + val valid = try { + RevoltAPI.checkSessionToken(token) + } catch (e: Throwable) { + false + } + + if (canReachRevolt && !valid) { + Log.d("MainActivity", "Session token is invalid, clearing session") + Toast.makeText( + context, + context.getString(R.string.token_invalid_toast), + Toast.LENGTH_SHORT + ).show() + kvStorage.remove("sessionToken") + kvStorage.remove("sessionId") + startWithDestination("login/greeting") + } else { + try { + Log.d("MainActivity", "Session token is valid, checking onboarding state") + val onboard = needsOnboarding(token) + if (onboard) { + Log.d("MainActivity", "Onboarding state is incomplete, starting onboarding") + startWithDestination("register/onboarding") + return@launch + } + } catch (e: Exception) { + Log.e("MainActivity", "Failed to check onboarding state, clearing session", e) + kvStorage.remove("sessionToken") + kvStorage.remove("sessionId") + startWithDestination("login/greeting") + } + + try { + Log.d("MainActivity", "Onboarding state is complete, logging in") + RevoltAPI.loginAs(token) + RevoltAPI.setSessionId(id) + startWithDestination("chat") + } catch (e: Exception) { + Log.e("MainActivity", "Failed to login, clearing session", e) + kvStorage.remove("sessionToken") + kvStorage.remove("sessionId") + startWithDestination("login/greeting") + } + } + } + } + + fun updateNextDestination(destination: String) { + viewModelScope.launch { + nextDestination.emit(null) + nextDestination.emit(destination) + } + } + + init { + Log.d("MainActivity", "Starting up") + checkLoggedInState() + } +} @AndroidEntryPoint class MainActivity : FragmentActivity() { + private val viewModel by viewModels() + + // Fix for SDK >=31, where core-splashscreen accidentally removes dynamic colours + // See the other one in DefaultDestinationScreen.kt + override fun onResume() { + super.onResume() + DynamicColors.applyToActivityIfAvailable(this) + DynamicColors.applyToActivitiesIfAvailable(RevoltApplication.instance) + } + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -66,9 +224,21 @@ class MainActivity : FragmentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) + installSplashScreen().apply { + setKeepOnScreenCondition { + !viewModel.isReady.value + } + } + setContent { val windowSizeClass = calculateWindowSizeClass(this) - AppEntrypoint(windowSizeClass) + AppEntrypoint( + windowSizeClass, + viewModel.nextDestination.collectAsState().value, + viewModel.isConnected.collectAsState().value, + viewModel::checkLoggedInState, + viewModel::updateNextDestination + ) } } @@ -85,7 +255,13 @@ val RevoltTweenDp: FiniteAnimationSpec = tween(400, easing = EaseInOutExpo) val RevoltTweenColour: FiniteAnimationSpec = tween(400, easing = EaseInOutExpo) @Composable -fun AppEntrypoint(windowSizeClass: WindowSizeClass) { +fun AppEntrypoint( + windowSizeClass: WindowSizeClass, + nextDestination: String?, + isConnected: Boolean, + onRetryConnection: () -> Unit, + onUpdateNextDestination: (String) -> Unit = {} +) { val navController = rememberNavController() RevoltTheme( @@ -98,7 +274,7 @@ fun AppEntrypoint(windowSizeClass: WindowSizeClass) { ) { NavHost( navController = navController, - startDestination = "splash", + startDestination = "default", enterTransition = { slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.Left, @@ -124,6 +300,14 @@ fun AppEntrypoint(windowSizeClass: WindowSizeClass) { ) } ) { + composable("default") { + DefaultDestinationScreen( + navController, + nextDestination, + isConnected, + onRetryConnection + ) + } composable("splash") { SplashScreen(navController) } composable("login/greeting") { LoginGreetingScreen(navController) } @@ -143,9 +327,34 @@ fun AppEntrypoint(windowSizeClass: WindowSizeClass) { RegisterVerifyScreen(navController, email) } - composable("register/onboarding") { OnboardingScreen(navController) } + composable("register/onboarding") { + OnboardingScreen( + navController, + onOnboardingComplete = { + onUpdateNextDestination("chat") + navController.popBackStack( + navController.graph.startDestinationRoute!!, + inclusive = true + ) + navController.navigate("default") + } + ) + } - composable("chat") { ChatRouterScreen(navController, windowSizeClass) } + composable("chat") { + ChatRouterScreen( + navController, + windowSizeClass, + onNullifiedUser = { + onRetryConnection() + navController.popBackStack( + navController.graph.startDestinationRoute!!, + inclusive = true + ) + navController.navigate("default") + } + ) + } composable("discover") { DiscoverScreen(navController) } diff --git a/app/src/main/java/chat/revolt/api/RevoltAPI.kt b/app/src/main/java/chat/revolt/api/RevoltAPI.kt index 6a70279b..32a9d9ef 100644 --- a/app/src/main/java/chat/revolt/api/RevoltAPI.kt +++ b/app/src/main/java/chat/revolt/api/RevoltAPI.kt @@ -93,7 +93,11 @@ val RevoltHttp = HttpClient(OkHttp) { engine { addInterceptor { chain -> val request = chain.request().newBuilder() - .header(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) + .apply { + if (chain.request().headers[RevoltAPI.TOKEN_HEADER_NAME] == null) { + header(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) + } + } .build() chain.proceed(request) } diff --git a/app/src/main/java/chat/revolt/api/internals/Extensions.kt b/app/src/main/java/chat/revolt/api/internals/Extensions.kt new file mode 100644 index 00000000..3550b0d6 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/internals/Extensions.kt @@ -0,0 +1,11 @@ +package chat.revolt.api.internals + +import android.content.Context +import android.content.ContextWrapper +import androidx.activity.ComponentActivity + +fun Context.getComponentActivity(): ComponentActivity? = when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.getComponentActivity() + else -> null +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/user/User.kt b/app/src/main/java/chat/revolt/api/routes/user/User.kt index 2f81cb2a..80f6d270 100644 --- a/app/src/main/java/chat/revolt/api/routes/user/User.kt +++ b/app/src/main/java/chat/revolt/api/routes/user/User.kt @@ -25,7 +25,7 @@ suspend fun fetchSelf(): User { try { val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) - throw Error(error.type) + throw Exception(error.type) } catch (e: SerializationException) { // Not an error } @@ -33,7 +33,7 @@ suspend fun fetchSelf(): User { val user = RevoltJson.decodeFromString(User.serializer(), response) if (user.id == null) { - throw Error("Self user ID is null") + throw Exception("Self user ID is null") } RevoltAPI.userCache[user.id] = user diff --git a/app/src/main/java/chat/revolt/screens/DefaultDestinationScreen.kt b/app/src/main/java/chat/revolt/screens/DefaultDestinationScreen.kt new file mode 100644 index 00000000..ba0e2b34 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/DefaultDestinationScreen.kt @@ -0,0 +1,47 @@ +package chat.revolt.screens + +import android.app.Activity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavController +import chat.revolt.RevoltApplication +import chat.revolt.api.internals.getComponentActivity +import chat.revolt.components.screens.splash.DisconnectedScreen +import com.google.android.material.color.DynamicColors + +@Composable +fun DefaultDestinationScreen( + navController: NavController, + nextDestination: String? = null, + isConnected: Boolean = false, + onRetryConnection: () -> Unit = {} +) { + val context = LocalContext.current + + if (!isConnected) { + DisconnectedScreen( + onRetry = { + onRetryConnection() + } + ) + return + } + + LaunchedEffect(nextDestination) { + nextDestination?.let { + // Fix for SDK >=31, where core-splashscreen accidentally removes dynamic colours + // See the other one in MainActivity.kt + DynamicColors.applyToActivityIfAvailable(context.getComponentActivity() as Activity) + DynamicColors.applyToActivitiesIfAvailable(RevoltApplication.instance) + + navController.popBackStack(navController.graph.startDestinationRoute!!, true) + navController.navigate(it) + } + } + + Box(Modifier.fillMaxSize()) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index cc62853a..275bef4c 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -293,6 +293,7 @@ class ChatRouterViewModel @Inject constructor( fun ChatRouterScreen( topNav: NavController, windowSizeClass: WindowSizeClass, + onNullifiedUser: () -> Unit, viewModel: ChatRouterViewModel = hiltViewModel() ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -385,11 +386,7 @@ fun ChatRouterScreen( .distinctUntilChanged() .collect { selfId -> if (selfId == null) { - topNav.popBackStack( - topNav.graph.startDestinationRoute!!, - inclusive = true - ) - topNav.navigate("splash") + onNullifiedUser() } } } diff --git a/app/src/main/java/chat/revolt/screens/register/OnboardingScreen.kt b/app/src/main/java/chat/revolt/screens/register/OnboardingScreen.kt index d2757df7..b703dee4 100644 --- a/app/src/main/java/chat/revolt/screens/register/OnboardingScreen.kt +++ b/app/src/main/java/chat/revolt/screens/register/OnboardingScreen.kt @@ -32,7 +32,7 @@ import chat.revolt.persistence.KVStorage import kotlinx.coroutines.launch @Composable -fun OnboardingScreen(navController: NavController) { +fun OnboardingScreen(navController: NavController, onOnboardingComplete: () -> Unit) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -40,9 +40,12 @@ fun OnboardingScreen(navController: NavController) { val error = remember { mutableStateOf("") } fun onboardingComplete() { - navController.navigate("splash") { - popUpTo("register/onboarding") { inclusive = true } - } + onOnboardingComplete() + navController.popBackStack( + navController.graph.startDestinationRoute!!, + inclusive = true + ) + navController.navigate("default") } suspend fun onboard() { diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index ca922270..ecc6d9a6 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,4 +1,5 @@ #FFFFFFFF + #FF131722 \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d61065ca..f8c8463a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,5 +2,6 @@ #FFDA4E5B #FF131722 + #FFFFFFFF #FF000000 \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 23e11c0d..bb584711 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,8 +1,15 @@ - + + + \ No newline at end of file