Files

494 lines
20 KiB
Kotlin

@file:OptIn(ExperimentalMaterial3Api::class)
package com.tutpro.baresip
import android.Manifest.permission.BLUETOOTH_CONNECT
import android.Manifest.permission.POST_NOTIFICATIONS
import android.Manifest.permission.READ_EXTERNAL_STORAGE
import android.Manifest.permission.RECORD_AUDIO
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.annotation.SuppressLint
import android.app.KeyguardManager
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_CALL
import android.content.Intent.ACTION_DIAL
import android.content.Intent.ACTION_VIEW
import android.content.IntentFilter
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.provider.CallLog
import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import androidx.core.content.ContextCompat
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
private lateinit var nm: NotificationManager
private lateinit var am: AudioManager
private lateinit var kgm: KeyguardManager
private lateinit var screenEventReceiver: BroadcastReceiver
private lateinit var serviceEventObserver: Observer<Event<Long>>
private lateinit var requestPermissionLauncher: ActivityResultLauncher<String>
private lateinit var requestPermissionsLauncher: ActivityResultLauncher<Array<String>>
private lateinit var comDevChangedListener: AudioManager.OnCommunicationDeviceChangedListener
private lateinit var baresipService: Intent
private var restart = false
private var atStartup = false
private var initialized = false
private val viewModel: ViewModel by viewModels()
private lateinit var navController: NavHostController
@SuppressLint("ClickableViewAccessibility")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val extraAction = intent.getStringExtra("action")
Log.i(TAG, "Main onCreate ${intent.action}/${intent.data}/$extraAction")
window.addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES)
BaresipService.darkTheme.value = Utils.isThemeDark(this)
// Must be done after view has been created
this.setShowWhenLocked(true)
this.setTurnScreenOn( true)
Utils.requestDismissKeyguard(this)
nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
am = getSystemService(AUDIO_SERVICE) as AudioManager
kgm = getSystemService(KEYGUARD_SERVICE) as KeyguardManager
serviceEventObserver = Observer {
val event = it.getContentIfNotHandled()
Log.d(TAG, "Observed event $event")
if (event != null && BaresipService.serviceEvents.isNotEmpty()) {
val first = BaresipService.serviceEvents.removeAt(0)
if (taskId != -1) {
if (first.event == "started" && !initialized)
// Android has restarted baresip when permission has been denied in app settings
recreate()
else {
if (first.event == "stopped") {
Log.d(TAG, "Handling service event 'stopped' with start error '${first.params[0]}'")
if (first.params[0] != "")
handleDialog(
ctx = applicationContext,
title = getString(R.string.notice),
message =getString(R.string.start_failed)
)
else {
finishAndRemoveTask()
if (restart)
reStart()
else
exitProcess(0)
}
}
else
handleServiceEvent(this, viewModel, first.event, first.params)
}
}
else
Log.d(TAG, "Omit service event '$event' for task -1")
}
}
BaresipService.serviceEvent.observeForever(serviceEventObserver)
screenEventReceiver = object : BroadcastReceiver() {
override fun onReceive(contxt: Context, intent: Intent) {
if (kgm.isKeyguardLocked) {
Log.d(TAG, "Screen on when locked")
this@MainActivity.setShowWhenLocked(Call.inCall())
}
}
}
this.registerReceiver(screenEventReceiver, IntentFilter().apply { addAction(Intent.ACTION_SCREEN_ON) })
if (Build.VERSION.SDK_INT >= 31) {
comDevChangedListener = AudioManager.OnCommunicationDeviceChangedListener { device ->
if (device != null)
Log.d(TAG, "Com device changed to type ${device.type} in mode ${am.mode}")
}
am.addOnCommunicationDeviceChangedListener(mainExecutor, comDevChangedListener)
}
initialized = true
val restartApp = {
Log.i(TAG, "Restarting baresip")
window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
if (BaresipService.isServiceRunning) {
restart = true
baresipService.action = "Stop"
ContextCompat.startForegroundService(this, baresipService)
}
else {
finishAndRemoveTask()
val pm = applicationContext.packageManager
val intent = pm.getLaunchIntentForPackage(applicationContext.packageName)
if (intent != null) {
applicationContext.startActivity(intent)
exitProcess(0)
}
else
Log.e(TAG, "Failed to restart: Launch intent is null")
}
}
val quitApp = {
Log.i(TAG, "Quiting baresip")
window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
if (BaresipService.isServiceRunning) {
restart = false
baresipService.action = "Stop"
ContextCompat.startForegroundService(this, baresipService)
}
else {
finishAndRemoveTask()
exitProcess(0)
}
}
baresipService = Intent(this@MainActivity, BaresipService::class.java)
atStartup = intent.hasExtra("onStartup")
if (intent?.action == Intent.ACTION_VIEW &&
"baresip".equals(intent.data?.scheme, ignoreCase = true)
) {
handleProvisioningIntent(this, viewModel, intent)
} else {
when (intent?.action) {
ACTION_DIAL, ACTION_CALL, ACTION_VIEW ->
if (BaresipService.isServiceRunning)
callAction(
applicationContext,
viewModel,
intent.data,
if (intent?.action == ACTION_CALL) "call" else "dial"
)
else
BaresipService.callActionUri = intent.data
.toString().replace("%2B", "+")
.replace("%20", "")
.filterNot{setOf('-', ' ', '(', ')').contains(it)}
}
}
val permissions = if (Build.VERSION.SDK_INT >= 33)
arrayOf(POST_NOTIFICATIONS, RECORD_AUDIO, BLUETOOTH_CONNECT)
else if (Build.VERSION.SDK_INT >= 31)
arrayOf(RECORD_AUDIO, BLUETOOTH_CONNECT)
else if (Build.VERSION.SDK_INT < 29)
arrayOf(RECORD_AUDIO, READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE)
else
arrayOf(RECORD_AUDIO)
requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
Log.i(TAG, "Permission granted: $isGranted")
}
requestPermissionsLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
val denied = mutableListOf<String>()
val shouldShow = mutableListOf<String>()
it.forEach { permission ->
if (!permission.value) {
denied.add(permission.key)
if (shouldShowRequestPermissionRationale(permission.key))
shouldShow.add(permission.key)
}
}
if (denied.contains(POST_NOTIFICATIONS) && !shouldShow.contains(POST_NOTIFICATIONS))
handleDialog(
ctx = applicationContext,
title = getString(R.string.notice),
message = getString(R.string.no_notifications),
action = { quitRestart(false) }
)
else if (shouldShow.isNotEmpty())
handleDialog(
ctx = applicationContext,
title = getString(R.string.permissions_rationale),
message = getString(R.string.audio_permissions),
action = { requestPermissionsLauncher.launch(permissions) }
)
else if (!BaresipService.isStartReceived) {
baresipService.action = "Start"
ContextCompat.startForegroundService(this, baresipService)
if (atStartup) moveTaskToBack(true)
}
}
setContent {
AppTheme {
navController = rememberNavController()
LaunchedEffect(key1 = viewModel) {
viewModel.navigationCommand.collect { command ->
Log.d(TAG, "MainActivity: Received NavigationCommand: $command")
when (command) {
is NavigationCommand.NavigateToChat -> {
val route = "chat/${command.aor}/${command.peerUri}"
navController.navigate(route) { launchSingleTop = true }
}
is NavigationCommand.NavigateToCalls -> {
val route = "calls/${command.aor}"
navController.navigate(route) { launchSingleTop = true }
}
is NavigationCommand.NavigateToChats ->
navController.navigate("chats") { launchSingleTop = true }
is NavigationCommand.NavigateToHome ->
navController.navigate("main") {
popUpTo("main") { inclusive = true }
}
}
}
}
NavHost(navController, startDestination = "main") {
mainScreenRoute(
navController = navController,
viewModel = viewModel,
onRequestPermissions = { requestPermissionsLauncher.launch(permissions) },
onRestartApp = { restartApp() },
onQuitApp = { quitApp() }
)
aboutScreenRoute(navController)
settingsScreenRoute(
navController = navController,
onRestartApp = { restartApp() }
)
accountsScreenRoute(navController)
audioScreenRoute(navController)
accountScreenRoute(navController)
codecsScreenRoute(navController)
contactsScreenRoute(navController)
contactScreenRoute(navController, viewModel)
callsScreenRoute(navController, viewModel)
callDetailsScreenRoute(navController, viewModel)
blockedScreenRoute(navController)
blockingScreenRoute(navController)
chatsScreenRoute(navController)
chatScreenRoute(navController, viewModel)
}
}
}
} // OnCreate
override fun onStart() {
super.onStart()
Log.i(TAG, "Main onStart action/type/data: ${intent.action}/${intent.type}/${intent.data}")
val action = intent.getStringExtra("action")
if (action != null) {
// MainActivity was not visible when call, message, or transfer request came in
intent.removeExtra("action")
handleIntent(applicationContext, viewModel, intent, action)
}
else if (isCallLogIntent(intent)) {
handleCallLogIntent()
}
else if (intent.action == Intent.ACTION_MAIN && !atStartup) {
val activeNotifications = nm.activeNotifications
if (activeNotifications.any { it.id == CALL_MISSED_NOTIFICATION_ID }) {
val ua = BaresipService.uas.value.find { it.account.missedCalls }
if (ua != null)
viewModel.navigateToCalls(ua.account.aor)
}
else if (activeNotifications.any { it.id == MESSAGE_NOTIFICATION_ID }) {
val lastUnread = BaresipService.messages.lastOrNull { it.new }
if (lastUnread != null)
viewModel.onNewMessageReceived(lastUnread.aor, lastUnread.peerUri)
else
viewModel.navigateToChats()
}
}
}
override fun onResume() {
super.onResume()
Log.d(TAG, "Main onResume")
nm.cancelAll()
if (Build.VERSION.SDK_INT >= 29) {
val baresipService = Intent(this, BaresipService::class.java)
baresipService.action = "Check Roles"
startService(baresipService)
}
}
override fun onPause() {
super.onPause()
Log.d(TAG, "Main onPause")
}
override fun onDestroy() {
Log.d(TAG, "Main onDestroy")
this.unregisterReceiver(screenEventReceiver)
if (Build.VERSION.SDK_INT >= 31)
am.removeOnCommunicationDeviceChangedListener(comDevChangedListener)
BaresipService.serviceEvent.removeObserver(serviceEventObserver)
BaresipService.serviceEvents.clear()
super.onDestroy()
}
override fun onNewIntent(intent: Intent) {
// Called when MainActivity already exists at the top of current task
super.onNewIntent(intent)
setIntent(intent)
this.setShowWhenLocked(true)
this.setTurnScreenOn(true)
Log.d(TAG, "onNewIntent action/type/data: ${intent.action}/${intent.type}/${intent.data}")
when {
isCallLogIntent(intent) -> {
handleCallLogIntent()
}
intent.action == Intent.ACTION_VIEW &&
intent.data?.scheme.equals("baresip", ignoreCase = true) -> {
handleProvisioningIntent(applicationContext, viewModel, intent)
}
intent.action in listOf(ACTION_DIAL, ACTION_CALL, ACTION_VIEW) -> {
callAction(
applicationContext,
viewModel,
intent.data,
if (intent.action == ACTION_CALL) "call" else "dial"
)
}
else -> {
val action = intent.getStringExtra("action")
if (action != null) {
intent.removeExtra("action")
handleIntent(applicationContext, viewModel, intent, action)
}
}
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val stream = if (am.mode == AudioManager.MODE_RINGTONE)
AudioManager.STREAM_RING
else
AudioManager.STREAM_VOICE_CALL
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP -> {
am.adjustStreamVolume(stream,
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)
AudioManager.ADJUST_LOWER else
AudioManager.ADJUST_RAISE,
AudioManager.FLAG_SHOW_UI)
Log.d(TAG, "Adjusted volume $keyCode of stream $stream to ${am.getStreamVolume(stream)}")
return true
}
}
return super.onKeyDown(keyCode, event)
}
private fun isCallLogIntent(intent: Intent?): Boolean {
return intent?.action == ACTION_VIEW && (intent.type == CallLog.Calls.CONTENT_TYPE ||
intent.data?.toString()?.contains("calls") == true)
}
private fun handleCallLogIntent() {
Log.d(TAG, "Handling Call Log intent")
val ua = BaresipService.uas.value.find { it.account.isMobile }
?: BaresipService.uas.value.firstOrNull()
if (ua != null)
viewModel.navigateToCalls(ua.account.aor)
}
private fun quitRestart(reStart: Boolean) {
Log.i(TAG, "quitRestart Restart = $reStart")
window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
if (BaresipService.isServiceRunning) {
restart = reStart
baresipService.action = "Stop"
ContextCompat.startForegroundService(this, baresipService)
}
else {
finishAndRemoveTask()
if (reStart)
quitRestart(true)
else
exitProcess(0)
}
}
private fun reStart() {
Log.d(TAG, "Trigger restart")
val pm = applicationContext.packageManager
val intent = pm.getLaunchIntentForPackage(this.packageName)
this.startActivity(intent)
exitProcess(0)
}
private fun handleProvisioningIntent(ctx: Context, viewModel: ViewModel, intent: Intent) {
val data = intent.data ?: run {
Log.w(TAG, "Provisioning intent has no data")
return
}
val endpoint = data.getQueryParameter("endpoint") ?: return
val extension = data.getQueryParameter("extension") ?: return
Log.i(TAG, "Provisioning from $endpoint ext=$extension")
lifecycleScope.launch {
try {
val payload = Provisioning.fetchAndDecryptBundle(ctx, endpoint, extension)
val acc = Provisioning.applyBundle(ctx, payload)
if (acc == null) throw Provisioning.ProvisioningException("Failed to apply bundle")
Log.i(TAG, "Provisioning applied: ${acc.aor}")
viewModel.navigateToHome()
if (BaresipService.isServiceRunning) {
val restart = Intent(ctx, BaresipService::class.java)
restart.action = "Stop"
ContextCompat.startForegroundService(ctx, restart)
} else {
val restartApp = Intent(ctx, MainActivity::class.java)
restartApp.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
ctx.startActivity(restartApp)
exitProcess(0)
}
} catch (e: Provisioning.ProvisioningException) {
Log.e(TAG, "Provisioning failed: ${e.message}")
}
}
}
}