android: fix voice permissions, theme apply, and profile avatar layout
Some checks failed
Android CI / android (push) Failing after 4m7s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 16:36:34 +03:00
parent 881ad99ada
commit af6d8426ba
7 changed files with 85 additions and 18 deletions

View File

@@ -429,3 +429,11 @@
- Added accessibility refinements for key surfaces and controls: - Added accessibility refinements for key surfaces and controls:
- explicit content descriptions for avatars and tab-like controls, - explicit content descriptions for avatars and tab-like controls,
- voice record button semantic label for TalkBack. - 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.

View File

@@ -13,7 +13,7 @@
android:roundIcon="@android:drawable/sym_def_app_icon" android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">

View File

@@ -2,10 +2,8 @@ package ru.daemonlord.messenger
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -13,12 +11,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
import ru.daemonlord.messenger.ui.theme.MessengerTheme
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
private var pendingInviteToken by mutableStateOf<String?>(null) private var pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null) private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null) private var pendingResetPasswordToken by mutableStateOf<String?>(null)
@@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
pendingNotificationMessageId = notificationPayload?.second pendingNotificationMessageId = notificationPayload?.second
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MaterialTheme { MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
AppRoot( AppRoot(
inviteToken = pendingInviteToken, inviteToken = pendingInviteToken,

View File

@@ -94,10 +94,24 @@ fun ChatRoute(
) == PackageManager.PERMISSION_GRANTED ) == 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( val audioPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
) { granted -> ) { granted ->
hasAudioPermission = granted hasAudioPermission = granted
if (granted && startRecordingAfterPermissionGrant) {
startVoiceRecording()
}
startRecordingAfterPermissionGrant = false
} }
DisposableEffect(voiceRecorder) { DisposableEffect(voiceRecorder) {
onDispose { onDispose {
@@ -137,15 +151,10 @@ fun ChatRoute(
onPickMedia = { pickMediaLauncher.launch("*/*") }, onPickMedia = { pickMediaLauncher.launch("*/*") },
onVoiceRecordStart = { onVoiceRecordStart = {
if (!hasAudioPermission) { if (!hasAudioPermission) {
startRecordingAfterPermissionGrant = true
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
} else { } else {
val started = voiceRecorder.start() startVoiceRecording()
if (started) {
AppAudioFocusCoordinator.request("voice-recording")
viewModel.onVoiceRecordStarted()
} else {
viewModel.onVoiceRecordCancelled()
}
} }
}, },
onVoiceRecordTick = { onVoiceRecordTick = {
@@ -1263,7 +1272,7 @@ private fun VoiceHoldToRecordButton(
}, },
) { ) {
Button( Button(
onClick = {}, onClick = onStart,
enabled = enabled, enabled = enabled,
modifier = Modifier.semantics { contentDescription = "Hold to record voice message" }, modifier = Modifier.semantics { contentDescription = "Hold to record voice message" },
) { ) {

View File

@@ -13,10 +13,11 @@ class VoiceRecorder(private val context: Context) {
fun start(): Boolean { fun start(): Boolean {
return runCatching { return runCatching {
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a") val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
val mediaRecorder = MediaRecorder(context).apply { val mediaRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC) setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioChannels(1)
setAudioEncodingBitRate(64_000) setAudioEncodingBitRate(64_000)
setAudioSamplingRate(44_100) setAudioSamplingRate(44_100)
setOutputFile(file.absolutePath) setOutputFile(file.absolutePath)

View File

@@ -8,15 +8,23 @@ import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -29,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.clip
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -69,6 +78,7 @@ fun ProfileScreen(
} }
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val scrollState = rememberScrollState()
val pickAvatarLauncher = rememberLauncherForActivityResult( val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(), contract = ActivityResultContracts.GetContent(),
) { uri -> ) { uri ->
@@ -86,6 +96,8 @@ fun ProfileScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(scrollState)
.navigationBarsPadding()
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -94,11 +106,24 @@ fun ProfileScreen(
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
) )
if (!avatarUrl.isBlank()) { if (!avatarUrl.isBlank()) {
AsyncImage( Box(
model = avatarUrl,
contentDescription = "Avatar",
modifier = Modifier.fillMaxWidth(), 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( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),

View File

@@ -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,
)
}