From af6d8426ba15c801bfee0b9902ede4bb4e630db6 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 16:36:34 +0300 Subject: [PATCH] android: fix voice permissions, theme apply, and profile avatar layout --- android/CHANGELOG.md | 8 +++++ android/app/src/main/AndroidManifest.xml | 2 +- .../ru/daemonlord/messenger/MainActivity.kt | 8 ++--- .../messenger/ui/chat/ChatScreen.kt | 25 +++++++++----- .../messenger/ui/chat/voice/VoiceRecorder.kt | 3 +- .../messenger/ui/profile/ProfileScreen.kt | 33 ++++++++++++++++--- .../messenger/ui/theme/MessengerTheme.kt | 24 ++++++++++++++ 7 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/theme/MessengerTheme.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index d45efb4..d6a08e0 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -429,3 +429,11 @@ - Added accessibility refinements for key surfaces and controls: - explicit content descriptions for avatars and tab-like controls, - voice record button semantic label for TalkBack. + +### Step 69 - Bugfix pass: voice recording, theme apply, profile avatar UX +- Fixed voice recording start on Android by switching `VoiceRecorder` to compatible `MediaRecorder()` initialization. +- Fixed microphone permission flow: record action now triggers runtime permission request reliably and auto-starts recording after grant. +- Fixed theme switching application by introducing app-level `MessengerTheme` and switching app manifest base theme to DayNight. +- Fixed profile screen usability after avatar upload: + - enabled vertical scrolling with safe insets/navigation padding, + - constrained avatar preview to a centered circular area instead of full-screen takeover. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0eacc97..52b8256 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ android:roundIcon="@android:drawable/sym_def_app_icon" android:supportsRtl="true" android:usesCleartextTraffic="true" - android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"> diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt index 1581c80..0e62f3a 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt @@ -2,10 +2,8 @@ package ru.daemonlord.messenger import android.content.Intent import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -13,12 +11,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.fillMaxSize +import androidx.appcompat.app.AppCompatActivity import dagger.hilt.android.AndroidEntryPoint import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras import ru.daemonlord.messenger.ui.navigation.MessengerNavHost +import ru.daemonlord.messenger.ui.theme.MessengerTheme @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class MainActivity : AppCompatActivity() { private var pendingInviteToken by mutableStateOf(null) private var pendingVerifyEmailToken by mutableStateOf(null) private var pendingResetPasswordToken by mutableStateOf(null) @@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() { pendingNotificationMessageId = notificationPayload?.second enableEdgeToEdge() setContent { - MaterialTheme { + MessengerTheme { Surface(modifier = Modifier.fillMaxSize()) { AppRoot( inviteToken = pendingInviteToken, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 674155c..5b1f1d2 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -94,10 +94,24 @@ fun ChatRoute( ) == PackageManager.PERMISSION_GRANTED ) } + var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) } + val startVoiceRecording: () -> Unit = { + val started = voiceRecorder.start() + if (started) { + AppAudioFocusCoordinator.request("voice-recording") + viewModel.onVoiceRecordStarted() + } else { + viewModel.onVoiceRecordCancelled() + } + } val audioPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> hasAudioPermission = granted + if (granted && startRecordingAfterPermissionGrant) { + startVoiceRecording() + } + startRecordingAfterPermissionGrant = false } DisposableEffect(voiceRecorder) { onDispose { @@ -137,15 +151,10 @@ fun ChatRoute( onPickMedia = { pickMediaLauncher.launch("*/*") }, onVoiceRecordStart = { if (!hasAudioPermission) { + startRecordingAfterPermissionGrant = true audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } else { - val started = voiceRecorder.start() - if (started) { - AppAudioFocusCoordinator.request("voice-recording") - viewModel.onVoiceRecordStarted() - } else { - viewModel.onVoiceRecordCancelled() - } + startVoiceRecording() } }, onVoiceRecordTick = { @@ -1263,7 +1272,7 @@ private fun VoiceHoldToRecordButton( }, ) { Button( - onClick = {}, + onClick = onStart, enabled = enabled, modifier = Modifier.semantics { contentDescription = "Hold to record voice message" }, ) { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt index 0b7f478..b717822 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt @@ -13,10 +13,11 @@ class VoiceRecorder(private val context: Context) { fun start(): Boolean { return runCatching { val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a") - val mediaRecorder = MediaRecorder(context).apply { + val mediaRecorder = MediaRecorder().apply { setAudioSource(MediaRecorder.AudioSource.MIC) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioChannels(1) setAudioEncodingBitRate(64_000) setAudioSamplingRate(44_100) setOutputFile(file.absolutePath) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt index c2d310c..5b29364 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt @@ -8,15 +8,23 @@ import android.os.Build import android.provider.MediaStore import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -29,6 +37,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.draw.clip import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -69,6 +78,7 @@ fun ProfileScreen( } val context = androidx.compose.ui.platform.LocalContext.current + val scrollState = rememberScrollState() val pickAvatarLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), ) { uri -> @@ -86,6 +96,8 @@ fun ProfileScreen( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(scrollState) + .navigationBarsPadding() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -94,11 +106,24 @@ fun ProfileScreen( style = MaterialTheme.typography.headlineSmall, ) if (!avatarUrl.isBlank()) { - AsyncImage( - model = avatarUrl, - contentDescription = "Avatar", + Box( modifier = Modifier.fillMaxWidth(), - ) + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = avatarUrl, + contentDescription = "Avatar", + modifier = Modifier + .size(180.dp) + .aspectRatio(1f) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = CircleShape, + ), + ) + } } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/theme/MessengerTheme.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/theme/MessengerTheme.kt new file mode 100644 index 0000000..db79329 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/theme/MessengerTheme.kt @@ -0,0 +1,24 @@ +package ru.daemonlord.messenger.ui.theme + +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val LightColors = lightColorScheme() +private val DarkColors = darkColorScheme() + +@Composable +fun MessengerTheme(content: @Composable () -> Unit) { + val darkTheme = when (AppCompatDelegate.getDefaultNightMode()) { + AppCompatDelegate.MODE_NIGHT_YES -> true + AppCompatDelegate.MODE_NIGHT_NO -> false + else -> isSystemInDarkTheme() + } + MaterialTheme( + colorScheme = if (darkTheme) DarkColors else LightColors, + content = content, + ) +}