feat: dark mode background fixes, chat bubble long-press menu, notification dedup, biometric auth for clear
- Fix: dark mode white backgrounds via transparent windowBackground, decorView color, and explicit Surface backgrounds on IoT/Profile/MainScreen - Fix: dark mode text colors in ProfileScreen (nickname, account info, assistant status) - Feat: long-press chat bubble to copy message text via DropdownMenu - Feat: notification deduplication — track notified message IDs, clear on foreground - Feat: cancel all notifications when app enters foreground - Feat: biometric/device-credential verification before clearing local messages Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -88,4 +88,7 @@ dependencies {
|
|||||||
|
|
||||||
// Core
|
// Core
|
||||||
implementation(libs.core.ktx)
|
implementation(libs.core.ktx)
|
||||||
|
|
||||||
|
// Biometric
|
||||||
|
implementation(libs.biometric)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class CyreneApplication : Application() {
|
|||||||
|
|
||||||
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val activityCount = AtomicInteger(0)
|
private val activityCount = AtomicInteger(0)
|
||||||
|
@Volatile
|
||||||
|
private var notificationHelper: NotificationHelper? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
@@ -40,6 +42,7 @@ class CyreneApplication : Application() {
|
|||||||
override fun onActivityStarted(activity: Activity) {
|
override fun onActivityStarted(activity: Activity) {
|
||||||
if (activityCount.incrementAndGet() == 1) {
|
if (activityCount.incrementAndGet() == 1) {
|
||||||
RuntimeLog.general("app", "App in foreground")
|
RuntimeLog.general("app", "App in foreground")
|
||||||
|
notificationHelper?.cancelAll()
|
||||||
getRepo()?.onAppForeground()
|
getRepo()?.onAppForeground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,10 +63,11 @@ class CyreneApplication : Application() {
|
|||||||
|
|
||||||
// Set up background notification callback once Koin is ready
|
// Set up background notification callback once Koin is ready
|
||||||
initScope.launch {
|
initScope.launch {
|
||||||
val notificationHelper = NotificationHelper(this@CyreneApplication)
|
val helper = NotificationHelper(this@CyreneApplication)
|
||||||
|
notificationHelper = helper
|
||||||
val repo = getRepo()
|
val repo = getRepo()
|
||||||
repo?.setNotificationCallback { message ->
|
repo?.setNotificationCallback { message ->
|
||||||
notificationHelper.showMessageNotification(message)
|
helper.showMessageNotification(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class ChatRepositoryImpl(
|
|||||||
private var isAppInForeground = false
|
private var isAppInForeground = false
|
||||||
private var onBackgroundNotification: ((Message) -> Unit)? = null
|
private var onBackgroundNotification: ((Message) -> Unit)? = null
|
||||||
private var historyRequested = false
|
private var historyRequested = false
|
||||||
|
private val notifiedMessageIds = mutableSetOf<String>()
|
||||||
|
|
||||||
// Duplicate suppression: track items from review/multi_message to skip wrapping response
|
// Duplicate suppression: track items from review/multi_message to skip wrapping response
|
||||||
private val recentParsedContents = mutableListOf<String>()
|
private val recentParsedContents = mutableListOf<String>()
|
||||||
@@ -79,6 +80,7 @@ class ChatRepositoryImpl(
|
|||||||
|
|
||||||
override fun onAppForeground() {
|
override fun onAppForeground() {
|
||||||
isAppInForeground = true
|
isAppInForeground = true
|
||||||
|
notifiedMessageIds.clear()
|
||||||
if (!_connectionState.value) {
|
if (!_connectionState.value) {
|
||||||
webSocketService.forceReconnect()
|
webSocketService.forceReconnect()
|
||||||
}
|
}
|
||||||
@@ -641,7 +643,9 @@ class ChatRepositoryImpl(
|
|||||||
_incomingMessages.tryEmit(message)
|
_incomingMessages.tryEmit(message)
|
||||||
|
|
||||||
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
|
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
|
||||||
onBackgroundNotification?.invoke(message)
|
if (notifiedMessageIds.add(id)) {
|
||||||
|
onBackgroundNotification?.invoke(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package top.yeij.cyrene.ui.components
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -8,13 +10,24 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -42,6 +55,7 @@ fun ChatBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatMessageBubble(
|
private fun ChatMessageBubble(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -49,6 +63,9 @@ private fun ChatMessageBubble(
|
|||||||
time: String,
|
time: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -58,23 +75,45 @@ private fun ChatMessageBubble(
|
|||||||
Column(
|
Column(
|
||||||
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
|
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Box {
|
||||||
shape = MaterialTheme.shapes.large,
|
Surface(
|
||||||
color = if (isUser)
|
shape = MaterialTheme.shapes.large,
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
shadowElevation = 2.dp,
|
|
||||||
modifier = Modifier.widthIn(max = 300.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = content,
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
color = if (isUser)
|
color = if (isUser)
|
||||||
MaterialTheme.colorScheme.onPrimary
|
MaterialTheme.colorScheme.primary
|
||||||
else
|
else
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
)
|
shadowElevation = 2.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 300.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = { showMenu = true },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color = if (isUser)
|
||||||
|
MaterialTheme.colorScheme.onPrimary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("复制") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
clipboardManager.setText(AnnotatedString(content))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = time,
|
text = time,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package top.yeij.cyrene.ui.navigation
|
package top.yeij.cyrene.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -10,6 +11,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat
|
|||||||
import androidx.compose.material.icons.filled.DevicesOther
|
import androidx.compose.material.icons.filled.DevicesOther
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationRail
|
import androidx.compose.material3.NavigationRail
|
||||||
import androidx.compose.material3.NavigationRailItem
|
import androidx.compose.material3.NavigationRailItem
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -129,7 +131,12 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
) {
|
||||||
when (selectedTab) {
|
when (selectedTab) {
|
||||||
0 -> ChatScreen()
|
0 -> ChatScreen()
|
||||||
1 -> IoTScreen()
|
1 -> IoTScreen()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package top.yeij.cyrene.ui.screens.iot
|
package top.yeij.cyrene.ui.screens.iot
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -31,6 +32,7 @@ fun IoTScreen(
|
|||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isLoading,
|
isRefreshing = isLoading,
|
||||||
onRefresh = { viewModel.refreshDevices() },
|
onRefresh = { viewModel.refreshDevices() },
|
||||||
|
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
||||||
) {
|
) {
|
||||||
if (devices.isEmpty() && !isLoading) {
|
if (devices.isEmpty() && !isLoading) {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ fun ProfileScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
// Profile header
|
// Profile header
|
||||||
@@ -158,6 +159,7 @@ fun ProfileScreen(
|
|||||||
text = profile.nickname.ifEmpty { profile.username },
|
text = profile.nickname.ifEmpty { profile.username },
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
// Username
|
// Username
|
||||||
if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
|
if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
|
||||||
@@ -254,6 +256,7 @@ fun ProfileScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
|
text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
if (!isDefaultAssistant) {
|
if (!isDefaultAssistant) {
|
||||||
Text(
|
Text(
|
||||||
@@ -375,6 +378,7 @@ private fun ProfileInfoCard(items: List<ProfileInfoItem>) {
|
|||||||
Text(
|
Text(
|
||||||
text = item.value,
|
text = item.value,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package top.yeij.cyrene.ui.screens.settings
|
package top.yeij.cyrene.ui.screens.settings
|
||||||
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -50,6 +52,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
@@ -394,8 +398,58 @@ fun SettingsScreen(
|
|||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showClearDialog = false
|
showClearDialog = false
|
||||||
viewModel.clearLocalMessages()
|
val activity = context as? FragmentActivity
|
||||||
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
if (activity == null) {
|
||||||
|
viewModel.clearLocalMessages()
|
||||||
|
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
val biometricManager = BiometricManager.from(context)
|
||||||
|
val canAuth = when (biometricManager.canAuthenticate(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
)) {
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (!canAuth) {
|
||||||
|
viewModel.clearLocalMessages()
|
||||||
|
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
val prompt = BiometricPrompt(
|
||||||
|
activity,
|
||||||
|
ContextCompat.getMainExecutor(context),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(
|
||||||
|
result: BiometricPrompt.AuthenticationResult,
|
||||||
|
) {
|
||||||
|
viewModel.clearLocalMessages()
|
||||||
|
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationFailed() {
|
||||||
|
Toast.makeText(context, "验证失败", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError(
|
||||||
|
errorCode: Int,
|
||||||
|
errString: CharSequence,
|
||||||
|
) {
|
||||||
|
Toast.makeText(context, "验证取消", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle("身份验证")
|
||||||
|
.setSubtitle("需要验证身份后才能清空消息记录")
|
||||||
|
.setAllowedAuthenticators(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ fun CyreneTheme(
|
|||||||
val window = (view.context as Activity).window
|
val window = (view.context as Activity).window
|
||||||
window.statusBarColor = colorScheme.background.toArgb()
|
window.statusBarColor = colorScheme.background.toArgb()
|
||||||
window.navigationBarColor = colorScheme.background.toArgb()
|
window.navigationBarColor = colorScheme.background.toArgb()
|
||||||
|
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
|
||||||
WindowCompat.getInsetsController(window, view).apply {
|
WindowCompat.getInsetsController(window, view).apply {
|
||||||
isAppearanceLightStatusBars = !darkTheme
|
isAppearanceLightStatusBars = !darkTheme
|
||||||
isAppearanceLightNavigationBars = !darkTheme
|
isAppearanceLightNavigationBars = !darkTheme
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ class NotificationHelper(private val context: Context) {
|
|||||||
notificationManager.notify(message.id.hashCode(), notification)
|
notificationManager.notify(message.id.hashCode(), notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancelAll() {
|
||||||
|
notificationManager.cancelAll()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CHANNEL_ID = "cyrene_messages"
|
private const val CHANNEL_ID = "cyrene_messages"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
<style name="Theme.Cyrene" parent="android:Theme.Material.Light.NoActionBar">
|
<style name="Theme.Cyrene" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro
|
|||||||
# Core
|
# Core
|
||||||
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
|
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
|
||||||
|
|
||||||
|
# Biometric
|
||||||
|
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user