[optimize] Optimize UI performance

This commit is contained in:
acite
2025-08-26 13:22:16 +08:00
parent 6d89a6f5c2
commit 3ed53ee593
12 changed files with 90 additions and 104 deletions

2
.gitignore vendored
View File

@@ -15,7 +15,7 @@ captures/
*.aab *.aab
*.apk *.apk
output-metadata.json output-metadata.json
release/
# IntelliJ # IntelliJ
*.iml *.iml
.idea/ .idea/

View File

@@ -26,6 +26,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = signingConfigs.getByName("debug")
} }
} }
compileOptions { compileOptions {
@@ -67,6 +68,7 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.media3.datasource.okhttp)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -1,66 +0,0 @@
import android.content.Context
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.ByteArrayDataSource
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
private fun InMemoryVideoPlayer(
modifier: Modifier = Modifier,
videoData: ByteArray
) {
val context = LocalContext.current
val exoPlayer = remember(context, videoData) {
createExoPlayer(context, videoData)
}
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}
AndroidView(
modifier = modifier,
factory = {
PlayerView(it).apply {
player = exoPlayer
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
}
)
}
@androidx.annotation.OptIn(UnstableApi::class)
private fun createExoPlayer(context: Context, videoData: ByteArray): ExoPlayer {
val byteArrayDataSource = ByteArrayDataSource(videoData)
val factory = DataSource.Factory {
byteArrayDataSource
}
val mediaSource = ProgressiveMediaSource.Factory(factory)
.createMediaSource(MediaItem.fromUri("data://local/video.mp4"))
return ExoPlayer.Builder(context).build().apply {
setMediaSource(mediaSource)
prepare()
playWhenReady = false
}
}

View File

@@ -69,6 +69,11 @@ object ApiClient {
} }
} }
fun createOkHttp(): OkHttpClient
{
return createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))
}
private fun createRetrofit(): Retrofit { private fun createRetrofit(): Retrofit {
val okHttpClient = createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) val okHttpClient = createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))

View File

@@ -23,16 +23,17 @@ object MediaManager
} }
} }
suspend fun listVideos(klass: String): List<Video> suspend fun listVideos(klass: String, callback: (Video) -> Unit)
{ {
try { val j = ApiClient.api!!.queryVideoClasses(klass, token)
val j = ApiClient.api!!.queryVideoClasses(klass, token) for(it in j)
return j.map{
queryVideo(klass, it)!!
}.toList()
}catch (e: Exception)
{ {
return listOf() try {
callback(queryVideo(klass, it)!!)
}catch (e: Exception)
{
}
} }
} }

View File

@@ -58,7 +58,7 @@ fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navContro
Global.sameClassVideos = RecentManager.recent Global.sameClassVideos = RecentManager.recent
val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }" val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }"
navController.navigate(route) navController.navigate(route)
}) }, homeScreenViewModel.imageLoader!!)
HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color) HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color)
} }
} }

View File

@@ -88,6 +88,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.ImageLoader
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.acitelight.aether.Global import com.acitelight.aether.Global
@@ -572,7 +573,7 @@ fun VideoPlayerPortal(videoPlayerViewModel: VideoPlayerViewModel, navController:
videoPlayerViewModel._player?.pause() videoPlayerViewModel._player?.pause()
val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }" val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }"
navController.navigate(route) navController.navigate(route)
}) }, videoPlayerViewModel.imageLoader!!)
HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color) HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color)
} }
} }
@@ -683,13 +684,13 @@ fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel)
contentPadding = PaddingValues(horizontal = 24.dp) contentPadding = PaddingValues(horizontal = 24.dp)
) { ) {
items(videoPlayerViewModel.video?.getGallery() ?: listOf()) { it -> items(videoPlayerViewModel.video?.getGallery() ?: listOf()) { it ->
SingleImageItem(img = it) SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!)
} }
} }
} }
@Composable @Composable
fun SingleImageItem(img: KeyImage) { fun SingleImageItem(img: KeyImage, imageLoader: ImageLoader) {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(LocalContext.current)
.data(img.url) .data(img.url)
@@ -700,7 +701,8 @@ fun SingleImageItem(img: KeyImage) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)), .clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
imageLoader = imageLoader
) )
} }
@@ -912,7 +914,7 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
} }
@Composable @Composable
fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit) fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit, imageLoader: ImageLoader)
{ {
var isImageLoaded by remember { mutableStateOf(false) } var isImageLoaded by remember { mutableStateOf(false) }
Card( Card(
@@ -943,7 +945,8 @@ fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit)
modifier = Modifier modifier = Modifier
.width(128.dp).fillMaxHeight() .width(128.dp).fillMaxHeight()
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
imageLoader = imageLoader
) )
Column ( Column (

View File

@@ -72,7 +72,7 @@ fun String.hexToString(charset: Charset = Charsets.UTF_8): String {
@Composable @Composable
fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navController: NavHostController) fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navController: NavHostController)
{ {
val videoList by videoScreenViewModel.videos.collectAsState() videoScreenViewModel.SetupClient()
Column( Column(
modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth() modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth()
@@ -86,7 +86,7 @@ fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navCon
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) )
{ {
items(videoList) { video -> items(videoScreenViewModel.videos) { video ->
VideoCard(video, navController, videoScreenViewModel) VideoCard(video, navController, videoScreenViewModel)
} }
} }
@@ -115,15 +115,13 @@ fun TopRow(videoScreenViewModel: VideoScreenViewModel)
@Composable @Composable
fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) { fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) {
val videoList by videoScreenViewModel.videos.collectAsState()
Card( Card(
shape = RoundedCornerShape(6.dp), shape = RoundedCornerShape(6.dp),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight(), .wrapContentHeight(),
onClick = { onClick = {
Global.sameClassVideos = videoList Global.sameClassVideos = videoScreenViewModel.videos
val route = "video_player_route/${ "${video.klass}/${video.id}".toHex() }" val route = "video_player_route/${ "${video.klass}/${video.id}".toHex() }"
navController.navigate(route) navController.navigate(route)
} }
@@ -142,7 +140,8 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
imageLoader = videoScreenViewModel.imageLoader!!
) )
Text( Text(

View File

@@ -11,11 +11,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import okhttp3.OkHttpClient
import com.acitelight.aether.Global import com.acitelight.aether.Global
import com.acitelight.aether.dataStore import com.acitelight.aether.dataStore
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex import com.acitelight.aether.model.VideoQueryIndex
import com.acitelight.aether.service.ApiClient import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.AuthManager import com.acitelight.aether.service.AuthManager
import com.acitelight.aether.service.MediaManager import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.MediaManager.token import com.acitelight.aether.service.MediaManager.token
@@ -52,6 +56,7 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
} }
var _init = false var _init = false
var imageLoader: ImageLoader? = null;
@Composable @Composable
fun Init(){ fun Init(){
@@ -59,6 +64,11 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
_init = true _init = true
val context = LocalContext.current val context = LocalContext.current
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
remember { remember {
viewModelScope.launch { viewModelScope.launch {
RecentManager.Query(context) RecentManager.Query(context)

View File

@@ -16,10 +16,15 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_READY import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.Global import com.acitelight.aether.Global
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex import com.acitelight.aether.model.VideoQueryIndex
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.MediaManager import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.view.hexToString import com.acitelight.aether.view.hexToString
@@ -46,6 +51,10 @@ class VideoPlayerViewModel() : ViewModel()
var renderedFirst = false var renderedFirst = false
var video: Video? = null var video: Video? = null
val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp())
var imageLoader: ImageLoader? = null;
@OptIn(UnstableApi::class)
@Composable @Composable
fun Init(videoId: String) fun Init(videoId: String)
{ {
@@ -53,11 +62,20 @@ class VideoPlayerViewModel() : ViewModel()
val context = LocalContext.current val context = LocalContext.current
val v = videoId.hexToString() val v = videoId.hexToString()
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
remember { remember {
viewModelScope.launch { viewModelScope.launch {
video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!!
RecentManager.Push(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1])) RecentManager.Push(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1]))
_player = ExoPlayer.Builder(context).build().apply { _player = ExoPlayer
.Builder(context)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
.build().apply {
val url = video?.getVideo() ?: "" val url = video?.getVideo() ?: ""
val mediaItem = MediaItem.fromUri(url) val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem) setMediaItem(mediaItem)

View File

@@ -1,18 +1,24 @@
package com.acitelight.aether.viewModel package com.acitelight.aether.viewModel
import android.app.Application import android.app.Application
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.platform.LocalContext
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.Global import com.acitelight.aether.Global
import com.acitelight.aether.dataStore import com.acitelight.aether.dataStore
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import com.acitelight.aether.service.ApiClient import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.AuthManager import com.acitelight.aether.service.AuthManager
import com.acitelight.aether.service.MediaManager import com.acitelight.aether.service.MediaManager
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -39,15 +45,27 @@ class VideoScreenViewModel(application: Application) : AndroidViewModel(applicat
private val _tabIndex = mutableIntStateOf(0) private val _tabIndex = mutableIntStateOf(0)
val tabIndex: State<Int> = _tabIndex val tabIndex: State<Int> = _tabIndex
private val _videos = MutableStateFlow<List<Video>>(emptyList()) val videos = mutableStateListOf<Video>()
val videos: StateFlow<List<Video>> = _videos
private val _klasses = MutableStateFlow<List<String>>(emptyList()) private val _klasses = MutableStateFlow<List<String>>(emptyList())
val klasses: StateFlow<List<String>> = _klasses; val klasses: StateFlow<List<String>> = _klasses;
var imageLoader: ImageLoader? = null;
@Composable
fun SetupClient()
{
val context = LocalContext.current
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
}
suspend fun init() { suspend fun init() {
_klasses.value = MediaManager.listVideoKlasses() _klasses.value = MediaManager.listVideoKlasses()
val p = MediaManager.listVideos(_klasses.value.first()) MediaManager.listVideos(_klasses.value.first()){
_videos.value = p v -> videos.add(videos.size, v)
}
} }
fun setTabIndex(index: Int) fun setTabIndex(index: Int)
@@ -55,8 +73,11 @@ class VideoScreenViewModel(application: Application) : AndroidViewModel(applicat
viewModelScope.launch() viewModelScope.launch()
{ {
_tabIndex.intValue = index; _tabIndex.intValue = index;
val p = MediaManager.listVideos(_klasses.value[index]) videos.clear()
_videos.value = p MediaManager.listVideos(_klasses.value[index])
{
v -> videos.add(videos.size, v)
}
} }
} }

View File

@@ -14,7 +14,6 @@ junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
kotlinxSerializationJson = "1.9.0" kotlinxSerializationJson = "1.9.0"
kotlinxSerializationJsonVersion = "1.9.0"
lifecycleRuntimeKtx = "2.9.2" lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2025.08.00" composeBom = "2025.08.00"
@@ -25,9 +24,7 @@ navigationCompose = "2.9.3"
okhttp = "5.1.0" okhttp = "5.1.0"
retrofit = "3.0.0" retrofit = "3.0.0"
retrofit2KotlinxSerializationConverter = "1.0.0" retrofit2KotlinxSerializationConverter = "1.0.0"
retrofitVersion = "3.0.0" media3DatasourceOkhttp = "1.8.0"
tinkAndroid = "1.18.0"
tweetnaclJava = "1.0.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -38,7 +35,6 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" } bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" }
bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" }
@@ -57,13 +53,10 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-serialization-json-v163 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJsonVersion" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-v2110 = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" }
retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" }
tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tinkAndroid" } androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" }
tweetnacl-java = { module = "com.github.InstantWebP2P:tweetnacl-java", version.ref = "tweetnaclJava" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }