add notifcaiton support with unmount button

This commit is contained in:
2026-03-10 01:01:20 +05:00
parent 0c5524a9a5
commit 93c239eb12
9 changed files with 259 additions and 0 deletions

13
.idea/deviceManager.xml generated Normal file
View 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>

View File

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

View File

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

View 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()
}

View File

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

View File

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

View File

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

View 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>

View 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>