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:
- 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.

View File

@@ -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">
<activity
android:name=".MainActivity"
android:exported="true">

View File

@@ -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<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
@@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
pendingNotificationMessageId = notificationPayload?.second
enableEdgeToEdge()
setContent {
MaterialTheme {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) {
AppRoot(
inviteToken = pendingInviteToken,

View File

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

View File

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

View File

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

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