add notifcaiton support with unmount button
This commit is contained in:
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
tools:ignore="ScopedStorage" />
|
tools:ignore="ScopedStorage" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
|
||||||
|
<!-- Notification permission for Android 13+ -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- Query for root shell packages -->
|
<!-- Query for root shell packages -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
@@ -44,6 +47,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Broadcast receiver for unmount action from notification -->
|
||||||
|
<receiver
|
||||||
|
android:name=".notification.UnmountReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { /* Notification permission result - we continue regardless */ }
|
||||||
|
|
||||||
private val manageStorageLauncher = registerForActivityResult(
|
private val manageStorageLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.StartActivityForResult()
|
ActivityResultContracts.StartActivityForResult()
|
||||||
) {
|
) {
|
||||||
@@ -78,6 +82,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAndRequestPermissions() {
|
private fun checkAndRequestPermissions() {
|
||||||
|
// Request notification permission on Android 13+
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request storage permission
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
if (!Environment.isExternalStorageManager()) {
|
if (!Environment.isExternalStorageManager()) {
|
||||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
|
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
|
||||||
|
|||||||
22
app/src/main/java/sh/sar/isodroid/isodrive/MountEventBus.kt
Normal file
22
app/src/main/java/sh/sar/isodroid/isodrive/MountEventBus.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package sh.sar.isodroid.isodrive
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
|
||||||
|
object MountEventBus {
|
||||||
|
private val _events = MutableSharedFlow<MountEvent>(extraBufferCapacity = 1)
|
||||||
|
val events = _events.asSharedFlow()
|
||||||
|
|
||||||
|
fun emitUnmounted() {
|
||||||
|
_events.tryEmit(MountEvent.Unmounted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitMounted() {
|
||||||
|
_events.tryEmit(MountEvent.Mounted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class MountEvent {
|
||||||
|
data object Mounted : MountEvent()
|
||||||
|
data object Unmounted : MountEvent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package sh.sar.isodroid.notification
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import sh.sar.isodroid.MainActivity
|
||||||
|
import sh.sar.isodroid.R
|
||||||
|
import sh.sar.isodroid.data.MountStatus
|
||||||
|
import sh.sar.isodroid.data.MountType
|
||||||
|
|
||||||
|
class NotificationHelper(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "iso_drive_mount_status"
|
||||||
|
const val NOTIFICATION_ID = 1001
|
||||||
|
const val ACTION_UNMOUNT = "sh.sar.isodroid.ACTION_UNMOUNT"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: NotificationHelper? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): NotificationHelper {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: NotificationHelper(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
init {
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Mount Status",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Shows when an ISO/IMG file is mounted"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMountedNotification(mountStatus: MountStatus) {
|
||||||
|
if (!mountStatus.mounted || mountStatus.path == null) {
|
||||||
|
hideNotification()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileName = mountStatus.path.substringAfterLast("/")
|
||||||
|
val mountType = when (mountStatus.type) {
|
||||||
|
MountType.CDROM -> "CD-ROM"
|
||||||
|
MountType.MASS_STORAGE -> "Mass Storage"
|
||||||
|
else -> "USB"
|
||||||
|
}
|
||||||
|
val accessMode = if (mountStatus.readOnly) "Read-Only" else "Read-Write"
|
||||||
|
|
||||||
|
// Intent to open the app when notification is tapped
|
||||||
|
val openIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
val openPendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
openIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Intent for unmount action
|
||||||
|
val unmountIntent = Intent(context, UnmountReceiver::class.java).apply {
|
||||||
|
action = ACTION_UNMOUNT
|
||||||
|
}
|
||||||
|
val unmountPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
1,
|
||||||
|
unmountIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("ISO Mounted")
|
||||||
|
.setContentText(fileName)
|
||||||
|
.setSubText("$mountType - $accessMode")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setContentIntent(openPendingIntent)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_eject,
|
||||||
|
"Unmount",
|
||||||
|
unmountPendingIntent
|
||||||
|
)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideNotification() {
|
||||||
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package sh.sar.isodroid.notification
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import sh.sar.isodroid.isodrive.IsoDriveManager
|
||||||
|
import sh.sar.isodroid.isodrive.MountEventBus
|
||||||
|
|
||||||
|
class UnmountReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == NotificationHelper.ACTION_UNMOUNT) {
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val isoDriveManager = IsoDriveManager.getInstance(context)
|
||||||
|
val result = isoDriveManager.unmount()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
if (result.success) {
|
||||||
|
Toast.makeText(context, "Unmounted successfully", Toast.LENGTH_SHORT).show()
|
||||||
|
NotificationHelper.getInstance(context).hideNotification()
|
||||||
|
MountEventBus.emitUnmounted()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Unmount failed: ${result.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,10 @@ import sh.sar.isodroid.data.IsoFile
|
|||||||
import sh.sar.isodroid.data.MountOptions
|
import sh.sar.isodroid.data.MountOptions
|
||||||
import sh.sar.isodroid.data.MountStatus
|
import sh.sar.isodroid.data.MountStatus
|
||||||
import sh.sar.isodroid.isodrive.IsoDriveManager
|
import sh.sar.isodroid.isodrive.IsoDriveManager
|
||||||
|
import sh.sar.isodroid.isodrive.MountEvent
|
||||||
|
import sh.sar.isodroid.isodrive.MountEventBus
|
||||||
import sh.sar.isodroid.isodrive.SupportStatus
|
import sh.sar.isodroid.isodrive.SupportStatus
|
||||||
|
import sh.sar.isodroid.notification.NotificationHelper
|
||||||
import sh.sar.isodroid.root.RootManager
|
import sh.sar.isodroid.root.RootManager
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -29,6 +32,7 @@ private val Application.dataStore: DataStore<Preferences> by preferencesDataStor
|
|||||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val isoDriveManager = IsoDriveManager.getInstance(application)
|
private val isoDriveManager = IsoDriveManager.getInstance(application)
|
||||||
|
private val notificationHelper = NotificationHelper.getInstance(application)
|
||||||
private val dataStore = application.dataStore
|
private val dataStore = application.dataStore
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(MainUiState())
|
private val _uiState = MutableStateFlow(MainUiState())
|
||||||
@@ -45,6 +49,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
initialize()
|
initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Observe mount events from notification actions
|
||||||
|
viewModelScope.launch {
|
||||||
|
MountEventBus.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is MountEvent.Unmounted -> {
|
||||||
|
checkMountStatus()
|
||||||
|
_uiState.update { it.copy(successMessage = "Unmounted successfully") }
|
||||||
|
}
|
||||||
|
is MountEvent.Mounted -> {
|
||||||
|
checkMountStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun initialize() {
|
private suspend fun initialize() {
|
||||||
@@ -178,6 +197,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private suspend fun checkMountStatus() {
|
private suspend fun checkMountStatus() {
|
||||||
val status = isoDriveManager.getStatus()
|
val status = isoDriveManager.getStatus()
|
||||||
_uiState.update { it.copy(mountStatus = status) }
|
_uiState.update { it.copy(mountStatus = status) }
|
||||||
|
updateNotification(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(status: MountStatus) {
|
||||||
|
if (status.mounted) {
|
||||||
|
notificationHelper.showMountedNotification(status)
|
||||||
|
} else {
|
||||||
|
notificationHelper.hideNotification()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun mount(path: String, options: MountOptions) {
|
suspend fun mount(path: String, options: MountOptions) {
|
||||||
|
|||||||
10
app/src/main/res/drawable/ic_eject.xml
Normal file
10
app/src/main/res/drawable/ic_eject.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M5,17h14v2H5zM12,5L5.33,15h13.34z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_notification.xml
Normal file
10
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,16.5c-2.49,0 -4.5,-2.01 -4.5,-4.5S9.51,7.5 12,7.5s4.5,2.01 4.5,4.5 -2.01,4.5 -4.5,4.5zM12,9.5c-1.38,0 -2.5,1.12 -2.5,2.5s1.12,2.5 2.5,2.5 2.5,-1.12 2.5,-2.5 -1.12,-2.5 -2.5,-2.5z"/>
|
||||||
|
</vector>
|
||||||
Reference in New Issue
Block a user