fix(android): align lint gates and photo permission handling

This commit is contained in:
Peter Steinberger
2026-03-02 04:27:33 +00:00
parent 37d036714e
commit fa9148400e
12 changed files with 54 additions and 77 deletions

View File

@@ -37,6 +37,10 @@ Docs: https://docs.openclaw.ai
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
- Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
- Android/Photos permissions: declare Android 14+ selected-photo access permission (`READ_MEDIA_VISUAL_USER_SELECTED`) and align Android permission/settings paths with current minSdk behavior for more reliable permission state handling.
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) Thanks @icesword0760.
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
@@ -118,7 +122,10 @@ Docs: https://docs.openclaw.ai
- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
- Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.
- Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
- Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.
## Unreleased
### Changes

View File

@@ -66,6 +66,7 @@ android {
lint {
disable +=
setOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"IconLauncherShape",
"NewerVersionAvailable",

View File

@@ -16,6 +16,7 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />

View File

@@ -359,6 +359,7 @@ class CameraCaptureManager(private val context: Context) {
.build()
}
@SuppressLint("UnsafeOptInUsageError")
private fun cameraDeviceInfoOrNull(info: CameraInfo): CameraDeviceInfo? {
val cameraId = cameraIdOrNull(info) ?: return null
val lensFacing =
@@ -389,6 +390,7 @@ class CameraCaptureManager(private val context: Context) {
)
}
@SuppressLint("UnsafeOptInUsageError")
private fun cameraIdOrNull(info: CameraInfo): String? =
runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
}

View File

@@ -136,12 +136,7 @@ class DeviceHandler(
} else {
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
}
val motionGranted =
if (Build.VERSION.SDK_INT >= 29) {
hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
} else {
true
}
val motionGranted = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
val notificationsGranted =
if (Build.VERSION.SDK_INT >= 33) {
hasPermission(Manifest.permission.POST_NOTIFICATIONS)
@@ -228,7 +223,7 @@ class DeviceHandler(
"motion",
permissionStateJson(
granted = motionGranted,
promptableWhenDenied = Build.VERSION.SDK_INT >= 29,
promptableWhenDenied = true,
),
)
// Screen capture on Android is interactive per-capture consent, not a sticky app permission.

View File

@@ -6,7 +6,6 @@ import android.app.RemoteInput
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import kotlinx.serialization.json.JsonPrimitive
@@ -234,9 +233,6 @@ class DeviceNotificationListenerService : NotificationListenerService() {
}
fun requestServiceRebind(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
runCatching {
NotificationListenerService.requestRebind(serviceComponent(context))
}

View File

@@ -6,7 +6,6 @@ import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.android.gateway.GatewaySession
@@ -85,7 +84,6 @@ private object SystemMotionDataSource : MotionDataSource {
}
override fun hasPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 29) return true
return ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}

View File

@@ -11,6 +11,7 @@ import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.core.content.ContextCompat
import androidx.core.graphics.scale
import ai.openclaw.android.gateway.GatewaySession
import java.io.ByteArrayOutputStream
import java.time.Instant
@@ -158,7 +159,7 @@ private object SystemPhotosDataSource : PhotosDataSource {
if (decoded.width <= maxWidth) return decoded
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
return Bitmap.createScaledBitmap(decoded, maxWidth, targetHeight, true)
return decoded.scale(maxWidth, targetHeight, true)
}
private fun computeInSampleSize(width: Int, maxWidth: Int): Int {
@@ -198,7 +199,7 @@ private object SystemPhotosDataSource : PhotosDataSource {
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
if (nextWidth >= working.width) return null
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
working = Bitmap.createScaledBitmap(working, nextWidth, nextHeight, true)
working = working.scale(nextWidth, nextHeight, true)
}
return null
}

View File

@@ -67,9 +67,6 @@ private class AndroidSystemNotificationPoster(
}
private fun ensureChannel(priority: String?): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return NOTIFICATION_CHANNEL_BASE_ID
}
val normalizedPriority = priority.orEmpty().trim().lowercase()
val (suffix, importance, name) =
when (normalizedPriority) {

View File

@@ -80,6 +80,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -242,7 +243,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
remember(context) {
hasMotionCapabilities(context)
}
val motionPermissionRequired = Build.VERSION.SDK_INT >= 29
val motionPermissionRequired = true
val notificationsPermissionRequired = Build.VERSION.SDK_INT >= 33
val discoveryPermission =
if (Build.VERSION.SDK_INT >= 33) {
@@ -1635,7 +1636,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
}
private fun canInstallUnknownApps(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 26) return true
return context.packageManager.canRequestPackageInstalls()
}
@@ -1649,11 +1649,10 @@ private fun openNotificationListenerSettings(context: Context) {
}
private fun openUnknownAppSourcesSettings(context: Context) {
if (Build.VERSION.SDK_INT < 26) return
val intent =
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
"package:${context.packageName}".toUri(),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching {
context.startActivity(intent)

View File

@@ -62,6 +62,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -171,7 +172,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
val motionPermissionRequired = Build.VERSION.SDK_INT >= 29
val motionPermissionRequired = true
val motionAvailable = remember(context) { hasMotionCapabilities(context) }
var notificationsPermissionGranted by
@@ -424,7 +425,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Microphone permission", style = mobileHeadline) },
supportingContent = {
@@ -477,7 +478,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Allow Camera", style = mobileHeadline) },
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) },
@@ -510,7 +511,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
else -> "Grant"
}
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("SMS Permission", style = mobileHeadline) },
supportingContent = {
@@ -561,7 +562,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
"Grant"
}
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("System Notifications", style = mobileHeadline) },
supportingContent = {
@@ -589,7 +590,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Notification Listener Access", style = mobileHeadline) },
supportingContent = {
@@ -624,7 +625,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Photos Permission", style = mobileHeadline) },
supportingContent = {
@@ -655,7 +656,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Contacts Permission", style = mobileHeadline) },
supportingContent = {
@@ -686,7 +687,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Calendar Permission", style = mobileHeadline) },
supportingContent = {
@@ -724,7 +725,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
else -> "Grant"
}
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Motion Permission", style = mobileHeadline) },
supportingContent = {
@@ -768,7 +769,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Install App Updates", style = mobileHeadline) },
supportingContent = {
@@ -802,7 +803,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
item {
Column(modifier = settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) {
Column(modifier = Modifier.settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) {
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
@@ -877,7 +878,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Prevent Sleep", style = mobileHeadline) },
supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) },
@@ -897,7 +898,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
ListItem(
modifier = settingsRowModifier(),
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) },
supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) },
@@ -927,8 +928,8 @@ private fun settingsTextFieldColors() =
cursorColor = mobileAccent,
)
private fun settingsRowModifier() =
Modifier
private fun Modifier.settingsRowModifier() =
this
.fillMaxWidth()
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
.background(Color.White, RoundedCornerShape(14.dp))
@@ -970,14 +971,10 @@ private fun openNotificationListenerSettings(context: Context) {
}
private fun openUnknownAppSourcesSettings(context: Context) {
if (Build.VERSION.SDK_INT < 26) {
openAppSettings(context)
return
}
val intent =
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
"package:${context.packageName}".toUri(),
)
runCatching {
context.startActivity(intent)
@@ -997,7 +994,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
}
private fun canInstallUnknownApps(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 26) return true
return context.packageManager.canRequestPackageInstalls()
}

View File

@@ -24,7 +24,6 @@ import androidx.core.content.ContextCompat
import ai.openclaw.android.gateway.GatewaySession
import ai.openclaw.android.isCanonicalMainSessionKey
import ai.openclaw.android.normalizeMainKey
import android.os.Build
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
@@ -1316,43 +1315,28 @@ private const val defaultTalkProvider = "elevenlabs"
private fun requestAudioFocusForTts(): Boolean {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return true
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setOnAudioFocusChangeListener(audioFocusListener)
.build()
audioFocusRequest = req
val result = am.requestAudioFocus(req)
Log.d(tag, "audio focus request result=$result")
result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED || result == AudioManager.AUDIOFOCUS_REQUEST_DELAYED
} else {
@Suppress("DEPRECATION")
val result = am.requestAudioFocus(
audioFocusListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK,
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
.setOnAudioFocusChangeListener(audioFocusListener)
.build()
audioFocusRequest = req
val result = am.requestAudioFocus(req)
Log.d(tag, "audio focus request result=$result")
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED || result == AudioManager.AUDIOFOCUS_REQUEST_DELAYED
}
private fun abandonAudioFocus() {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let {
am.abandonAudioFocusRequest(it)
Log.d(tag, "audio focus abandoned")
}
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
am.abandonAudioFocus(audioFocusListener)
audioFocusRequest?.let {
am.abandonAudioFocusRequest(it)
Log.d(tag, "audio focus abandoned")
}
audioFocusRequest = null
}
private fun cleanupPlayer() {