diff --git a/app/build.gradle b/app/build.gradle index e13bd62..e15ebb4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -84,6 +84,7 @@ dependencies { implementation libs.androidx.compose.ui.graphics implementation libs.androidx.compose.ui.tooling.preview implementation libs.androidx.compose.material3 + implementation libs.androidx.documentfile testImplementation libs.junit androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.espresso.core diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0880aff..0c8cc66 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,7 @@ - + - + @@ -33,6 +30,10 @@ + + { return try { File(rootfs, "env").readLines() @@ -153,41 +165,68 @@ class BuildEnvironment( return exitCode } - private fun setupProject(projectPath: String, gradleBuildDir: String): File { - val fullPath = File(projectPath, gradleBuildDir) - val hash = Integer.toHexString(fullPath.absolutePath.hashCode()) - val workDir = File(projectRoot, hash) - - // Clean up assets from a previous export. - if (workDir.exists()) { - val apkAssetsDir = File(workDir, "src/main/assets") - if (apkAssetsDir.exists()) { - apkAssetsDir.deleteRecursively() + private fun setupProject(projectPath: String, gradleBuildDir: String, outputHandler: (Int, String) -> Unit): File { + var projectTreeUri = FileUtils.getProjectTreeUri(context, projectPath) + if (projectTreeUri == null) { + if (checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + outputHandler(OUTPUT_STDERR, "Project path \"$projectPath\" is not accessible. " + + "Click on the build notification and give ${context.getString(R.string.app_launcher_name)} app access to the project directory.") + Utils.showDirectoryAccessNotification(context, projectPath) + } else { + throw SecurityException("POST_NOTIFICATIONS permission not granted. " + + "Please grant POST_NOTIFICATIONS permission for ${context.getString(R.string.app_launcher_name)} app and retry.") } - val aabAssetsDir = File(workDir, "assetPackInstallTime/src/main/assets") - if (aabAssetsDir.exists()) { - aabAssetsDir.deleteRecursively() + projectTreeUri = waitForDirectoryAccess(DIR_ACCESS_WAIT_DURATION) + ?: throw Exception("Directory access not granted in time. Build canceled.") + outputHandler(OUTPUT_INFO, "Access granted for $projectPath. Starting Gradle build...") + FileUtils.saveProjectTreeUri(context, projectPath, projectTreeUri) + + // Notify user if limit is reached so they can clear older projects. + val persistedCount = context.contentResolver.persistedUriPermissions.size + val limit = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) 128 else 512 + if (persistedCount == limit) { + outputHandler(OUTPUT_INFO, "Warning: Persisted directory access limit reached." + + "This build will continue, but new projects would require clearing older ones in ${context.getString(R.string.app_launcher_name)} app") } } - if (!FileUtils.tryCopyDirectory(fullPath, workDir)) { - throw IOException("Failed to copy $fullPath to $workDir") + val workDir = Utils.getProjectCacheDir(context, projectPath, gradleBuildDir) + if (!workDir.exists()) { + workDir.mkdirs() + ProjectInfo.writeToDirectory(context, workDir, projectPath, gradleBuildDir, projectTreeUri) } - ProjectInfo.writeToDirectory(workDir, projectPath, gradleBuildDir) - + outputHandler(OUTPUT_INFO, "> Importing project files...") + FileUtils.importAndroidProject(context, projectTreeUri, gradleBuildDir, workDir) return workDir } - fun cleanProject(projectPath: String, gradleBuildDir: String) { - val fullPath = File(projectPath, gradleBuildDir) - val hash = Integer.toHexString(fullPath.absolutePath.hashCode()) - val workDir = File(projectRoot, hash) + private fun fixGradleArgs(projectPath: String, rawGradleArgs: List): List { + val normalizedProjectPath = projectPath.trimEnd('/') + return rawGradleArgs.map { arg -> + when { + arg.startsWith("-Pdebug_keystore_file=") -> "-Pdebug_keystore_file=/project/.android/debug.keystore" + arg.startsWith("-Prelease_keystore_file=") -> "-Prelease_keystore_file=/project/.android/release.keystore" + arg.startsWith("-Paddons_directory=") -> "-Paddons_directory=/project/${FileUtils.ADDONS_DIR_NAME}" + + arg.startsWith("-Pplugins_local_binaries=") -> { + val prefix = "-Pplugins_local_binaries=" + val value = arg.removePrefix(prefix) + val updated = value.replace("$normalizedProjectPath/${FileUtils.ADDONS_DIR_NAME}", "/project/${FileUtils.ADDONS_DIR_NAME}") + prefix + updated + } + else -> arg + } + } + } + fun cleanProject(projectPath: String, gradleBuildDir: String) { + val workDir = Utils.getProjectCacheDir(context, projectPath, gradleBuildDir) if (workDir.exists()) { workDir.deleteRecursively() } + FileUtils.deleteProjectTreeUri(context, projectPath) } fun cleanGlobalCache() { @@ -348,14 +387,14 @@ class BuildEnvironment( return AppPaths.getRootfsReadyFile(File(rootfs)).exists() } - fun executeGradle(gradleArgs: List, projectPath: String, gradleBuildDir: String, outputHandler: (Int, String) -> Unit): Int { + fun executeGradle(rawGradleArgs: List, projectPath: String, gradleBuildDir: String, outputHandler: (Int, String) -> Unit): Int { if (!isRootfsReady()) { outputHandler(OUTPUT_STDERR, "Rootfs isn't installed. Install it in the Godot Gradle Build Environment app.") return 255 } val workDir = try { - setupProject(projectPath, gradleBuildDir) + setupProject(projectPath, gradleBuildDir, outputHandler) } catch (e: Exception) { outputHandler(OUTPUT_STDERR, "Unable to setup project: ${e.message}") return 255 @@ -371,12 +410,14 @@ class BuildEnvironment( outputHandler(type, line) } + val gradleArgs = fixGradleArgs(projectPath, rawGradleArgs) + var result = executeGradleInternal(gradleArgs, workDir, captureOutputHandler) val stderr = stderrBuilder.toString() if (result == 0 && stderr.contains("BUILD FAILED")) { // Sometimes Gradle builds fail, but it still gives an exit code of 0. - result = 1; + result = 1 } stderrBuilder.clear() @@ -399,7 +440,7 @@ class BuildEnvironment( result = executeGradleInternal(gradleArgs, workDir, captureOutputHandler) val stderr = stderrBuilder.toString() if (result == 0 && stderr.contains("BUILD FAILED")) { - result = 1; + result = 1 } } @@ -417,4 +458,24 @@ class BuildEnvironment( } } -} \ No newline at end of file + fun waitForDirectoryAccess(timeoutMs: Long): Uri? { + accessLock.withLock { + var remainingTimeInNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMs) + while (grantedTreeUri == null && remainingTimeInNanos > 0) { + try { + remainingTimeInNanos = accessLockCondition.awaitNanos(remainingTimeInNanos) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + return grantedTreeUri + } + } + + fun onDirectoryAccessGranted(uri: Uri) { + accessLock.withLock { + grantedTreeUri = uri + accessLockCondition.signalAll() + } + } +} diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironmentService.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironmentService.kt index ef65862..cf46eaa 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironmentService.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironmentService.kt @@ -11,6 +11,7 @@ import android.os.Message import android.os.Messenger import android.os.RemoteException import android.util.Log +import androidx.core.net.toUri import java.util.concurrent.LinkedBlockingQueue class BuildEnvironmentService : Service() { @@ -26,6 +27,7 @@ class BuildEnvironmentService : Service() { const val MSG_CLEAN_GLOBAL_CACHE = 6 const val MSG_INSTALL_ROOTFS = 7 const val MSG_DELETE_ROOTFS = 8 + const val MSG_BUILD_DIR_ACCESS_GRANTED = 9 } private lateinit var mMessenger: Messenger @@ -62,6 +64,12 @@ class BuildEnvironmentService : Service() { MSG_CLEAN_GLOBAL_CACHE -> queueWork(WorkItem(copy, msg.arg1)) MSG_INSTALL_ROOTFS -> queueWork(WorkItem(copy, msg.arg1)) MSG_DELETE_ROOTFS -> queueWork(WorkItem(copy, msg.arg1)) + MSG_BUILD_DIR_ACCESS_GRANTED -> { + val uri = msg.data.getString(Utils.EXTRA_TREE_URI)?.toUri() + if (uri != null) { + mBuildEnvironment.onDirectoryAccessGranted(uri) + } + } } } } @@ -88,7 +96,7 @@ class BuildEnvironmentService : Service() { return } - Log.i(TAG, "Canceling command: ${id}") + Log.i(TAG, "Canceling command: $id") if (currentItem?.id == id && currentItem?.msg?.what == MSG_EXECUTE_GRADLE) { mBuildEnvironment.killCurrentProcess() @@ -131,7 +139,7 @@ class BuildEnvironmentService : Service() { var result = 255 if (args != null && projectPath != null && gradleBuildDir != null) { - result = mBuildEnvironment.executeGradle(args, projectPath, gradleBuildDir, { type, line -> + result = mBuildEnvironment.executeGradle(args, projectPath, gradleBuildDir) { type, line -> val reply = Message.obtain(null, MSG_COMMAND_OUTPUT, id, type) val replyData = Bundle() replyData.putString("line", line) @@ -142,7 +150,7 @@ class BuildEnvironmentService : Service() { } catch (e: RemoteException) { Log.e(TAG, "Error send command output to client: ${e.message}") } - }) + } } val reply = Message.obtain(null, MSG_COMMAND_RESULT, id, result) diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/DirectoryAccessActivity.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/DirectoryAccessActivity.kt new file mode 100644 index 0000000..99bc062 --- /dev/null +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/DirectoryAccessActivity.kt @@ -0,0 +1,82 @@ +package org.godotengine.godot_gradle_build_environment + +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.net.Uri +import android.os.Bundle +import android.os.Environment +import android.os.IBinder +import android.os.Message +import android.os.Messenger +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts + +class DirectoryAccessActivity : ComponentActivity() { + + private var serviceMessenger: Messenger? = null + private var isServiceBound = false + + private val directoryPickerLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> + if (uri != null) { + val msg = Message.obtain(null, BuildEnvironmentService.MSG_BUILD_DIR_ACCESS_GRANTED) + msg.data = Bundle().apply { + putString(Utils.EXTRA_TREE_URI, uri.toString()) + } + serviceMessenger?.send(msg) + } + // Finish and remove from recents screen too + finishAndRemoveTask() + } + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + serviceMessenger = Messenger(binder) + } + + override fun onServiceDisconnected(name: ComponentName?) { + serviceMessenger = null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + if (intent.action == Utils.ACTION_REQUEST_DIRECTORY_ACCESS) { + bindToBuildService() + + val tempProjectPath = intent.getStringExtra(Utils.EXTRA_PROJECT_PATH) + var initialUri: Uri? = null + if (tempProjectPath != null) { + val externalStorageRoot = Environment.getExternalStorageDirectory().absolutePath + if (tempProjectPath.startsWith(externalStorageRoot)) { + val relativePath = tempProjectPath.replaceFirst(externalStorageRoot, "").trim('/') + initialUri = Uri.Builder() + .scheme("content") + .authority("com.android.externalstorage.documents") + .appendPath("document") + .appendPath("primary:$relativePath") + .build() + } + } + directoryPickerLauncher.launch(initialUri) + } + } + + private fun bindToBuildService() { + val intent = Intent(this, BuildEnvironmentService::class.java) + bindService(intent, connection, BIND_AUTO_CREATE) + isServiceBound = true + } + + override fun onDestroy() { + if (isServiceBound) { + unbindService(connection) + } + super.onDestroy() + } +} diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/FileUtils.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/FileUtils.kt index 4badd10..b19a2c5 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/FileUtils.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/FileUtils.kt @@ -1,19 +1,27 @@ package org.godotengine.godot_gradle_build_environment +import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Build import android.util.Log +import androidx.documentfile.provider.DocumentFile import java.io.File -import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.nio.file.Files import kotlin.math.log10 import kotlin.math.min import kotlin.math.pow +import androidx.core.content.edit +import androidx.core.net.toUri object FileUtils { private val TAG = FileUtils::class.java.simpleName + private const val PREF_NAME = "tree_uri_prefs" + + const val ADDONS_DIR_NAME = "addons" fun tryCopyFile(source: File, dest: File): Boolean { try { @@ -25,21 +33,6 @@ object FileUtils { return true } - fun tryCopyDirectory(sourceDir: File, destDir: File): Boolean { - if (!sourceDir.isDirectory) { - Log.e(TAG, "Source directory ${sourceDir.absolutePath} not found") - return false - } - - try { - sourceDir.copyRecursively(destDir) - } catch (e: Exception) { - Log.e(TAG, "Failed to copy ${sourceDir.absolutePath} -> ${destDir.absolutePath}: ${e.message}", e) - return false - } - return true - } - /** * Recursively calculates the total size of a directory in bytes. */ @@ -85,4 +78,95 @@ object FileUtils { return String.format("%.1f %s", value, units[unitIndex]) } + fun importAndroidProject(context: Context, projectTreeUri: Uri, gradleBuildDir: String, destDir: File) { + val root = DocumentFile.fromTreeUri(context, projectTreeUri) + ?: throw IOException("Invalid tree uri") + + val gradleDir = findDirByPath(root, gradleBuildDir) + ?: throw IOException("Gradle build dir not found: $gradleBuildDir") + + val addonsDir = root.listFiles().firstOrNull { + it.isDirectory && it.name == ADDONS_DIR_NAME + } + + if (destDir.exists()) { + val apkAssetsDir = File(destDir, "src/main/assets") + if (apkAssetsDir.exists()) apkAssetsDir.deleteRecursively() + + val aabAssetsDir = File(destDir, "assetPackInstallTime/src/main/assets") + if (aabAssetsDir.exists()) aabAssetsDir.deleteRecursively() + } else { + destDir.mkdirs() + } + + copyDirectoryMerge(context, gradleDir, destDir) + + if (addonsDir != null) { + val localAddons = File(destDir, ADDONS_DIR_NAME) + if (localAddons.exists()) { + localAddons.deleteRecursively() + } + localAddons.mkdirs() + copyDirectoryMerge(context, addonsDir, localAddons) + } + } + + private fun findDirByPath(parent: DocumentFile, relativePath: String): DocumentFile? { + var current: DocumentFile? = parent + + val parts = relativePath.trim('/').split('/') + + for (part in parts) { + current = current?.listFiles()?.firstOrNull { + it.isDirectory && it.name == part + } ?: return null + } + + return current + } + + private fun copyDirectoryMerge(context: Context, src: DocumentFile, dest: File) { + src.listFiles().forEach { file -> + val name = file.name ?: return@forEach + + if (file.isDirectory) { + val newDir = File(dest, name) + if (!newDir.exists()) newDir.mkdirs() + copyDirectoryMerge(context, file, newDir) + } else { + val outFile = File(dest, name) + context.contentResolver.openInputStream(file.uri).use { input -> + FileOutputStream(outFile, false).use { output -> + input?.copyTo(output) + } + } + } + } + } + + fun saveProjectTreeUri(context: Context, projectPath: String, projectTreeUri: Uri) { + context.contentResolver.takePersistableUriPermission( + projectTreeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + prefs.edit { putString(projectPath, projectTreeUri.toString()) } + } + + fun deleteProjectTreeUri(context: Context, projectPath: String) { + val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val projectTreeUri = prefs.getString(projectPath, null) ?: return + + context.contentResolver.releasePersistableUriPermission( + projectTreeUri.toUri(), + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + prefs.edit { remove(projectPath) } + } + + fun getProjectTreeUri(context: Context, projectPath: String): Uri? { + val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val uriString = prefs.getString(projectPath, null) + return uriString?.toUri() + } } diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/MainActivity.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/MainActivity.kt index 8adb7bf..be5eb07 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/MainActivity.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/MainActivity.kt @@ -1,38 +1,44 @@ package org.godotengine.godot_gradle_build_environment -import android.content.Intent -import android.net.Uri +import android.Manifest +import android.app.AlertDialog +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import android.os.Environment -import android.provider.Settings import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import org.godotengine.godot_gradle_build_environment.ui.theme.GodotGradleBuildEnvironmentTheme -import java.io.File class MainActivity : ComponentActivity() { + private val permissionRequestLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + Toast.makeText(this, "Permission granted", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "Permission not granted", Toast.LENGTH_SHORT).show() + } + } - companion object { - private const val REQUEST_MANAGE_EXTERNAL_STORAGE_REQ_CODE = 2002 - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - if (!Environment.isExternalStorageManager()) { - try { - val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) - intent.data = Uri.parse("package:$packageName") - startActivityForResult(intent, REQUEST_MANAGE_EXTERNAL_STORAGE_REQ_CODE) - } catch (e: Exception) { - val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) - startActivityForResult(intent, REQUEST_MANAGE_EXTERNAL_STORAGE_REQ_CODE) - } + Utils.createNotificationChannel(this) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + AlertDialog.Builder(this) + .setTitle(R.string.notification_perm_request_title) + .setMessage(R.string.notification_perm_request_message) + .setPositiveButton(R.string.perm_request_positive_btn) { _, _ -> + permissionRequestLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + .setNegativeButton(R.string.perm_request_negative_btn, null) + .setCancelable(false) + .show() } } @@ -47,18 +53,4 @@ class MainActivity : ComponentActivity() { } } } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - if (requestCode == REQUEST_MANAGE_EXTERNAL_STORAGE_REQ_CODE) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - if (Environment.isExternalStorageManager()) { - Toast.makeText(this, "Permission granted", Toast.LENGTH_SHORT).show() - } else { - Toast.makeText(this, "Permission NOT granted", Toast.LENGTH_SHORT).show() - } - } - } - } } diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/ProjectInfo.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/ProjectInfo.kt index 6c41246..2fedfb8 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/ProjectInfo.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/ProjectInfo.kt @@ -1,19 +1,19 @@ package org.godotengine.godot_gradle_build_environment +import android.content.Context +import android.net.Uri import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File +import androidx.documentfile.provider.DocumentFile @Serializable data class ProjectInfo( val projectPath: String, - val gradleBuildDir: String + val gradleBuildDir: String, + val projectTreeUri: String, + val projectName: String ) { - fun getProjectName(): String { - return File(projectPath).name - } - companion object { private const val PROJECT_INFO_FILENAME = ".gabe_project_info.json" @@ -22,8 +22,9 @@ data class ProjectInfo( ignoreUnknownKeys = true } - fun writeToDirectory(directory: File, projectPath: String, gradleBuildDir: String) { - val projectInfo = ProjectInfo(projectPath, gradleBuildDir) + fun writeToDirectory(context: Context, directory: File, projectPath: String, gradleBuildDir: String, projectTreeUri: Uri) { + val name = DocumentFile.fromTreeUri(context, projectTreeUri)?.name ?: "Unknown Project" + val projectInfo = ProjectInfo(projectPath, gradleBuildDir, projectTreeUri.toString(), name) val jsonString = json.encodeToString(projectInfo) val file = File(directory, PROJECT_INFO_FILENAME) file.writeText(jsonString) @@ -53,7 +54,7 @@ data class ProjectInfo( CachedProject(dir, info) } } - ?.sortedBy { it.info.getProjectName() } + ?.sortedBy { it.info.projectName } ?: emptyList() } } diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/Utils.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/Utils.kt new file mode 100644 index 0000000..a2caa20 --- /dev/null +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/Utils.kt @@ -0,0 +1,71 @@ +package org.godotengine.godot_gradle_build_environment + +import android.Manifest +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.annotation.RequiresPermission +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import java.io.File + +object Utils { + private val TAG = Utils::class.java.simpleName + + const val EXTRA_PROJECT_PATH = "extra_project_path" + const val EXTRA_TREE_URI = "extra_tree_uri" + const val NOTIFICATION_CHANNEL_ID = "request_dir_access" + const val ACTION_REQUEST_DIRECTORY_ACCESS = "action_request_dir_access" + const val DIRECTORY_ACCESS_REQUEST = 1001 + + fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + context.getString(R.string.dir_access_notification_channel_name), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = context.getString(R.string.dir_access_notification_channel_description) + } + + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + fun showDirectoryAccessNotification(context: Context, projectPath: String) { + val intent = Intent(context, DirectoryAccessActivity::class.java).apply { + action = ACTION_REQUEST_DIRECTORY_ACCESS + putExtra(EXTRA_PROJECT_PATH, projectPath) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + context, + DIRECTORY_ACCESS_REQUEST, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.icon_rootfs_tab) + .setContentTitle(context.getString(R.string.dir_access_notification_title)) + .setContentText(context.getString(R.string.dir_access_notification_message)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(DIRECTORY_ACCESS_REQUEST, notification) + } + + fun getProjectCacheDir(context: Context, projectPath: String, gradleBuildDir: String): File { + val fullPath = File(projectPath, gradleBuildDir) + val hash = Integer.toHexString(fullPath.absolutePath.hashCode()) + return File(AppPaths.getProjectDir(context).absolutePath, hash) + } +} diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/ProjectsScreen.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/ProjectsScreen.kt index 5d15f64..9b220e7 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/ProjectsScreen.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/ProjectsScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers @@ -170,7 +171,7 @@ private fun deleteProject( try { serviceMessenger.send(msg) } catch (e: Exception) { - Log.e("ProjectsScreen", "Error sending delete message for project ${project.info.getProjectName()}: ${e.message}") + Log.e("ProjectsScreen", "Error sending delete message for project ${project.info.projectName}: ${e.message}") } } @@ -221,7 +222,7 @@ private fun ProjectItem( modifier = Modifier.weight(1f) ) { Text( - text = project.info.getProjectName(), + text = project.info.projectName, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface ) @@ -231,6 +232,11 @@ private fun ProjectItem( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp) ) + Text( + text = "${stringResource(R.string.gradle_build_dir_label)}: ${project.info.gradleBuildDir}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) Text( text = sizeText ?: "Calculating...", style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/RootfsScreen.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/RootfsScreen.kt index 2f5286a..71a8799 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/RootfsScreen.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/RootfsScreen.kt @@ -4,7 +4,6 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.os.Bundle import android.os.Handler import android.os.IBinder import android.os.Looper diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/SettingsScreen.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/SettingsScreen.kt index 34284b2..63bdf96 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/SettingsScreen.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/SettingsScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98f5014..cd3a37c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,4 +7,13 @@ Deleting rootfs... Rootfs is not installed!\nYou will not be able to do an Gradle builds until you install it. Rootfs appears to be installed! - \ No newline at end of file + Gradle Build Directory + Notification permission required + GABE needs notification permission to request access to your project directory when a gradle build is started. Without it, builds cannot continue. + Allow + Not now + Project Access + Requests access to the project directory + Directory access required + Tap to grant access to the project directory + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c033fe4..26e8e5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" xz = "1.11" +documentfile = "1.1.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,6 +31,7 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui- androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } xz = { module = "org.tukaani:xz", version.ref = "xz" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }