diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 553cd171..71415dd4 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -1,21 +1,21 @@ import com.google.protobuf.gradle.id -import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.compose.compiler) alias(libs.plugins.google.protobuf) } android { namespace = "io.github.teamclouday.androidMic" - compileSdk = 36 + compileSdk { + version = release(37) + } defaultConfig { applicationId = "io.github.teamclouday.AndroidMic" minSdk = 23 - targetSdk = 36 + targetSdk = 37 versionCode = 24 versionName = "2.2.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -72,12 +72,7 @@ android { targetCompatibility = JavaVersion.VERSION_21 } - kotlin { - compilerOptions { - // set the target JVM bytecode - jvmTarget.set(JvmTarget.JVM_21) - } - } + buildFeatures { prefab = true compose = true @@ -99,7 +94,11 @@ android { // resources.excludes.add("google/protobuf/*.proto") // } - sourceSets.getByName("main").resources.srcDir("src/main/proto") + sourceSets.named("main") { + resources { + directories.add(layout.projectDirectory.dir("src/main/proto").toString()) + } + } } protobuf { @@ -150,7 +149,6 @@ dependencies { // integration test androidTestImplementation(composeBom) - androidTestImplementation(libs.test.junit.ktx) androidTestImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.runner) } \ No newline at end of file diff --git a/Android/app/src/main/java/io/github/teamclouday/androidMic/AndroidMicApp.kt b/Android/app/src/main/java/io/github/teamclouday/androidMic/AndroidMicApp.kt index 809665ee..b5d39ab4 100644 --- a/Android/app/src/main/java/io/github/teamclouday/androidMic/AndroidMicApp.kt +++ b/Android/app/src/main/java/io/github/teamclouday/androidMic/AndroidMicApp.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.launch private const val TAG = "AndroidMicApp" + class AndroidMicApp : Application() { diff --git a/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/service/ForegroundService.kt b/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/service/ForegroundService.kt index 731dc629..be4d355e 100644 --- a/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/service/ForegroundService.kt +++ b/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/service/ForegroundService.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds data class ServiceStates( @@ -37,10 +38,19 @@ const val WAIT_PERIOD = 500L const val BIND_SERVICE_ACTION = "BIND_SERVICE_ACTION" const val STOP_STREAM_ACTION = "STOP_STREAM_ACTION" +const val MUTE_ACTION = "MUTE_ACTION" +const val UNMUTE_ACTION = "UNMUTE_ACTION" class ForegroundService : Service() { private val scope = CoroutineScope(Dispatchers.Default) + fun updateNotification() { + val notificationManager = + getSystemService(NotificationManager::class.java) + val notification = messageui.getNotification(states.isMuted) + notificationManager.notify(NOTIF_ID, notification) + } + private inner class ServiceHandler(looper: Looper) : Handler(looper) { override fun handleMessage(msg: Message) { @@ -57,11 +67,13 @@ class ForegroundService : Service() { Command.Mute -> { states.isMuted = true managerAudio?.mute() + updateNotification() } Command.Unmute -> { states.isMuted = false managerAudio?.unmute() + updateNotification() } } @@ -136,6 +148,20 @@ class ForegroundService : Service() { serviceShouldStop = false } + MUTE_ACTION -> { + states.isMuted = true + managerAudio?.mute() + updateNotification() + reply(uiMessenger, ResponseData(isMuted = true)) + } + + UNMUTE_ACTION -> { + states.isMuted = false + managerAudio?.unmute() + updateNotification() + reply(uiMessenger, ResponseData(isMuted = false)) + } + else -> { Log.w(TAG, "unknown action for onStartCommand") } @@ -154,7 +180,7 @@ class ForegroundService : Service() { // (Service is not destroy when the screen rotate) serviceShouldStop = true scope.launch { - delay(3000L) + delay(3000.milliseconds) if (serviceShouldStop) stopService() } @@ -325,11 +351,10 @@ class ForegroundService : Service() { managerAudio?.start() - // the id is not important here // we need to start in foreground to use the mic - // but no need to specified a flag because we declared + // but no need to specify a flag because we declared // the type in manifest - startForeground(3, messageui.getNotification()) + startForeground(NOTIF_ID, messageui.getNotification(states.isMuted)) Log.d(TAG, "startAudio [recording]") states.isAudioStarted = true diff --git a/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/service/ServiceUtil.kt b/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/service/ServiceUtil.kt index 6f15bef5..6c3fec51 100644 --- a/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/service/ServiceUtil.kt +++ b/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/service/ServiceUtil.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.launch const val CHANNEL_ID = "Service" +const val NOTIF_ID = 3 + class MessageUi(private val ctx: ForegroundService) { // show message on UI fun showMessage(message: String) { @@ -23,7 +25,7 @@ class MessageUi(private val ctx: ForegroundService) { } - fun getNotification(): Notification { + fun getNotification(isMuted: Boolean): Notification { // launch activity val launchIntent = Intent(ctx, MainActivity::class.java).apply { flags = @@ -33,11 +35,14 @@ class MessageUi(private val ctx: ForegroundService) { PendingIntent.getActivity(ctx, 0, launchIntent, PendingIntent.FLAG_IMMUTABLE) - val stopStreamingIntent: Intent = Intent(ctx, ForegroundService::class.java) - .setAction(STOP_STREAM_ACTION) - val pStopStreamingIntent = PendingIntent.getService( - ctx, 0, stopStreamingIntent, PendingIntent.FLAG_IMMUTABLE + ctx, 0, Intent(ctx, ForegroundService::class.java) + .setAction(STOP_STREAM_ACTION), PendingIntent.FLAG_IMMUTABLE + ) + + val pMuteIntent = PendingIntent.getService( + ctx, 0, Intent(ctx, ForegroundService::class.java) + .setAction(if (isMuted) UNMUTE_ACTION else MUTE_ACTION ), PendingIntent.FLAG_IMMUTABLE ) val builder = NotificationCompat.Builder(ctx, CHANNEL_ID) @@ -54,6 +59,13 @@ class MessageUi(private val ctx: ForegroundService) { pStopStreamingIntent ) ) + .addAction( + NotificationCompat.Action( + R.drawable.ic_launcher_foreground, + ctx.getString(if (isMuted) R.string.unmute else R.string.mute), + pMuteIntent + ) + ) return builder.build() } diff --git a/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/streaming/UdpStreamer.kt b/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/streaming/UdpStreamer.kt index 4efdeea7..85521c83 100644 --- a/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/streaming/UdpStreamer.kt +++ b/Android/app/src/main/java/io/github/teamclouday/androidMic/domain/streaming/UdpStreamer.kt @@ -22,7 +22,6 @@ private const val TAG: String = "UDP streamer" class UdpStreamer(private val scope: CoroutineScope, val ip: String, var port: Int) : Streamer { - private val socket: DatagramSocket = DatagramSocket() private val address = InetAddress.getByName(ip) private var streamJob: Job? = null diff --git a/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/MainActivity.kt b/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/MainActivity.kt index dae3232f..7308f87c 100644 --- a/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/MainActivity.kt +++ b/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/MainActivity.kt @@ -15,6 +15,7 @@ import io.github.teamclouday.androidMic.ui.utils.rememberWindowInfo import io.github.teamclouday.androidMic.utils.ignore private const val TAG = "MainActivity" + class MainActivity : ComponentActivity() { val vm: MainViewModel by viewModels() diff --git a/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/MainViewModel.kt b/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/MainViewModel.kt index 0baf4718..8eebf0e3 100644 --- a/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/MainViewModel.kt +++ b/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/MainViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking private const val TAG = "MainViewModel" + class MainViewModel : ViewModel() { val prefs: AppPreferences = AndroidMicApp.appModule.appPreferences @@ -189,7 +190,6 @@ class MainViewModel : ViewModel() { } - fun setTheme(theme: Themes) { viewModelScope.launch { prefs.theme.update(theme) diff --git a/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/home/NavigationDrawer.kt b/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/home/NavigationDrawer.kt index f6e56b01..953c4e3b 100644 --- a/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/home/NavigationDrawer.kt +++ b/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/home/NavigationDrawer.kt @@ -1,5 +1,6 @@ package io.github.teamclouday.androidMic.ui.home +import android.content.ClipData import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,14 +21,17 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope import io.github.teamclouday.androidMic.Mode import io.github.teamclouday.androidMic.R import io.github.teamclouday.androidMic.ui.MainViewModel @@ -38,6 +42,7 @@ import io.github.teamclouday.androidMic.ui.home.dialog.DialogIpPort import io.github.teamclouday.androidMic.ui.home.dialog.DialogMode import io.github.teamclouday.androidMic.ui.home.dialog.DialogSampleRate import io.github.teamclouday.androidMic.ui.home.dialog.DialogTheme +import kotlinx.coroutines.launch @Composable @@ -75,7 +80,7 @@ fun DrawerBody(vm: MainViewModel) { subTitle = mode.value.toString(), contentDescription = "set mode", icon = Icons.Rounded.Settings, - expanded = dialogModeExpanded + onClick = { dialogModeExpanded.value = true }, ) if (mode.value != Mode.USB) { @@ -92,13 +97,13 @@ fun DrawerBody(vm: MainViewModel) { subTitle = (if (mode.value != Mode.ADB) (vm.prefs.ip.getAsState().value + ":") else "") + vm.prefs.port.getAsState().value, contentDescription = "set ip and port", icon = Icons.Rounded.Wifi, - expanded = dialogIpPortExpanded + onClick = { dialogIpPortExpanded.value = true }, ) } // Audio - SettingsItemsSubtitle(R.string.drawer_subtitle_record) + SettingsItemsSubtitle(R.string.drawer_subtitle_audio) val dialogSampleRateExpanded = rememberSaveable { mutableStateOf(false) @@ -108,7 +113,7 @@ fun DrawerBody(vm: MainViewModel) { title = stringResource(id = R.string.sample_rate), subTitle = vm.prefs.sampleRate.getAsState().value.value.toString(), contentDescription = "set sample rate", - expanded = dialogSampleRateExpanded + onClick = { dialogSampleRateExpanded.value = true }, ) val dialogChannelCountExpanded = rememberSaveable { @@ -119,7 +124,7 @@ fun DrawerBody(vm: MainViewModel) { title = stringResource(id = R.string.channel_count), subTitle = vm.prefs.channelCount.getAsState().value.getString(), contentDescription = "set channel count", - expanded = dialogChannelCountExpanded + onClick = { dialogChannelCountExpanded.value = true }, ) val dialogAudioFormatExpanded = rememberSaveable { @@ -130,7 +135,7 @@ fun DrawerBody(vm: MainViewModel) { title = stringResource(id = R.string.audio_format), subTitle = vm.prefs.audioFormat.getAsState().value.toString(), contentDescription = "set audio format", - expanded = dialogAudioFormatExpanded + onClick = { dialogAudioFormatExpanded.value = true }, ) val dialogAudioSourceExpanded = rememberSaveable { @@ -141,7 +146,7 @@ fun DrawerBody(vm: MainViewModel) { title = stringResource(id = R.string.audio_source), subTitle = vm.prefs.audioSource.getAsState().value.toString(), contentDescription = "set audio source", - expanded = dialogAudioSourceExpanded + onClick = { dialogAudioSourceExpanded.value = true }, ) // Other @@ -156,14 +161,26 @@ fun DrawerBody(vm: MainViewModel) { subTitle = vm.prefs.theme.getAsState().value.toString(), contentDescription = "set theme", icon = Icons.Rounded.DarkMode, - expanded = dialogThemesExpanded + onClick = { dialogThemesExpanded.value = true }, ) - SettingsItemReadOnly( + val clipboard = LocalClipboard.current + + val appVersion = remember { + vm.uiHelper.getAppVersion() + } + SettingsItem( title = stringResource(id = R.string.drawerVersion), - subTitle = vm.uiHelper.getAppVersion(), + subTitle = appVersion, contentDescription = "app version", - icon = Icons.Rounded.Verified + icon = Icons.Rounded.Verified, + onClick = { + vm.viewModelScope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText("version", appVersion)) + ) + } + } ) } } @@ -187,58 +204,21 @@ private fun SettingsItemsSubtitle( HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) } + @Composable private fun SettingsItem( title: String, subTitle: String, contentDescription: String, icon: ImageVector? = null, - expanded: MutableState + onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable { - expanded.value = true - } - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onBackground - ) - } - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onBackground + .clickable( + onClick = onClick ) - - Text( - text = subTitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground - ) - } - } - HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) -} - -@Composable -private fun SettingsItemReadOnly( - title: String, - subTitle: String, - contentDescription: String, - icon: ImageVector? = null -) { - Row( - modifier = Modifier - .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -265,4 +245,4 @@ private fun SettingsItemReadOnly( } } HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) -} +} \ No newline at end of file diff --git a/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/home/dialog/ipPort.kt b/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/home/dialog/ipPort.kt index 123dc9c7..71834514 100644 --- a/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/home/dialog/ipPort.kt +++ b/Android/app/src/main/java/io/github/teamclouday/androidMic/ui/home/dialog/ipPort.kt @@ -49,7 +49,7 @@ fun DialogIpPort(vm: MainViewModel, expanded: MutableState, portOnly: B horizontalAlignment = Alignment.CenterHorizontally ) { // ip field - if(!portOnly) { + if (!portOnly) { ManagerOutlinedTextField(tempIp, R.string.dialog_ip) Spacer(modifier = Modifier.height(10.dp)) diff --git a/Android/app/src/main/res/values-fr/strings.xml b/Android/app/src/main/res/values-fr/strings.xml index 9617c432..aad83935 100644 --- a/Android/app/src/main/res/values-fr/strings.xml +++ b/Android/app/src/main/res/values-fr/strings.xml @@ -11,12 +11,15 @@ --> Paramètre Connection + Audio Autre Mode de connection + Source audio Thèmes Couleur dynamique + Version Réinitialiser Enregistrer Annuler @@ -53,5 +56,13 @@ Début enregistrement Arrêt stream Arrêt enregistrement + IP:PORT + IP + PORT + Fréquence d\'échantillonnage + Nombre de canaux + Format audio + Rendre muet + Réactiver le son \ No newline at end of file diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index 424982ef..1c3d8433 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -10,7 +10,7 @@ --> 设置 连接 - 录制 + 录制 其它 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 4297c096..7d488a51 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ --> Setting Connection - Record + Audio Other @@ -68,4 +68,8 @@ Stop streaming Stop recording + + Mute + Unmute + \ No newline at end of file diff --git a/Android/gradle.properties b/Android/gradle.properties index a9d5d0bc..6eb062d4 100644 --- a/Android/gradle.properties +++ b/Android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true -android.enableJetifier=true kotlin.code.style=official \ No newline at end of file diff --git a/Android/gradle/libs.versions.toml b/Android/gradle/libs.versions.toml index c90c59cd..f8270494 100644 --- a/Android/gradle/libs.versions.toml +++ b/Android/gradle/libs.versions.toml @@ -1,23 +1,23 @@ [versions] -agp = "8.13.1" -kotlin = "2.2.21" -protobuf = "0.9.4" -protobuf-kotlin = "3.25.5" -ktx-lifecycle = "2.10.0" +agp = "9.2.1" +kotlin = "2.4.0" +protobuf = "0.10.0" +protobuf-kotlin = "4.35.1" +ktx-lifecycle = "2.11.0" [libraries] # AndroidX Core -androidx-ktx = { group = "androidx.core", name = "core-ktx", version = "1.17.0" } +androidx-ktx = { group = "androidx.core", name = "core-ktx", version = "1.19.0" } runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "ktx-lifecycle" } runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "ktx-lifecycle" } androidx-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "ktx-lifecycle" } -compose-activity = { group = "androidx.activity", name = "activity-compose", version = "1.12.1" } -datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version = "1.2.0" } +compose-activity = { group = "androidx.activity", name = "activity-compose", version = "1.13.0" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version = "1.2.1" } # Compose # https://central.sonatype.com/artifact/dev.chrisbanes.compose/compose-bom/versions # compose-bom-alpha = { group = "dev.chrisbanes.compose", name = "compose-bom", version = "2024.05.00-alpha03" } -compose-bom = { group = "androidx.compose", name = "compose-bom", version = "2025.12.00" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version = "2026.06.01" } compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } @@ -30,7 +30,7 @@ protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin" protobuf-java-lite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobuf-kotlin" } # Test -kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.10.2" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.11.0" } test-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version = "1.3.0" } androidx-runner = { group = "androidx.test", name = "runner", version = "1.7.0" } diff --git a/Android/gradle/wrapper/gradle-wrapper.properties b/Android/gradle/wrapper/gradle-wrapper.properties index d4081da4..a351597e 100644 --- a/Android/gradle/wrapper/gradle-wrapper.properties +++ b/Android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME