Fleet Android UI (#37345)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #37189

- [x] QA'd all new/changed functionality manually
This commit is contained in:
Dante Catalfamo
2025-12-16 16:23:53 -06:00
committed by GitHub
parent 8503d39822
commit cbdd4f6a7b
10 changed files with 341 additions and 171 deletions
+18 -13
View File
@@ -7,9 +7,9 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.spotless)
alias(libs.plugins.detekt)
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20"
id("jacoco")
}
@@ -26,6 +26,8 @@ android {
versionCode = 2
versionName = "1.0.0"
buildConfigField("String", "INFO_URL", "\"https://fleetdm.com/better\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Pass integration test flag from project property to instrumentation runner
@@ -110,6 +112,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
@@ -143,6 +146,7 @@ kotlin {
jvmToolchain(17)
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
freeCompilerArgs.add("-opt-in=androidx.compose.material3.ExperimentalMaterial3Api")
}
}
@@ -238,39 +242,40 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.amapi.sdk)
implementation(libs.kotlinx.serialization.json)
// SCEP (Simple Certificate Enrollment Protocol)
implementation("com.google.code.jscep:jscep:3.0.1")
implementation(libs.jscep)
// Bouncy Castle - Cryptography provider
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
implementation("org.bouncycastle:bcpkix-jdk18on:1.78.1")
implementation(libs.bouncycastle.bcprov)
implementation(libs.bouncycastle.bcpkix)
// Apache Commons - Utilities used by jScep
implementation("commons-codec:commons-codec:1.20.0")
implementation(libs.commons.codec)
// Logging - Required by jScep
implementation("org.slf4j:slf4j-api:2.0.17")
implementation("org.slf4j:slf4j-simple:2.0.17")
implementation(libs.slf4j.api)
implementation(libs.slf4j.simple)
// Testing
testImplementation(libs.junit)
testImplementation(libs.androidx.work.testing)
testImplementation(libs.robolectric)
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("org.json:json:20231013") // For JSON parsing in unit tests
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.json) // For JSON parsing in unit tests
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation("io.mockk:mockk-android:1.13.13")
androidTestImplementation(libs.mockk.android)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
// Preferences DataStore (SharedPreferences like APIs)
implementation("androidx.datastore:datastore-preferences:1.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
}
+5 -1
View File
@@ -6,6 +6,10 @@
<!-- Source: https://developers.google.com/android/management/sdk-integration#add_queries -->
<queries>
<package android:name="com.google.android.apps.work.clouddpc" />
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<!-- REQUIRED for downloading certificates from remote server -->
@@ -61,4 +65,4 @@
</application>
</manifest>
</manifest>
@@ -56,11 +56,11 @@ object ApiClient {
}
}
val apiKeyFlow: Flow<String?>
val apiKeyDebugFlow: Flow<String?>
get() = dataStore.data.map { preferences ->
preferences[API_KEY]?.let { encrypted ->
try {
KeystoreManager.decrypt(encrypted)
"****" + KeystoreManager.decrypt(encrypted).takeLast(4)
} catch (e: Exception) {
Log.e("ApiClient", "Failed to decrypt API key", e)
null
@@ -1,131 +1,231 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package com.fleetdm.agent
import android.app.admin.DevicePolicyManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Context.DEVICE_POLICY_SERVICE
import android.content.Context.RESTRICTIONS_SERVICE
import android.content.Intent
import android.content.RestrictionsManager
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.graphics.Color
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.core.net.toUri
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.fleetdm.agent.ui.theme.FleetTextDark
import com.fleetdm.agent.ui.theme.MyApplicationTheme
import java.security.KeyStore
import java.security.cert.X509Certificate
import java.util.Date
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
const val CLICKS_TO_DEBUG = 8
@Serializable
object MainDestination
@Serializable
object DebugDestination
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// 1. Fetch the Managed Configuration (Application Restrictions)
val restrictionsManager = getSystemService(Context.RESTRICTIONS_SERVICE) as android.content.RestrictionsManager
val appRestrictions = restrictionsManager.applicationRestrictions
val dpm = getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(
scrim = Color.TRANSPARENT,
darkScrim = Color.TRANSPARENT,
),
)
setContent {
val enrollSecret by remember { mutableStateOf(appRestrictions.getString("enroll_secret")) }
val delegatedScopes by remember { mutableStateOf(dpm.getDelegatedScopes(null, packageName).toList()) }
val delegatedCertScope by remember {
mutableStateOf(delegatedScopes.contains(DevicePolicyManager.DELEGATION_CERT_INSTALL))
}
val androidID by remember { mutableStateOf(Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)) }
val enrollmentSpecificID by remember { mutableStateOf(appRestrictions.getString("host_uuid")) }
val certIds by remember { mutableStateOf(CertificateOrchestrator.getCertificateIDs(this)) }
val permissionsList by remember {
val grantedPermissions = mutableListOf<String>()
val packageInfo: PackageInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS)
packageInfo.requestedPermissions?.let {
for (i in it.indices) {
if ((
packageInfo.requestedPermissionsFlags?.get(i)
?.and(PackageInfo.REQUESTED_PERMISSION_GRANTED)
) != 0
) {
grantedPermissions.add(it[i])
}
}
}
mutableStateOf(grantedPermissions.toList())
}
val fleetBaseUrl by remember {
mutableStateOf(appRestrictions.getString("server_url"))
}
var installedCertificates: List<CertificateInfo> by remember { mutableStateOf(listOf()) }
val apiKey by ApiClient.apiKeyFlow.collectAsState(initial = null)
val baseUrl by ApiClient.baseUrlFlow.collectAsState(initial = null)
val allegedInstalledCerts by CertificateOrchestrator.installedCertsFlow(this).collectAsState(initial = "")
LaunchedEffect(Unit) {
installedCertificates = listKeystoreCertificates()
}
MyApplicationTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
content = { padding ->
Column(
modifier = Modifier.padding(padding).verticalScroll(rememberScrollState()),
) {
StatusScreen()
KeyValue("packageName", packageName)
KeyValue("versionName", packageManager.getPackageInfo(packageName, 0).versionName)
KeyValue("longVersionCode", packageManager.getPackageInfo(packageName, 0).longVersionCode.toString())
KeyValue("enroll_secret", enrollSecret)
KeyValue("delegatedScopes", delegatedScopes.toString())
KeyValue("delegated cert scope", delegatedCertScope.toString())
KeyValue("android id", androidID)
KeyValue("host_uuid (MC)", enrollmentSpecificID)
KeyValue("server_url (MC)", fleetBaseUrl)
KeyValue("orbit_node_key (datastore)", apiKey)
KeyValue("base_url (datastore)", baseUrl)
KeyValue("certificate_templates->id", certIds.toString())
KeyValue("alleged_installed", allegedInstalledCerts.toString())
PermissionList(
permissionsList = permissionsList,
)
CertificateList(certificateList = installedCertificates)
}
},
)
AppNavigation()
}
}
}
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = MainDestination,
) {
composable<MainDestination> {
MainScreen(
onNavigateToDebug = { navController.navigate(DebugDestination) },
)
}
composable<DebugDestination> {
DebugScreen(
onNavigateBack = { navController.popBackStack() },
)
}
}
}
@Composable
fun MainScreen(onNavigateToDebug: () -> Unit) {
val context = LocalContext.current
var versionClicks by remember { mutableStateOf(0) }
val installedCerts by CertificateOrchestrator.installedCertsFlow(context).collectAsState(initial = emptyMap())
Scaffold(
modifier = Modifier.fillMaxSize(),
content = { paddingValues ->
Column(Modifier.padding(paddingValues = paddingValues)) {
LogoHeader()
HorizontalDivider()
AboutFleet {
val intent = Intent(Intent.ACTION_VIEW, BuildConfig.INFO_URL.toUri())
if (intent.resolveActivity(context.packageManager) != null) {
// A browser is available, open the URL directly
context.startActivity(intent)
} else {
// A browser is not available in the work profile, display a toast with the URL
val toast = Toast.makeText(context, "Visit ${BuildConfig.INFO_URL}\nfor more information", Toast.LENGTH_LONG)
toast.show()
}
}
HorizontalDivider()
CertificateList(certificates = installedCerts)
HorizontalDivider()
AppVersion {
if (++versionClicks >= CLICKS_TO_DEBUG) {
onNavigateToDebug()
} else if (versionClicks == 1) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("", "Fleet Android Agent: ${BuildConfig.VERSION_NAME}"))
Toast.makeText(context, "Fleet Agent version copied", Toast.LENGTH_SHORT).show()
}
}
}
},
)
}
@Composable
fun DebugScreen(onNavigateBack: () -> Unit) {
val context = LocalContext.current
val restrictionsManager = context.getSystemService(RESTRICTIONS_SERVICE) as RestrictionsManager
val appRestrictions = restrictionsManager.applicationRestrictions
val dpm = context.getSystemService(DEVICE_POLICY_SERVICE) as DevicePolicyManager
val enrollSecret = remember { appRestrictions.getString("enroll_secret")?.let { "****" + it.takeLast(4) } }
val delegatedScopes = remember { dpm.getDelegatedScopes(null, context.packageName).toList() }
val delegatedCertScope = remember { delegatedScopes.contains(DevicePolicyManager.DELEGATION_CERT_INSTALL) }
val enrollmentSpecificID = remember { appRestrictions.getString("host_uuid")?.let { "****" + it.takeLast(4) } }
val certIds = remember { CertificateOrchestrator.getCertificateIDs(context) }
val permissionsList = remember {
val grantedPermissions = mutableListOf<String>()
val packageInfo: PackageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
packageInfo.requestedPermissions?.let {
for (i in it.indices) {
if ((
packageInfo.requestedPermissionsFlags?.get(i)
?.and(PackageInfo.REQUESTED_PERMISSION_GRANTED)
) != 0
) {
grantedPermissions.add(it[i])
}
}
}
grantedPermissions.toList()
}
val fleetBaseUrl = remember { appRestrictions.getString("server_url") }
val apiKey by ApiClient.apiKeyDebugFlow.collectAsState(initial = null)
val baseUrl by ApiClient.baseUrlFlow.collectAsState(initial = null)
val installedCerts by CertificateOrchestrator.installedCertsFlow(context).collectAsState(initial = emptyMap())
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Debug Information") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
)
},
content = { paddingValues ->
Column(
Modifier
.padding(paddingValues = paddingValues)
.verticalScroll(rememberScrollState()),
) {
KeyValue("packageName", context.packageName)
KeyValue("versionName", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
KeyValue("longVersionCode", context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode.toString())
KeyValue("enroll_secret", enrollSecret)
KeyValue("delegatedScopes", delegatedScopes.toString())
KeyValue("delegated cert scope", delegatedCertScope.toString())
KeyValue("host_uuid (MC)", enrollmentSpecificID)
KeyValue("server_url (MC)", fleetBaseUrl)
KeyValue("orbit_node_key (datastore)", apiKey)
KeyValue("base_url (datastore)", baseUrl)
KeyValue("certificate_templates->id", certIds.toString())
KeyValue("certs_installed", installedCerts.toString())
PermissionList(
permissionsList = permissionsList,
)
}
},
)
}
@Composable
fun PermissionList(modifier: Modifier = Modifier, permissionsList: List<String>) {
Column(modifier = modifier) {
@@ -139,37 +239,6 @@ fun PermissionList(modifier: Modifier = Modifier, permissionsList: List<String>)
}
}
@Composable
fun CertificateList(modifier: Modifier = Modifier, certificateList: List<CertificateInfo>) {
Column(modifier = modifier) {
Text("certificate list:")
HorizontalDivider()
certificateList.forEach {
Text(it.alias)
HorizontalDivider()
}
}
}
@Composable
fun StatusScreen(modifier: Modifier = Modifier) {
val tag = "CertStatusScreen"
Log.i(tag, "this is a log!")
Greeting(
name = "banana frog",
modifier = modifier,
)
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier,
)
}
@Composable
fun KeyValue(key: String, value: String?) {
Text(
@@ -183,37 +252,94 @@ fun KeyValue(key: String, value: String?) {
HorizontalDivider()
}
suspend fun listKeystoreCertificates(): List<CertificateInfo> = withContext(Dispatchers.IO) {
try {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val aliases = keyStore.aliases().toList()
aliases.mapNotNull { alias ->
val cert = keyStore.getCertificate(alias) as? X509Certificate
cert?.let {
CertificateInfo(
alias = alias,
subject = it.subjectDN.name,
issuer = it.issuerDN.name,
notBefore = it.notBefore,
notAfter = it.notAfter,
)
}
}
} catch (e: Exception) {
Log.e("Certificate", "Error listing keystore certificates", e)
emptyList()
@Composable
fun AboutFleet(modifier: Modifier = Modifier, onLearnClick: () -> Unit = {}) {
Column(modifier = modifier.padding(20.dp)) {
Text(
text = stringResource(R.string.app_description),
)
Text(
text = stringResource(R.string.learn_about_fleet),
fontWeight = FontWeight.Bold,
color = FleetTextDark,
modifier = Modifier
.padding(top = 10.dp)
.clickable(onClick = onLearnClick),
)
}
}
data class CertificateInfo(val alias: String, val subject: String, val issuer: String, val notBefore: Date, val notAfter: Date)
@Composable
fun LogoHeader(modifier: Modifier = Modifier) {
Image(
modifier = modifier.padding(20.dp),
painter = painterResource(R.drawable.fleet_logo),
contentDescription = stringResource(R.string.fleet_logo),
)
}
@Composable
fun CertificateList(modifier: Modifier = Modifier, certificates: CertStatusMap) {
Column(modifier = modifier.padding(20.dp)) {
Text(
text = stringResource(R.string.certificate_list_title),
color = FleetTextDark,
fontWeight = FontWeight.Bold,
)
certificates.forEach { (_, value) ->
if (value.status == CertificateInstallStatus.INSTALLED) {
Text(text = value.alias)
}
}
}
}
@Composable
fun AppVersion(onClick: () -> Unit = {}) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Column(
modifier = Modifier
.padding(20.dp),
) {
Text(
text = stringResource(R.string.app_version_title),
color = FleetTextDark,
fontWeight = FontWeight.Bold,
)
Text(text = BuildConfig.VERSION_NAME)
}
}
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
fun FleetScreenPreview() {
MyApplicationTheme {
Greeting("Android")
Column {
LogoHeader()
HorizontalDivider()
AboutFleet()
HorizontalDivider()
CertificateList(
certificates = mapOf(
1 to CertificateInstallInfo(alias = "WIFI-1", status = CertificateInstallStatus.INSTALLED),
2 to CertificateInstallInfo(alias = "VPN-3", status = CertificateInstallStatus.FAILED),
),
)
HorizontalDivider()
AppVersion(onClick = {})
}
}
}
@Preview(showBackground = true)
@Composable
fun AboutFleetPreview() {
MyApplicationTheme {
AboutFleet()
}
}
@@ -9,3 +9,6 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val FleetTextLight = Color(0xFF515774)
val FleetTextDark = Color(0xFF192147)
@@ -11,11 +11,11 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
// private val DarkColorScheme = darkColorScheme(
// primary = Purple80,
// secondary = PurpleGrey80,
// tertiary = Pink80,
// )
private val LightColorScheme = lightColorScheme(
primary = Purple40,
@@ -35,20 +35,21 @@ private val LightColorScheme = lightColorScheme(
@Composable
fun MyApplicationTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
// dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val colorScheme = LightColorScheme
// val colorScheme = when {
// dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
// val context = LocalContext.current
// if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
// }
//
// darkTheme -> DarkColorScheme
// else -> LightColorScheme
// }
MaterialTheme(
colorScheme = colorScheme,
@@ -16,6 +16,7 @@ val Typography =
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
color = FleetTextLight,
),
/* Other default text styles to override
titleLarge = TextStyle(
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -1,6 +1,13 @@
<resources>
<string name="app_name">Fleet Agent</string>
<string name="app_description">The app is installed by your IT administrator to install the certificates needed to connect to corporate networks.</string>
<string name="learn_about_fleet">Learn about Fleet</string>
<string name="fleet_logo">Fleet logo</string>
<string name="certificate_list_title">Installed Certificates</string>
<string name="app_version_title">App Version</string>
<!-- Managed Configuration -->
<string name="enroll_secret_title">Enroll Secret</string>
<string name="enroll_secret_description">Secret used to enroll in a fleet instance</string>
<string name="server_url_title">Fleet Base URL</string>
+24 -1
View File
@@ -1,5 +1,5 @@
[versions]
agp = "8.13.1"
agp = "8.13.2"
kotlin = "2.2.21"
coreKtx = "1.17.0"
junit = "4.13.2"
@@ -13,6 +13,15 @@ detekt = "1.23.8"
workManager = "2.11.0"
amapi = "1.7.0"
robolectric = "4.16"
kotlinx = "1.9.0"
jscep = "3.0.1"
bouncycastle = "1.78.1"
commonsCodec = "1.20.0"
slf4j = "2.0.17"
datastorePreferences = "1.2.0"
navigationCompose = "2.9.6"
json = "20231013"
mockk = "1.13.13"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -33,11 +42,25 @@ androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx"
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workManager" }
amapi-sdk = { group = "com.google.android.libraries.enterprise.amapi", name = "amapi", version.ref = "amapi" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
jscep = { group = "com.google.code.jscep", name = "jscep", version.ref = "jscep" }
bouncycastle-bcprov = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" }
bouncycastle-bcpkix = { group = "org.bouncycastle", name = "bcpkix-jdk18on", version.ref = "bouncycastle" }
commons-codec = { group = "commons-codec", name = "commons-codec", version.ref = "commonsCodec" }
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx" }
json = { group = "org.json", name = "json", version.ref = "json" }
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }