Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Expand Down Expand Up @@ -98,7 +98,11 @@
<service
android:name=".transcribe.AudioMonitorService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Monitors user-selected folders for voice messages to enable offline transcription. User-initiated, runs only while enabled in settings."/>
</service>

<!-- Speech Recognition Service for system voice input integration -->
<service
Expand Down
31 changes: 5 additions & 26 deletions app/src/main/java/com/translander/TranslanderApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,12 @@ package com.translander
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.translander.settings.SettingsActivity
import com.translander.asr.DictionaryManager
import com.translander.asr.ModelManager
import com.translander.asr.RecognizerManager
import com.translander.notification.ServiceAlertNotification
import com.translander.settings.SettingsRepository
import com.translander.transcribe.TranscribeManager
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -39,6 +36,10 @@ class TranslanderApp : Application() {
lateinit var transcribeManager: TranscribeManager
private set

val serviceAlertNotification: ServiceAlertNotification by lazy {
ServiceAlertNotification(this)
}

override fun onCreate() {
super.onCreate()
instance = this
Expand Down Expand Up @@ -91,28 +92,6 @@ class TranslanderApp : Application() {
}
}

fun showServiceStartNotification(messageResId: Int) {
val intent = Intent(this, SettingsActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

val notification = NotificationCompat.Builder(this, SERVICE_ALERT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_mic)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(messageResId))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()

val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(SERVICE_ALERT_NOTIFICATION_ID, notification)
}

companion object {
private const val TAG = "TranslanderApp"
const val NOTIFICATION_CHANNEL_ID = "voice_transcribe_service"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.translander.notification

import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.translander.R
import com.translander.TranslanderApp
import com.translander.settings.SettingsActivity

/**
* Handles the service alert notification shown when FGS cannot start at boot.
* On Android 14+, restricted FGS types (microphone, dataSync) cannot start from
* BOOT_COMPLETED, so we show a notification prompting the user to open the app.
*/
class ServiceAlertNotification(private val context: Context) {

/**
* Show a notification prompting the user to tap and start services.
* Uses an ongoing notification that the user must tap to dismiss.
*
* @param messageResId String resource ID for the notification message
*/
fun show(messageResId: Int) {
val intent = Intent(context, SettingsActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

val notification = NotificationCompat.Builder(context, TranslanderApp.SERVICE_ALERT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_mic)
.setContentTitle(context.getString(R.string.app_name))
.setContentText(context.getString(messageResId))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setAutoCancel(false)
.build()

val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(TranslanderApp.SERVICE_ALERT_NOTIFICATION_ID, notification)
}

/**
* Dismiss the service alert notification.
* Called after services have been successfully started.
*/
fun dismiss() {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(TranslanderApp.SERVICE_ALERT_NOTIFICATION_ID)
}
}
54 changes: 33 additions & 21 deletions app/src/main/java/com/translander/receiver/BootReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package com.translander.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.util.Log
import com.translander.R
import com.translander.TranslanderApp
import com.translander.service.FloatingMicService
import com.translander.transcribe.AudioMonitorService
import com.translander.util.BootCompatHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
Expand All @@ -21,35 +23,45 @@ class BootReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
Log.i(TAG, "Boot completed, checking if service should start")
Log.i(TAG, "Boot completed, checking if services should start")

// Check if service was enabled and we have overlay permission
val hasOverlayPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Settings.canDrawOverlays(context)
} else true

if (!hasOverlayPermission) {
Log.i(TAG, "No overlay permission, not starting service")
return
}

// Use goAsync() to extend receiver lifetime beyond 10s limit
val pendingResult = goAsync()

CoroutineScope(Dispatchers.IO).launch {
try {
val wasEnabled = TranslanderApp.instance.settingsRepository.serviceEnabled.first()
val app = TranslanderApp.instance
val floatingMicEnabled = app.settingsRepository.serviceEnabled.first()
val audioMonitorEnabled = app.settingsRepository.audioMonitorEnabled.first()

// Check overlay permission for floating mic
val hasOverlayPermission = Settings.canDrawOverlays(context)

val canStartFloatingMic = floatingMicEnabled && hasOverlayPermission
val canStartAudioMonitor = audioMonitorEnabled

if (wasEnabled) {
Log.i(TAG, "Service was enabled, starting FloatingMicService")
val serviceIntent = Intent(context, FloatingMicService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Log.i(TAG, "floatingMicEnabled=$floatingMicEnabled, audioMonitorEnabled=$audioMonitorEnabled, hasOverlay=$hasOverlayPermission")

if (!canStartFloatingMic && !canStartAudioMonitor) {
Log.i(TAG, "No services enabled, nothing to start")
return@launch
}

if (BootCompatHelper.canStartFgsFromBoot) {
// Pre-Android 14: can start FGS directly
if (canStartFloatingMic) {
Log.i(TAG, "Starting FloatingMicService")
val serviceIntent = Intent(context, FloatingMicService::class.java)
context.startForegroundService(serviceIntent)
}
if (canStartAudioMonitor) {
Log.i(TAG, "Starting AudioMonitorService")
val serviceIntent = Intent(context, AudioMonitorService::class.java)
context.startForegroundService(serviceIntent)
} else {
context.startService(serviceIntent)
}
} else {
Log.i(TAG, "Service was not enabled, not starting")
// Android 14+: show single unified notification
Log.i(TAG, "Android 14+: showing notification instead of starting services")
app.serviceAlertNotification.show(R.string.service_start_services)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to check service enabled state", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ class FloatingMicService : Service() {
}

private fun showFailureNotification() {
TranslanderApp.instance.showServiceStartNotification(R.string.service_start_floating_mic)
TranslanderApp.instance.serviceAlertNotification.show(R.string.service_start_floating_mic)
}

private fun createNotification(): Notification {
Expand Down
11 changes: 10 additions & 1 deletion app/src/main/java/com/translander/settings/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class SettingsActivity : ComponentActivity() {
// Revalidate model files on disk
TranslanderApp.instance.modelManager.checkModelStatus()

// Sync floating service state
// Sync floating service state and restart services if needed (Android 14+ boot workaround)
val app = TranslanderApp.instance
app.applicationScope.launch {
val serviceEnabled = app.settingsRepository.serviceEnabled.first()
Expand All @@ -186,6 +186,15 @@ class SettingsActivity : ComponentActivity() {
startFloatingService()
}
}

// Restart audio monitor if enabled (needed on Android 14+ where boot start is blocked)
val audioMonitorEnabled = app.settingsRepository.audioMonitorEnabled.first()
if (audioMonitorEnabled) {
app.transcribeManager.setAudioMonitorEnabled(true)
}

// Dismiss the boot notification now that services are started
app.serviceAlertNotification.dismiss()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Environment
import android.os.FileObserver
Expand Down Expand Up @@ -63,7 +64,13 @@ class AudioMonitorService : Service() {
}

try {
startForeground(SERVICE_NOTIFICATION_ID, createServiceNotification())
val notification = createServiceNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(SERVICE_NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(SERVICE_NOTIFICATION_ID, notification)
}
// Dismiss any failure notification from previous boot attempt
getSystemService(android.app.NotificationManager::class.java)
?.cancel(TranslanderApp.SERVICE_ALERT_NOTIFICATION_ID)
Expand Down Expand Up @@ -127,7 +134,7 @@ class AudioMonitorService : Service() {
}

private fun showFailureNotification() {
TranslanderApp.instance.showServiceStartNotification(R.string.service_start_folder_monitor)
TranslanderApp.instance.serviceAlertNotification.show(R.string.service_start_folder_monitor)
}

private fun createServiceNotification(): Notification {
Expand Down
20 changes: 13 additions & 7 deletions app/src/main/java/com/translander/transcribe/TranscribeManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package com.translander.transcribe

import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import com.translander.TranslanderApp
import com.translander.util.BootCompatHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -48,11 +48,7 @@ class TranscribeManager(private val context: Context) {
if (active) return
try {
val intent = Intent(context, AudioMonitorService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
context.startForegroundService(intent)
active = true
} catch (e: Exception) {
Log.e(TAG, "Failed to start AudioMonitorService", e)
Expand Down Expand Up @@ -88,14 +84,24 @@ class TranscribeManager(private val context: Context) {

/**
* Start all enabled triggers based on settings.
* On Android 14+, skips auto-start at boot since dataSync FGS is blocked.
* BootReceiver handles showing the notification, so we just skip here.
* User must open app to start services (handled in SettingsActivity.onResume).
*/
fun startEnabledTriggers() {
scope.launch {
val settings = TranslanderApp.instance.settingsRepository
val monitorEnabled = settings.audioMonitorEnabled.first()

if (monitorEnabled) {
audioMonitorTrigger.start()
if (BootCompatHelper.canStartFgsFromBoot) {
audioMonitorTrigger.start()
} else {
// Android 14+: BootReceiver shows the notification,
// so we just skip starting here. Services will be started
// when user opens the app (SettingsActivity.onResume).
Log.i(TAG, "Android 14+: skipping audio monitor start at boot")
}
}
}
}
Expand Down
47 changes: 47 additions & 0 deletions app/src/main/java/com/translander/util/BootCompatHelper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.translander.util

import android.os.Build

/**
* Helper for Android 14+ boot compatibility.
* Centralizes API level checks for foreground service (FGS) restrictions.
*
* ## Android Version Restrictions Matrix
*
* | Android | API | FGS Restrictions at Boot |
* |---------|-----|--------------------------|
* | 8.0-13 | 26-33 | None - FGS starts directly from BOOT_COMPLETED |
* | 14 | 34 | `microphone` FGS type blocked from boot/background |
* | 15 | 35 | Same as 14, plus `dataSync` has 6-hour timeout |
* | 16 | 36 | Same restrictions as Android 15 |
*
* ## Why Check for API 34?
*
* Android 14 (API 34, UPSIDE_DOWN_CAKE) was the first version to restrict
* starting certain FGS types from background contexts like BOOT_COMPLETED.
* This restriction applies to:
* - `microphone` (used by FloatingMicService)
* - `camera`
* - `mediaProjection`
*
* Android 15 (API 35) added the 6-hour timeout for `dataSync` FGS type,
* which is why AudioMonitorService uses `specialUse` instead.
*
* The check for API < 34 covers all Android versions from 14 onwards,
* including 15 and 16.
*
* @see <a href="https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start">FGS Background Start Restrictions</a>
* @see <a href="https://developer.android.com/about/versions/15/changes/foreground-service-types">Android 15 FGS Changes</a>
*/
object BootCompatHelper {
/**
* Whether the app can start foreground services with restricted types
* (microphone, dataSync) from BOOT_COMPLETED receiver.
*
* Returns `true` for Android 8.0-13 (API 26-33) where FGS can start directly.
* Returns `false` for Android 14+ (API 34+) where a notification must be
* shown and the user must tap to start services.
*/
val canStartFgsFromBoot: Boolean
get() = Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE
}
1 change: 1 addition & 0 deletions app/src/main/res/values-bg/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<string name="service_alert_channel_description">Известия при неуспешно стартиране на услуги</string>
<string name="service_start_floating_mic">Натиснете, за да стартирате плаващия микрофон</string>
<string name="service_start_folder_monitor">Натиснете, за да стартирате наблюдението на папки</string>
<string name="service_start_services">Докоснете, за да стартирате услугите на TranSlander</string>
<string name="accessibility_service_label">TranSlander Текстов вход</string>
<string name="accessibility_service_description">Позволява на TranSlander да вмъква транскрибиран текст директно в текстови полета. Активирайте за безпроблемно гласово въвеждане на текст.</string>

Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-cs/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<string name="service_alert_channel_description">Oznámení při selhání spuštění služeb</string>
<string name="service_start_floating_mic">Klepněte pro spuštění plovoucího mikrofonu</string>
<string name="service_start_folder_monitor">Klepněte pro spuštění monitoru složek</string>
<string name="service_start_services">Klepnutím spustíte služby TranSlander</string>
<string name="accessibility_service_label">TranSlander Textový vstup</string>
<string name="accessibility_service_description">Umožňuje TranSlander vkládat přepsaný text přímo do textových polí. Povolte pro plynulý hlasový vstup.</string>

Expand Down
Loading