diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbcf0e169..9d2db540d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.hilt) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) } @@ -163,6 +164,7 @@ dependencies { implementation(platform(libs.compose.bom)) implementation(libs.compose.material3) implementation(libs.compose.materialIconsExtended) + implementation(libs.compose.navigation) implementation(libs.compose.runtime.livedata) debugImplementation(libs.compose.ui.tooling) implementation(libs.compose.ui.toolingPreview) @@ -189,6 +191,7 @@ dependencies { @Suppress("RedundantSuppression") implementation(libs.dnsjava) implementation(libs.guava) + implementation(libs.kotlinx.serialization) implementation(libs.mikepenz.aboutLibraries) implementation(libs.nsk90.kstatemachine) implementation(libs.okhttp.base) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 173b80fc0..6bbd778c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,23 +62,35 @@ + + + + + + + + + + android:parentActivityName=".ui.MainActivity"/> @@ -106,7 +118,7 @@ @@ -134,7 +146,7 @@ + android:parentActivityName=".ui.MainActivity" /> - if (cancelled) - finish() - } - - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // handle "Sync all" intent from launcher shortcut - val syncAccounts = intent.action == Intent.ACTION_SYNC + val uri = Destination.Accounts.PATH.toUri().buildUpon() + val syncOnLaunch = intent.action == Intent.ACTION_SYNC + if (syncOnLaunch) + uri.appendQueryParameter("syncAccounts", "true") - setContent { - AccountsScreen( - initialSyncAccounts = syncAccounts, - onShowAppIntro = { - introActivityLauncher.launch(null) - }, - accountsDrawerHandler = accountsDrawerHandler, - onAddAccount = { - startActivity(Intent(this, LoginActivity::class.java)) - }, - onShowAccount = { account -> - val intent = Intent(this, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - startActivity(intent) - }, - onManagePermissions = { - startActivity(Intent(this, PermissionsActivity::class.java)) - } - ) - } + MainActivity.legacyRedirect( + activity = this, + uri = uri.build() + ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt index dc718ac99..d0923ea8e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt @@ -50,6 +50,7 @@ import java.util.logging.Logger class AccountsModel @AssistedInject constructor( @Assisted val syncAccountsOnInit: Boolean, private val accountRepository: AccountRepository, + internal val accountsDrawerHandler: AccountsDrawerHandler, @ApplicationContext val context: Context, private val db: AppDatabase, introPageFactory: IntroPageFactory, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt index e0ec50d83..8bd655234 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt @@ -6,11 +6,13 @@ package at.bitfire.davdroid.ui import android.Manifest import android.accounts.Account +import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Build import android.provider.Settings import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -73,20 +75,55 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.account.AccountActivity import at.bitfire.davdroid.ui.account.AccountProgress import at.bitfire.davdroid.ui.composable.ActionCard import at.bitfire.davdroid.ui.composable.ProgressBar +import at.bitfire.davdroid.ui.intro.IntroActivity +import at.bitfire.davdroid.ui.setup.LoginActivity import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import kotlinx.coroutines.delay import kotlinx.coroutines.launch +@Composable +fun AccountsScreen( + initialSyncAccounts: Boolean +) { + val context = LocalContext.current + val activity = context as? Activity + + val introActivityLauncher = rememberLauncherForActivityResult(IntroActivity.Contract) { cancelled -> + if (cancelled) activity?.finish() + } + + AccountsScreen( + initialSyncAccounts = initialSyncAccounts, + onShowAppIntro = { + introActivityLauncher.launch(null) + }, + onAddAccount = { + // eventually this will become a navigation + context.startActivity(Intent(context, LoginActivity::class.java)) + }, + onShowAccount = { account -> + // eventually this will become a navigation + val intent = Intent(context, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + context.startActivity(intent) + }, + onManagePermissions = { + // eventually this will become a navigation + context.startActivity(Intent(context, PermissionsActivity::class.java)) + } + ) +} + @Composable fun AccountsScreen( initialSyncAccounts: Boolean, onShowAppIntro: () -> Unit, - accountsDrawerHandler: AccountsDrawerHandler, onAddAccount: () -> Unit, onShowAccount: (Account) -> Unit, onManagePermissions: () -> Unit, @@ -111,7 +148,7 @@ fun AccountsScreen( } AccountsScreen( - accountsDrawerHandler = accountsDrawerHandler, + accountsDrawerHandler = model.accountsDrawerHandler, accounts = accounts, showSyncAll = showSyncAll, onSyncAll = { model.syncAllAccounts() }, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt new file mode 100644 index 000000000..abb877c89 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt @@ -0,0 +1,60 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.annotation.UiThread +import androidx.appcompat.app.AppCompatActivity +import at.bitfire.davdroid.ui.navigation.Destination +import at.bitfire.davdroid.ui.navigation.Navigation +import dagger.hilt.android.AndroidEntryPoint +import java.util.logging.Logger +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity: AppCompatActivity() { + + @Inject + lateinit var logger: Logger + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + Navigation( + deepLinkUri = intent.data, + logger = logger + ) + } + } + + + companion object { + + fun intentWithDestination(context: Context, uri: Uri) = + Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java).apply { + // Create a new activity, do not allow going back. + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + } + + /** + * Starts [MainActivity] as a redirect of a legacy activity. + * + * @param activity The activity that is requesting the redirection. + * @param uri The URI to launch. Should have schema of [Destination.APP_BASE_URI]. + */ + @UiThread + fun legacyRedirect(activity: Activity, uri: Uri) { + activity.startActivity(intentWithDestination(activity, uri)) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt index 4b1038a79..53ebe365e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt @@ -5,7 +5,6 @@ package at.bitfire.davdroid.ui import android.content.Context -import android.content.Intent import android.content.pm.ShortcutInfo import android.content.pm.ShortcutManager import android.graphics.Typeface @@ -34,10 +33,12 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.core.content.getSystemService import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri import androidx.core.text.getSpans import at.bitfire.davdroid.R import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.navigation.Destination import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -86,7 +87,12 @@ object UiUtils { ShortcutInfo.Builder(context, SHORTCUT_SYNC_ALL) .setIcon(Icon.createWithResource(context, R.drawable.ic_sync_shortcut)) .setShortLabel(context.getString(R.string.accounts_sync_all)) - .setIntent(Intent(Intent.ACTION_SYNC, null, context, AccountsActivity::class.java)) + .setIntent( + MainActivity.intentWithDestination( + context, + Destination.Accounts.PATH.toUri().buildUpon().appendQueryParameter("syncAccounts", "true").build() + ) + ) .build() ) } catch(e: Exception) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/Destination.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/Destination.kt new file mode 100644 index 000000000..0c0bc3093 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/Destination.kt @@ -0,0 +1,24 @@ +package at.bitfire.davdroid.ui.navigation + +import androidx.navigation.navDeepLink +import kotlinx.serialization.Serializable + +object Destination { + + /** + * Base URI for internal (non-exposed) deep links (used for navigation). + */ + private const val APP_BASE_URI = "nav:" + + @Serializable + data class Accounts(val syncAccounts: Boolean = false) { + companion object { + const val PATH = "$APP_BASE_URI/accounts" + + val deepLinks = listOf( + navDeepLink(basePath = PATH) + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/Navigation.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/Navigation.kt new file mode 100644 index 000000000..4b01b5450 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/navigation/Navigation.kt @@ -0,0 +1,47 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.navigation + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.navigation.NavDeepLinkRequest +import androidx.navigation.NavOptions +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import at.bitfire.davdroid.ui.AccountsScreen +import java.util.logging.Logger + +@Composable +fun Navigation(deepLinkUri: Uri?, logger: Logger) { + val navController = rememberNavController() + + LaunchedEffect(Unit) { + if (deepLinkUri != null) { + val deepLinkRq = NavDeepLinkRequest.Builder.fromUri(deepLinkUri).build() + logger.info("Got deep link: $deepLinkUri → $deepLinkRq") + navController.navigate( + deepLinkRq, + NavOptions.Builder().setLaunchSingleTop(true).build() + ) + } + } + + NavHost( + navController = navController, + startDestination = Destination.Accounts() + ) { + composable( + deepLinks = Destination.Accounts.deepLinks + ) { backStackEntry -> + val route = backStackEntry.toRoute() + AccountsScreen( + initialSyncAccounts = route.syncAccounts + ) + } + } +} \ No newline at end of file diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt b/app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt index 3efcf73f0..6c20518ba 100644 --- a/app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt +++ b/app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt @@ -23,9 +23,6 @@ interface OseFlavorModules { @Module @InstallIn(ActivityComponent::class) interface ForActivities { - @Binds - fun accountsDrawerHandler(impl: OseAccountsDrawerHandler): AccountsDrawerHandler - @Binds fun loginTypesProvider(impl: StandardLoginTypesProvider): LoginTypesProvider } @@ -33,6 +30,9 @@ interface OseFlavorModules { @Module @InstallIn(ViewModelComponent::class) interface ForViewModels { + @Binds + fun accountsDrawerHandler(impl: OseAccountsDrawerHandler): AccountsDrawerHandler + @Binds fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AboutActivity.AppLicenseInfoProvider diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2f9722c5..fa4f092b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ bitfire-ical4android = "12df9bfddb" bitfire-vcard4android = "ae5d609f92" compose-accompanist = "0.37.0" compose-bom = "2024.12.01" +compose-navigation = "2.8.5" dnsjava = "3.6.0" glance = "1.1.1" guava = "33.4.0-android" @@ -31,6 +32,7 @@ hilt = "2.55" # keep in sync with ksp version kotlin = "2.1.0" kotlinx-coroutines = "1.10.1" +kotlinx-serialization = "1.7.3" # see https://github.com/google/ksp/releases for version numbers ksp = "2.1.0-1.0.29" mikepenz-aboutLibraries = "11.4.0" @@ -72,6 +74,7 @@ compose-accompanist-permissions = { module = "com.google.accompanist:accompanist compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-materialIconsExtended = { module = "androidx.compose.material:material-icons-extended" } +compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" } compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-toolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" } @@ -85,6 +88,7 @@ hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", vers junit = { module = "junit:junit", version = "4.13.2" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } mikepenz-aboutLibraries = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } @@ -106,5 +110,6 @@ android-application = { id = "com.android.application", version.ref = "android-a compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } mikepenz-aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "mikepenz-aboutLibraries" }