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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ kotlin {
android {
namespace = "org.randomcoder.udroid"
compileSdk = 36
ndkVersion = "28.2.13676358"
testBuildType = "probe"

defaultConfig {
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,22 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />

<application
android:name=".UdroidApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/Theme.Udroid"
tools:replace="android:icon,android:roundIcon,android:theme">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package org.randomcoder.udroid.runtime

import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.Settings
import androidx.core.content.ContextCompat
import java.io.File

data class AndroidStorageVolume(
val label: String,
val hostPath: String,
val state: String,
val primary: Boolean,
val removable: Boolean,
) {
val mounted: Boolean
get() = state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY

val guestTarget: String
get() = AndroidStorageMounts.guestTarget(primary, hostPath)
}

object AndroidStorageMounts {
fun discover(context: Context): List<AndroidStorageVolume> {
val manager = context.getSystemService(StorageManager::class.java)
val volumes = manager.storageVolumes
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return volumes.filter { it.isPrimary }.mapNotNull { volume ->
volume.directory?.let { directory ->
AndroidStorageVolume(
label = volume.getDescription(context),
hostPath = directory.absolutePath,
state = volume.state,
primary = volume.isPrimary,
removable = volume.isRemovable,
)
}
}
}

val marker = "/Android/data/${context.packageName}/files"
return context.getExternalFilesDirs(null).mapIndexedNotNull { index, appDirectory ->
val path = appDirectory?.absolutePath ?: return@mapIndexedNotNull null
val root = path.substringBefore(marker).takeIf { it != path } ?: return@mapIndexedNotNull null
val volume =
volumes.firstOrNull { candidate ->
if (index == 0) {
candidate.isPrimary
} else {
!candidate.isPrimary &&
candidate.uuid?.equals(File(root).name, ignoreCase = true) == true
}
}
AndroidStorageVolume(
label = volume?.getDescription(context) ?: if (index == 0) "Internal shared storage" else "External storage",
hostPath = root,
state = Environment.getExternalStorageState(appDirectory),
primary = index == 0,
removable = volume?.isRemovable ?: index != 0,
)
}.filter(AndroidStorageVolume::primary)
.distinctBy(AndroidStorageVolume::hostPath)
}

fun hasFullAccess(context: Context): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED
}

fun accessIntent(context: Context): Intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val appSettings = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:${context.packageName}"),
)
appSettings.takeIf { it.resolveActivity(context.packageManager) != null }
?: Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
} else {
Intent()
}

fun legacyPermissions(): Array<String> =
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
)

internal fun guestTarget(
primary: Boolean,
hostPath: String,
): String {
if (primary) return "/mnt/shared"
val volumeId = File(hostPath).name.lowercase().ifBlank { "external" }
return "/mnt/storage/$volumeId"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,18 +104,6 @@ object ProotMountProfileValidator {
requireSafePath(mount.guestTarget, "Guest target")
}

val enabledTargets =
buildList {
PROOT_DEFAULT_MOUNTS
.filter { profile.isDefaultEnabled(it.id) }
.forEach { add(it.guestTarget) }
profile.customMounts.filter(ProotCustomMount::enabled).forEach {
add(it.guestTarget)
}
}
require(enabledTargets.distinct().size == enabledTargets.size) {
"Enabled mappings must use unique guest destinations"
}
return profile
}

Expand Down Expand Up @@ -172,17 +160,6 @@ object ProotMountResolver {
}
addAll(sessionMounts)
}
val duplicateTarget =
resolved
.groupingBy(ResolvedProotMount::guestTarget)
.eachCount()
.entries
.firstOrNull { it.value > 1 }
?.key
require(duplicateTarget == null) {
"$duplicateTarget is already mounted by an active session feature; disable that " +
"feature or choose another Linux path"
}
return resolved
}

Expand Down
14 changes: 12 additions & 2 deletions app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ fun LinuxSystemPage(
RuntimePhase.RUNNING,
RuntimePhase.STOPPING,
)
val runtimeStopping =
snapshot.rootfsName == rootfs.name && snapshot.phase == RuntimePhase.STOPPING
val desktopBlocksMaintenance =
desktop.rootfsName == rootfs.name &&
desktop.phase in
Expand Down Expand Up @@ -476,11 +478,19 @@ fun LinuxSystemPage(
if (runtimeBlocksMaintenance) {
OutlinedButton(
modifier = Modifier.weight(1f),
enabled = !runtimeStopping,
onClick = onStopTerminal,
) {
Icon(Icons.Rounded.Stop, contentDescription = null)
if (runtimeStopping) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Icon(Icons.Rounded.Stop, contentDescription = null)
}
Text(
"Stop terminal",
if (runtimeStopping) "Stopping…" else "Stop terminal",
modifier = Modifier.padding(start = 6.dp),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,17 @@ fun ProotMountProfileDialog(
}
}

AndroidStorageMountsCard(
enabled = true,
mounts = draft.customMounts,
onAdd = { mount ->
draft = draft.copy(customMounts = draft.customMounts + mount)
validationMessage =
"${mount.hostSource} will be available at ${mount.guestTarget}"
},
onMessage = { validationMessage = it },
)

Column {
Text(
"Session mounts",
Expand Down
Loading
Loading