Compare commits

...

18 Commits

Author SHA1 Message Date
Arnau Mora
958be01a79 Merge branch 'migrate-main-activity' into migrate-intro-about
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt
#	app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt
2025-01-11 15:52:33 +01:00
Arnau Mora
597a3d293e Added redirection from AccountsActivity to MainActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-11 15:50:16 +01:00
Arnau Mora
0e59334e68 Moved AccountsDrawerHandler back to activity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-11 15:46:18 +01:00
Arnau Mora
d8aad544ff Merge branch 'migrate-main-activity' into migrate-intro-about
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt
2025-01-11 15:40:19 +01:00
Arnau Mora
5b935263d3 Merge branch 'main-ose' into migrate-main-activity 2025-01-11 15:35:49 +01:00
Ricki Hirner
013cb915fd Merge branch 'main-ose' into migrate-main-activity 2025-01-10 16:14:41 +01:00
Arnau Mora
754c971fb9 Merge branch 'main-ose' into migrate-main-activity
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/sync/account/AddressBookAuthenticatorService.kt
#	app/src/main/res/xml/sync_prefs.xml
2025-01-02 14:54:13 +01:00
Arnau Mora
282f1d1db6 Moved NavController
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-02 14:52:52 +01:00
Arnau Mora
52fde0c9f7 Completed migration
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 18:28:50 +01:00
Arnau Mora
97478fb7a3 Merge branch 'migrate-main-activity' into migrate-intro-about
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt
#	app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt
#	app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt
2025-01-01 18:25:20 +01:00
Arnau Mora
bdae74189b Moved AccountsDrawerHandler to model
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 18:23:47 +01:00
Arnau Mora
b2785bc296 Migrated AboutActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 18:18:52 +01:00
Arnau Mora
0d9be98547 Migrated IntroActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 18:07:40 +01:00
Arnau Mora
b8b38b600a Typo
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 17:34:19 +01:00
Arnau Mora
a544e53267 Created LocalNavController
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 17:33:57 +01:00
Arnau Mora
a02559ca9a Fixed import
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 17:32:56 +01:00
Arnau Mora
ddf881a504 Added navigation
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 17:29:55 +01:00
Arnau Mora
777b419a60 Renamed AccountsActivity to MainActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 15:17:17 +01:00
24 changed files with 548 additions and 503 deletions

View File

@@ -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)

View File

@@ -60,9 +60,8 @@
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity"
tools:node="remove" tools:selector="net.openid.appauth"/>
<activity android:name=".ui.intro.IntroActivity" />
<activity
android:name=".ui.AccountsActivity"
android:name=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -70,15 +69,18 @@
</intent-filter>
</activity>
<activity
android:name=".ui.AboutActivity"
android:label="@string/navigation_drawer_about"
android:parentActivityName=".ui.AccountsActivity"/>
<!--
Left here for external apps.
Automatically redirects to MainActivity.
Should be removed in the future.
-->
<!--suppress DeprecatedClassUsageInspection -->
<activity android:name=".ui.AccountsActivity" android:exported="true" />
<activity
android:name=".ui.AppSettingsActivity"
android:label="@string/app_settings"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
@@ -106,7 +108,7 @@
<activity
android:name=".ui.setup.LoginActivity"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
@@ -134,7 +136,7 @@
<activity
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
</activity>
<activity
@@ -156,7 +158,7 @@
<activity
android:name=".ui.webdav.WebdavMountsActivity"
android:exported="true"
android:parentActivityName=".ui.AccountsActivity" />
android:parentActivityName=".ui.MainActivity" />
<activity
android:name=".ui.webdav.AddWebdavMountActivity"
android:parentActivityName=".ui.webdav.WebdavMountsActivity"

View File

@@ -21,8 +21,9 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.db.migration.*
import at.bitfire.davdroid.ui.AccountsActivity
import at.bitfire.davdroid.db.migration.AutoMigration12
import at.bitfire.davdroid.db.migration.AutoMigration16
import at.bitfire.davdroid.ui.MainActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import dagger.Module
import dagger.Provides
@@ -74,7 +75,7 @@ abstract class AppDatabase: RoomDatabase() {
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
val launcherIntent = Intent(context, AccountsActivity::class.java)
val launcherIntent = Intent(context, MainActivity::class.java)
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(context.getString(R.string.database_destructive_migration_title))

View File

@@ -1,358 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui
import android.content.Context
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.composable.PixelBoxes
import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.util.withJson
import dagger.BindsOptionalOf
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.text.Collator
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.LinkedList
import java.util.Locale
import java.util.Optional
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
@AndroidEntryPoint
class AboutActivity: AppCompatActivity() {
val model by viewModels<Model>()
@Inject
lateinit var licenseInfoProvider: Optional<AppLicenseInfoProvider>
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onSupportNavigateUp() }) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(R.string.navigate_up)
)
}
},
title = {
Text(stringResource(R.string.navigation_drawer_about))
},
actions = {
IconButton(onClick = {
uriHandler.openUri(Constants.HOMEPAGE_URL
.buildUpon()
.withStatParams("AboutActivity")
.build().toString())
}) {
Icon(
Icons.Default.Home,
contentDescription = stringResource(R.string.navigation_drawer_website)
)
}
}
)
}
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
val scope = rememberCoroutineScope()
val state = rememberPagerState(pageCount = { 3 })
TabRow(state.currentPage) {
Tab(state.currentPage == 0, onClick = {
scope.launch { state.scrollToPage(0) }
}) {
Text(
stringResource(R.string.app_name),
modifier = Modifier.padding(8.dp)
)
}
Tab(state.currentPage == 1, onClick = {
scope.launch { state.scrollToPage(1) }
}) {
Text(
stringResource(R.string.about_translations),
modifier = Modifier.padding(8.dp)
)
}
Tab(state.currentPage == 2, onClick = {
scope.launch { state.scrollToPage(2) }
}) {
Text(
stringResource(R.string.about_libraries),
modifier = Modifier.padding(8.dp)
)
}
}
HorizontalPager(
state,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalAlignment = Alignment.Top
) { index ->
when (index) {
0 -> AboutApp(licenseInfoProvider = licenseInfoProvider.getOrNull())
1 -> {
val translations = model.translations.observeAsState(emptyList())
TranslatorsGallery(translations.value)
}
2 -> LibrariesContainer(Modifier.fillMaxSize(),
itemContentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
itemSpacing = 8.dp,
librariesBlock = { ctx ->
Libs.Builder()
.withJson(ctx, R.raw.aboutlibraries)
.build()
})
}
}
}
}
}
}
}
@HiltViewModel
class Model @Inject constructor(
@ApplicationContext val context: Context,
private val logger: Logger
): ViewModel() {
data class Translation(
val language: String,
val translators: Set<String>
)
val translations = MutableLiveData<List<Translation>>()
init {
viewModelScope.launch(Dispatchers.IO) {
loadTranslations()
}
}
private fun loadTranslations() {
try {
context.resources.assets.open("translators.json").use { stream ->
val jsonTranslations = JSONObject(stream.readBytes().decodeToString())
val result = LinkedList<Translation>()
for (langCode in jsonTranslations.keys()) {
val jsonTranslators = jsonTranslations.getJSONArray(langCode)
val translators = Array<String>(jsonTranslators.length()) { idx ->
jsonTranslators.getString(idx)
}
val langTag = langCode.replace('_', '-')
val language = Locale.forLanguageTag(langTag).displayName
result += Translation(language, translators.toSet())
}
// sort translations by localized language name
val collator = Collator.getInstance()
result.sortWith { o1, o2 ->
collator.compare(o1.language, o2.language)
}
translations.postValue(result)
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't load translators", e)
}
}
}
interface AppLicenseInfoProvider {
@Composable
fun LicenseInfo()
}
@Module
@InstallIn(ActivityComponent::class)
interface AppLicenseInfoProviderModule {
@BindsOptionalOf
fun appLicenseInfoProvider(): AppLicenseInfoProvider
}
}
@Composable
fun AboutApp(licenseInfoProvider: AboutActivity.AppLicenseInfoProvider? = null) {
Column(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())) {
Image(
UiUtils.adaptiveIconPainterResource(R.mipmap.ic_launcher),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier
.size(128.dp)
.align(Alignment.CenterHorizontally)
)
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
Text(
stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(R.string.about_copyright),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
Text(
stringResource(R.string.about_license_info_no_warranty),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
PixelBoxes(
arrayOf(Color(0xFFFCF434), Color.White, Color(0xFF9C59D1), Color.Black),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp)
)
licenseInfoProvider?.LicenseInfo()
}
}
@Composable
@Preview
fun AboutApp_Preview() {
AboutApp(licenseInfoProvider = object : AboutActivity.AppLicenseInfoProvider {
@Composable
override fun LicenseInfo() {
Text("Some flavored License Info")
}
})
}
@Composable
fun TranslatorsGallery(
translations: List<AboutActivity.Model.Translation>
) {
val collator = Collator.getInstance()
LazyColumn(Modifier.padding(8.dp)) {
items(translations) { translation ->
Text(
translation.language,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(vertical = 4.dp)
)
Text(
translation.translators
.sortedWith { a, b -> collator.compare(a, b) }
.joinToString(" · "),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
}
@Composable
@Preview
fun TranslatorsGallery_Sample() {
TranslatorsGallery(listOf(
AboutActivity.Model.Translation("Some Language", setOf("User 1", "User 2")),
AboutActivity.Model.Translation("Another Language", setOf("User 3", "User 4"))
))
}

View File

@@ -6,53 +6,17 @@ package at.bitfire.davdroid.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
@Deprecated("Automatically redirects to MainActivity. Should be removed in the future.")
class AccountsActivity: AppCompatActivity() {
@Inject
lateinit var accountsDrawerHandler: AccountsDrawerHandler
private val introActivityLauncher = registerForActivityResult(IntroActivity.Contract) { cancelled ->
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
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))
}
)
}
startActivity(
Intent(this, MainActivity::class.java).apply {
action = intent.action
}
)
}
}
}

View File

@@ -53,6 +53,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.composition.LocalNavController
import at.bitfire.davdroid.ui.navigation.Routes
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
import kotlinx.coroutines.launch
import java.net.URI
@@ -104,6 +106,7 @@ abstract class AccountsDrawerHandler {
open fun ImportantEntries(
snackbarHostState: SnackbarHostState
) {
val navController = LocalNavController.current
val context = LocalContext.current
val isBeta =
LocalInspectionMode.current ||
@@ -116,7 +119,7 @@ abstract class AccountsDrawerHandler {
icon = Icons.Default.Info,
title = stringResource(R.string.navigation_drawer_about),
onClick = {
context.startActivity(Intent(context, AboutActivity::class.java))
navController.navigate(Routes.About)
}
)

View File

@@ -6,6 +6,7 @@ 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
@@ -71,11 +72,18 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.toRoute
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.composition.LocalNavController
import at.bitfire.davdroid.ui.intro.INTRO_CANCELLED
import at.bitfire.davdroid.ui.navigation.Routes
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
@@ -84,9 +92,49 @@ import kotlinx.coroutines.launch
@Composable
fun AccountsScreen(
backStackEntry: NavBackStackEntry,
accountsDrawerHandler: AccountsDrawerHandler
) {
val route = backStackEntry.toRoute<Routes.Accounts>()
val navController = LocalNavController.current
val context = LocalContext.current
val activity = context as? Activity
LaunchedEffect(Unit) {
navController.currentBackStackEntry
?.savedStateHandle
?.getStateFlow(INTRO_CANCELLED, false)
?.collect { cancelled ->
if (cancelled) activity?.finish()
}
}
AccountsScreen(
accountsDrawerHandler = accountsDrawerHandler,
initialSyncAccounts = route.syncAccounts,
onShowAppIntro = { navController.navigate(Routes.Intro) },
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(
accountsDrawerHandler: AccountsDrawerHandler,
initialSyncAccounts: Boolean,
onShowAppIntro: () -> Unit,
accountsDrawerHandler: AccountsDrawerHandler,
onAddAccount: () -> Unit,
onShowAccount: (Account) -> Unit,
onManagePermissions: () -> Unit,

View File

@@ -0,0 +1,57 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import at.bitfire.davdroid.ui.about.AboutScreen
import at.bitfire.davdroid.ui.intro.IntroScreen
import at.bitfire.davdroid.ui.navigation.LocalNavController
import at.bitfire.davdroid.ui.navigation.Routes
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity: AppCompatActivity() {
@Inject
lateinit var accountsDrawerHandler: AccountsDrawerHandler
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
startDestination = accountsFromIntent()
) {
composable<Routes.Accounts> { AccountsScreen(it, accountsDrawerHandler) }
composable<Routes.Intro> { IntroScreen() }
composable<Routes.About> { AboutScreen() }
}
}
}
}
/**
* Initializes the accounts route from the current intent data.
* Checks whether the action is [Intent.ACTION_SYNC].
*/
private fun accountsFromIntent() = Routes.Accounts(
// handle "Sync all" intent from launcher shortcut
syncAccounts = intent.action == Intent.ACTION_SYNC
)
}

View File

@@ -34,9 +34,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.composable.CardWithImage
@@ -80,7 +80,7 @@ fun PermissionsScreen(
@Composable
fun PermissionsScreen(
modifier: Modifier = Modifier,
model: PermissionsModel = viewModel()
model: PermissionsModel = hiltViewModel()
) {
// check permissions when the lifecycle owner (for instance Activity) is resumed
val lifecycle = LocalLifecycleOwner.current.lifecycle

View File

@@ -40,8 +40,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
@@ -77,7 +77,7 @@ fun TasksScreen(onNavUp: () -> Unit) {
@Composable
fun TasksCard(
model: TasksModel = viewModel()
model: TasksModel = hiltViewModel()
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()

View File

@@ -86,7 +86,7 @@ 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(Intent(Intent.ACTION_SYNC, null, context, MainActivity::class.java))
.build()
)
} catch(e: Exception) {

View File

@@ -0,0 +1,68 @@
package at.bitfire.davdroid.ui.about
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.text.Collator
import java.util.LinkedList
import java.util.Locale
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@HiltViewModel
class AboutModel @Inject constructor(
@ApplicationContext val context: Context,
private val logger: Logger,
val licenseInfoProvider: AppLicenseInfoProvider?
): ViewModel() {
data class Translation(
val language: String,
val translators: Set<String>
)
val translations = MutableLiveData<List<Translation>>()
init {
viewModelScope.launch(Dispatchers.IO) {
loadTranslations()
}
}
private fun loadTranslations() {
try {
context.resources.assets.open("translators.json").use { stream ->
val jsonTranslations = JSONObject(stream.readBytes().decodeToString())
val result = LinkedList<Translation>()
for (langCode in jsonTranslations.keys()) {
val jsonTranslators = jsonTranslations.getJSONArray(langCode)
val translators = Array<String>(jsonTranslators.length()) { idx ->
jsonTranslators.getString(idx)
}
val langTag = langCode.replace('_', '-')
val language = Locale.forLanguageTag(langTag).displayName
result += Translation(language, translators.toSet())
}
// sort translations by localized language name
val collator = Collator.getInstance()
result.sortWith { o1, o2 ->
collator.compare(o1.language, o2.language)
}
translations.postValue(result)
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't load translators", e)
}
}
}

View File

@@ -0,0 +1,250 @@
package at.bitfire.davdroid.ui.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.UiUtils
import at.bitfire.davdroid.ui.composable.PixelBoxes
import at.bitfire.davdroid.ui.composition.LocalNavController
import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.util.withJson
import kotlinx.coroutines.launch
import java.text.Collator
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AboutScreen(model: AboutModel = hiltViewModel()) {
val uriHandler = LocalUriHandler.current
val navController = LocalNavController.current
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(R.string.navigate_up)
)
}
},
title = {
Text(stringResource(R.string.navigation_drawer_about))
},
actions = {
IconButton(onClick = {
uriHandler.openUri(
Constants.HOMEPAGE_URL
.buildUpon()
.withStatParams("AboutActivity")
.build().toString())
}) {
Icon(
Icons.Default.Home,
contentDescription = stringResource(R.string.navigation_drawer_website)
)
}
}
)
}
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
val scope = rememberCoroutineScope()
val state = rememberPagerState(pageCount = { 3 })
TabRow(state.currentPage) {
Tab(state.currentPage == 0, onClick = {
scope.launch { state.scrollToPage(0) }
}) {
Text(
stringResource(R.string.app_name),
modifier = Modifier.padding(8.dp)
)
}
Tab(state.currentPage == 1, onClick = {
scope.launch { state.scrollToPage(1) }
}) {
Text(
stringResource(R.string.about_translations),
modifier = Modifier.padding(8.dp)
)
}
Tab(state.currentPage == 2, onClick = {
scope.launch { state.scrollToPage(2) }
}) {
Text(
stringResource(R.string.about_libraries),
modifier = Modifier.padding(8.dp)
)
}
}
HorizontalPager(
state,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalAlignment = Alignment.Top
) { index ->
when (index) {
0 -> AboutApp(licenseInfoProvider = model.licenseInfoProvider)
1 -> {
val translations = model.translations.observeAsState(emptyList())
TranslatorsGallery(translations.value)
}
2 -> LibrariesContainer(
Modifier.fillMaxSize(),
itemContentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
itemSpacing = 8.dp,
librariesBlock = { ctx ->
Libs.Builder()
.withJson(ctx, R.raw.aboutlibraries)
.build()
})
}
}
}
}
}
@Composable
fun AboutApp(licenseInfoProvider: AppLicenseInfoProvider? = null) {
Column(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())) {
Image(
UiUtils.adaptiveIconPainterResource(R.mipmap.ic_launcher),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier
.size(128.dp)
.align(Alignment.CenterHorizontally)
)
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
Text(
stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(R.string.about_copyright),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
Text(
stringResource(R.string.about_license_info_no_warranty),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
PixelBoxes(
arrayOf(Color(0xFFFCF434), Color.White, Color(0xFF9C59D1), Color.Black),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp)
)
licenseInfoProvider?.LicenseInfo()
}
}
@Composable
@Preview
fun AboutApp_Preview() {
AboutApp(licenseInfoProvider = object : AppLicenseInfoProvider {
@Composable
override fun LicenseInfo() {
Text("Some flavored License Info")
}
})
}
@Composable
fun TranslatorsGallery(
translations: List<AboutModel.Translation>
) {
val collator = Collator.getInstance()
LazyColumn(Modifier.padding(8.dp)) {
items(translations) { translation ->
Text(
translation.language,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(vertical = 4.dp)
)
Text(
translation.translators
.sortedWith { a, b -> collator.compare(a, b) }
.joinToString(" · "),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
}
@Composable
@Preview
fun TranslatorsGallery_Sample() {
TranslatorsGallery(listOf(
AboutModel.Translation("Some Language", setOf("User 1", "User 2")),
AboutModel.Translation("Another Language", setOf("User 3", "User 4"))
))
}

View File

@@ -0,0 +1,8 @@
package at.bitfire.davdroid.ui.about
import androidx.compose.runtime.Composable
interface AppLicenseInfoProvider {
@Composable
fun LicenseInfo()
}

View File

@@ -0,0 +1,13 @@
package at.bitfire.davdroid.ui.about
import dagger.BindsOptionalOf
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
@Module
@InstallIn(ViewModelComponent::class)
interface AppLicenseInfoProviderModule {
@BindsOptionalOf
fun appLicenseInfoProvider(): AppLicenseInfoProvider
}

View File

@@ -30,8 +30,8 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
@@ -41,7 +41,7 @@ import java.util.Locale
@Composable
fun BatteryOptimizationsPageContent(
model: BatteryOptimizationsPageModel = viewModel()
model: BatteryOptimizationsPageModel = hiltViewModel()
) {
val ignoreBatteryOptimizationsResultLauncher = rememberLauncherForActivityResult(
BatteryOptimizationsPage.IgnoreBatteryOptimizationsContract

View File

@@ -1,72 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.intro
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.rememberCoroutineScope
import at.bitfire.davdroid.ui.AppTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class IntroActivity : AppCompatActivity() {
val model by viewModels<IntroModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pages = model.pages
setContent {
AppTheme {
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState { pages.size }
BackHandler {
if (pagerState.settledPage == 0) {
setResult(Activity.RESULT_CANCELED)
finish()
} else scope.launch {
pagerState.animateScrollToPage(pagerState.settledPage - 1)
}
}
IntroScreen(
pages = pages,
pagerState = pagerState,
onDonePressed = {
setResult(Activity.RESULT_OK)
finish()
}
)
}
}
}
/**
* For launching the [IntroActivity]. Result is `true` when the user cancelled the intro.
*/
object Contract: ActivityResultContract<Unit?, Boolean>() {
override fun createIntent(context: Context, input: Unit?): Intent =
Intent(context, IntroActivity::class.java)
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_CANCELED
}
}
}

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.ui.intro
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
@@ -53,11 +54,42 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.M3ColorScheme
import at.bitfire.davdroid.ui.composition.LocalNavController
import kotlinx.coroutines.launch
const val INTRO_CANCELLED = "cancelled"
@Composable
fun IntroScreen(
model: IntroModel = hiltViewModel()
) {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState { model.pages.size }
BackHandler {
if (pagerState.settledPage == 0) {
navController.previousBackStackEntry
?.savedStateHandle
?.set(INTRO_CANCELLED, true)
navController.popBackStack()
} else scope.launch {
pagerState.animateScrollToPage(pagerState.settledPage - 1)
}
}
IntroScreen(
pages = model.pages,
pagerState = pagerState,
onDonePressed = { navController.popBackStack() }
)
}
@Composable
fun IntroScreen(
pages: List<IntroPage>,

View File

@@ -26,9 +26,9 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
@@ -54,7 +54,7 @@ class OpenSourcePage @Inject constructor(
}
@Composable
private fun Page(model: Model = viewModel()) {
private fun Page(model: Model = hiltViewModel()) {
val dontShow by model.dontShow.collectAsStateWithLifecycle(false)
OpenSourcePage(
dontShow = dontShow,

View File

@@ -0,0 +1,6 @@
package at.bitfire.davdroid.ui.navigation
import androidx.compose.runtime.compositionLocalOf
import androidx.navigation.NavController
val LocalNavController = compositionLocalOf<NavController> { error("No NavController attached.") }

View File

@@ -0,0 +1,14 @@
package at.bitfire.davdroid.ui.navigation
import kotlinx.serialization.Serializable
object Routes {
@Serializable
data class Accounts(val syncAccounts: Boolean)
@Serializable
data object Intro
@Serializable
data object About
}

View File

@@ -4,10 +4,10 @@
package at.bitfire.davdroid
import at.bitfire.davdroid.ui.AboutActivity
import at.bitfire.davdroid.ui.AccountsDrawerHandler
import at.bitfire.davdroid.ui.OpenSourceLicenseInfoProvider
import at.bitfire.davdroid.ui.OseAccountsDrawerHandler
import at.bitfire.davdroid.ui.about.AppLicenseInfoProvider
import at.bitfire.davdroid.ui.intro.IntroPageFactory
import at.bitfire.davdroid.ui.setup.LoginTypesProvider
import at.bitfire.davdroid.ui.setup.StandardLoginTypesProvider
@@ -34,7 +34,7 @@ interface OseFlavorModules {
@InstallIn(ViewModelComponent::class)
interface ForViewModels {
@Binds
fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AboutActivity.AppLicenseInfoProvider
fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AppLicenseInfoProvider
@Binds
fun loginTypesProvider(impl: StandardLoginTypesProvider): LoginTypesProvider

View File

@@ -14,17 +14,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.text.HtmlCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.about.AppLicenseInfoProvider
import com.google.common.io.CharStreams
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
class OpenSourceLicenseInfoProvider @Inject constructor(): AboutActivity.AppLicenseInfoProvider {
class OpenSourceLicenseInfoProvider @Inject constructor(): AppLicenseInfoProvider {
@Composable
override fun LicenseInfo() {
@@ -33,7 +34,7 @@ class OpenSourceLicenseInfoProvider @Inject constructor(): AboutActivity.AppLice
@Composable
fun LicenseInfoGpl(
model: Model = viewModel()
model: Model = hiltViewModel()
) {
model.gpl?.let { OpenSourceLicenseInfo(it.toAnnotatedString()) }
}

View File

@@ -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" }