From f6c3b6f38aa086cd9ca50df86b1c782ed869e0cc Mon Sep 17 00:00:00 2001 From: Prakhar Shukla Date: Sat, 22 Aug 2020 01:51:58 +0530 Subject: [PATCH] android 10 compatibility: APK-based package management (#1715) A basic implementation of APK-based package management. Can install/uninstall Termux packages or output a list of installed ones into Android log. --- .github/workflows/debug_build.yml | 6 +- .github/workflows/run_tests.yml | 4 + app/build.gradle | 169 ++++++---- app/src/main/AndroidManifest.xml | 90 ++++-- app/src/main/cpp/Android.mk | 5 - app/src/main/cpp/termux-bootstrap-zip.S | 18 -- app/src/main/cpp/termux-bootstrap.c | 11 - .../java/com/termux/app/BackgroundJob.java | 1 + .../java/com/termux/app/PackageDownloader.kt | 297 ++++++++++++++++++ .../java/com/termux/app/PackageInstaller.kt | 276 ++++++++++++++++ .../main/java/com/termux/app/PackageLister.kt | 31 ++ .../java/com/termux/app/PackageUninstaller.kt | 40 +++ .../java/com/termux/app/TermuxActivity.java | 22 +- .../java/com/termux/app/TermuxInstaller.java | 157 +-------- .../termux/app/TermuxPackageInstaller.java | 135 ++++++++ .../java/com/termux/app/TermuxService.java | 75 ++++- build.gradle | 4 + gradle.properties | 4 +- gradle/wrapper/gradle-wrapper.properties | 3 +- pkg.sh | 68 ++++ 20 files changed, 1108 insertions(+), 308 deletions(-) delete mode 100644 app/src/main/cpp/Android.mk delete mode 100644 app/src/main/cpp/termux-bootstrap-zip.S delete mode 100644 app/src/main/cpp/termux-bootstrap.c create mode 100644 app/src/main/java/com/termux/app/PackageDownloader.kt create mode 100644 app/src/main/java/com/termux/app/PackageInstaller.kt create mode 100644 app/src/main/java/com/termux/app/PackageLister.kt create mode 100644 app/src/main/java/com/termux/app/PackageUninstaller.kt create mode 100644 app/src/main/java/com/termux/app/TermuxPackageInstaller.java create mode 100644 pkg.sh diff --git a/.github/workflows/debug_build.yml b/.github/workflows/debug_build.yml index 0aa761a7..c374fe96 100644 --- a/.github/workflows/debug_build.yml +++ b/.github/workflows/debug_build.yml @@ -1,4 +1,4 @@ -name: Build +name: APK on: push: @@ -16,6 +16,10 @@ jobs: steps: - name: Clone repository uses: actions/checkout@v2 + - name: Setup java + uses: actions/setup-java@v1 + with: + java-version: 11 - name: Build run: | ./gradlew assembleDebug diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 26474995..1db3a8e6 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -16,6 +16,10 @@ jobs: steps: - name: Clone repository uses: actions/checkout@v2 + - name: Setup java + uses: actions/setup-java@v1 + with: + java-version: 11 - name: Execute tests run: | ./gradlew test diff --git a/app/build.gradle b/app/build.gradle index 7ad7cb9a..db2d6a05 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,12 @@ +import java.security.DigestInputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + + plugins { - id "com.android.application" + id "com.android.application" } +apply plugin: 'kotlin-android' android { compileSdkVersion project.properties.compileSdkVersion.toInteger() @@ -19,17 +25,6 @@ android { targetSdkVersion project.properties.targetSdkVersion.toInteger() versionCode 98 versionName "0.98" - - externalNativeBuild { - ndkBuild { - cFlags "-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections" - } - } - - ndk { - abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' - } - } signingConfigs { @@ -57,11 +52,8 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } - - externalNativeBuild { - ndkBuild { - path "src/main/cpp/Android.mk" - } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 } testOptions { @@ -74,75 +66,132 @@ android { dependencies { testImplementation 'junit:junit:4.13' testImplementation 'org.robolectric:robolectric:4.3.1' + + //kotlin + implementation "androidx.core:core-ktx:1.3.1" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.8' + } task versionName { - doLast { - print android.defaultConfig.versionName - } + doLast { + print android.defaultConfig.versionName + } } -def downloadBootstrap(String arch, String expectedChecksum, int version) { +def setupBootstrap(String arch, String expectedChecksum, int version) { def digest = java.security.MessageDigest.getInstance("SHA-256") - def localUrl = "src/main/cpp/bootstrap-" + arch + ".zip" - def file = new File(projectDir, localUrl) - if (file.exists()) { + def zipDownloadFile = new File(project.buildDir, "./gradle/bootstrap-" + arch + "-" + version + ".zip") + + if (zipDownloadFile.exists()) { def buffer = new byte[8192] - def input = new FileInputStream(file) + def input = new FileInputStream(zipDownloadFile) while (true) { def readBytes = input.read(buffer) if (readBytes < 0) break digest.update(buffer, 0, readBytes) } def checksum = new BigInteger(1, digest.digest()).toString(16) - if (checksum == expectedChecksum) { - return - } else { - logger.quiet("Deleting old local file with wrong hash: " + localUrl) - file.delete() + if (checksum != expectedChecksum) { + logger.quiet("Deleting old local file with wrong hash: " + zipDownloadFile.getAbsolutePath()) + zipDownloadFile.delete() } } - def remoteUrl = "https://bintray.com/termux/bootstrap/download_file?file_path=bootstrap-" + arch + "-v" + version + ".zip" - logger.quiet("Downloading " + remoteUrl + " ...") + if (!zipDownloadFile.exists()) { + def remoteUrl = "https://bintray.com/termux/bootstrap/download_file?file_path=android10-v" + version + "-bootstrap-" + arch + ".zip" + logger.quiet("Downloading " + remoteUrl + " ...") - file.parentFile.mkdirs() - def out = new BufferedOutputStream(new FileOutputStream(file)) + zipDownloadFile.parentFile.mkdirs() + def out = new BufferedOutputStream(new FileOutputStream(zipDownloadFile)) - def connection = new URL(remoteUrl).openConnection() - connection.setInstanceFollowRedirects(true) - def digestStream = new java.security.DigestInputStream(connection.inputStream, digest) - out << digestStream - out.close() + def connection = new URL(remoteUrl).openConnection() + connection.setInstanceFollowRedirects(true) + def digestStream = new DigestInputStream(connection.inputStream, digest) + out << digestStream + out.close() - def checksum = new BigInteger(1, digest.digest()).toString(16) - if (checksum != expectedChecksum) { - file.delete() - throw new GradleException("Wrong checksum for " + remoteUrl + ": expected: " + expectedChecksum + ", actual: " + checksum) + def checksum = new BigInteger(1, digest.digest()).toString(16) + if (checksum != expectedChecksum) { + zipDownloadFile.delete() + throw new GradleException("Wrong checksum for " + remoteUrl + ": expected: " + expectedChecksum + ", actual: " + checksum) + } } + + def doneMarkerFile = new File(zipDownloadFile.getAbsolutePath() + "." + expectedChecksum + ".done") + + if (doneMarkerFile.exists()) return + + def archDirName + if (arch == "aarch64") archDirName = "arm64-v8a"; + if (arch == "arm") archDirName = "armeabi-v7a"; + if (arch == "i686") archDirName = "x86"; + if (arch == "x86_64") archDirName = "x86_64"; + + def outputPath = project.getRootDir().getAbsolutePath() + "/app/src/main/jniLibs/" + archDirName + "/" + def outputDir = new File(outputPath).getAbsoluteFile() + if (!outputDir.exists()) outputDir.mkdirs() + + def symlinksFile = new File(outputDir, "libsymlinks.so").getAbsoluteFile() + if (symlinksFile.exists()) symlinksFile.delete(); + + def mappingsFile = new File(outputDir, "libfiles.so").getAbsoluteFile() + if (mappingsFile.exists()) mappingsFile.delete() + mappingsFile.createNewFile() + def mappingsFileWriter = new BufferedWriter(new FileWriter(mappingsFile)) + + def counter = 100 + new ZipInputStream(new FileInputStream(zipDownloadFile)).withCloseable { zipInput -> + ZipEntry zipEntry + while ((zipEntry = zipInput.getNextEntry()) != null) { + if (zipEntry.getName() == "SYMLINKS.txt") { + zipInput.transferTo(new FileOutputStream(symlinksFile)) + } else if (!zipEntry.isDirectory()) { + def soName = "lib" + counter + ".so" + def targetFile = new File(outputDir, soName).getAbsoluteFile() + + println "target file path is ${targetFile}" + + try { + zipInput.transferTo(new FileOutputStream(targetFile)) + } catch (Exception e) { + println "Error ${e}" + } + + + if (zipEntry.getName().endsWith("/pkg")) { + def pkgScript = new FileInputStream(project.getRootDir().getAbsolutePath() + "/pkg.sh") + pkgScript.transferTo(new FileOutputStream(targetFile)) + } + + mappingsFileWriter.writeLine(soName + "←" + zipEntry.getName()) + counter++ + } + } + } + + mappingsFileWriter.close() + doneMarkerFile.createNewFile() } -clean { +task setupBootstraps() { doLast { - def tree = fileTree(new File(projectDir, 'src/main/cpp')) - tree.include 'bootstrap-*.zip' - tree.each { it.delete() } - } -} - -task downloadBootstraps(){ - doLast { - def version = 27 - downloadBootstrap("aarch64", "517fb3aa215f7b96961f9377822d7f1b5e86c831efb4ab096ed65d0b1cdf02e9", version) - downloadBootstrap("arm", "94d17183afdd017cf8ab885b9103a370b16bec1d3cb641884511d545ee009b90", version) - downloadBootstrap("i686", "7f27723d2f0afbe7e90f203b3ca2e80871a8dfa08b136229476aa5e7ba3e988f", version) - downloadBootstrap("x86_64", "b19b2721bae5fb3a3fb0754c49611ce4721221e1e7997e7fd98940776ad88c3d", version) + def version = 12 + setupBootstrap("aarch64", "5e07239cad78050f56a28f9f88a0b485cead45864c6c00e1a654c728152b0244", version) + setupBootstrap("arm", "fc72279c480c1eea46b6f0fcf78dc57599116c16dcf3b2b970a9ef828f0ec30b", version) + setupBootstrap("i686", "895680fc967aecfa4ed77b9dc03aab95d86345be69df48402c63bfc0178337f6", version) + setupBootstrap("x86_64", "8714ab8a5ff4e1f5f3ec01e7d0294776bfcffb187c84fa95270ec67ede8f682e", version) } } afterEvaluate { - android.applicationVariants.all { variant -> - variant.javaCompileProvider.get().dependsOn(downloadBootstraps) - } + android.applicationVariants.all { variant -> + variant.javaCompileProvider.get().dependsOn(setupBootstraps) + } +} +repositories { + mavenCentral() } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b2930376..22170359 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,15 +3,20 @@ package="com.termux" android:installLocation="internalOnly" android:sharedUserId="com.termux" - android:sharedUserLabel="@string/shared_user_label" > + android:sharedUserLabel="@string/shared_user_label"> - - + + - @@ -20,28 +25,32 @@ - + + + + android:supportsRtl="false" + android:theme="@style/Theme.Termux"> - + + android:windowSoftInputMode="adjustResize|stateAlwaysVisible"> @@ -50,28 +59,33 @@ - + + + android:theme="@android:style/Theme.Material.Light.DarkActionBar" /> + + android:taskAffinity="com.termux.filereceiver"> - + + @@ -82,8 +96,9 @@ - + + @@ -99,17 +114,18 @@ - - - + + + + @@ -123,7 +139,7 @@ + android:permission="com.termux.permission.RUN_COMMAND"> @@ -131,13 +147,19 @@ - - - + + + + diff --git a/app/src/main/cpp/Android.mk b/app/src/main/cpp/Android.mk deleted file mode 100644 index d013e2ed..00000000 --- a/app/src/main/cpp/Android.mk +++ /dev/null @@ -1,5 +0,0 @@ -LOCAL_PATH:= $(call my-dir) -include $(CLEAR_VARS) -LOCAL_MODULE := libtermux-bootstrap -LOCAL_SRC_FILES := termux-bootstrap-zip.S termux-bootstrap.c -include $(BUILD_SHARED_LIBRARY) diff --git a/app/src/main/cpp/termux-bootstrap-zip.S b/app/src/main/cpp/termux-bootstrap-zip.S deleted file mode 100644 index 1cfc9580..00000000 --- a/app/src/main/cpp/termux-bootstrap-zip.S +++ /dev/null @@ -1,18 +0,0 @@ - .global blob - .global blob_size - .section .rodata - blob: - #if defined __i686__ - .incbin "bootstrap-i686.zip" - #elif defined __x86_64__ - .incbin "bootstrap-x86_64.zip" - #elif defined __aarch64__ - .incbin "bootstrap-aarch64.zip" - #elif defined __arm__ - .incbin "bootstrap-arm.zip" - #else - # error Unsupported arch - #endif - 1: - blob_size: - .int 1b - blob diff --git a/app/src/main/cpp/termux-bootstrap.c b/app/src/main/cpp/termux-bootstrap.c deleted file mode 100644 index 8ba745ff..00000000 --- a/app/src/main/cpp/termux-bootstrap.c +++ /dev/null @@ -1,11 +0,0 @@ -#include - -extern jbyte blob[]; -extern int blob_size; - -JNIEXPORT jbyteArray JNICALL Java_com_termux_app_TermuxInstaller_getZip(JNIEnv *env, __attribute__((__unused__)) jobject This) -{ - jbyteArray ret = (*env)->NewByteArray(env, blob_size); - (*env)->SetByteArrayRegion(env, ret, 0, blob_size, blob); - return ret; -} diff --git a/app/src/main/java/com/termux/app/BackgroundJob.java b/app/src/main/java/com/termux/app/BackgroundJob.java index 657cce7d..fd62c476 100644 --- a/app/src/main/java/com/termux/app/BackgroundJob.java +++ b/app/src/main/java/com/termux/app/BackgroundJob.java @@ -138,6 +138,7 @@ public final class BackgroundJob { List environment = new ArrayList<>(); + environment.add("TERMUX_ANDROID10=1"); environment.add("TERM=xterm-256color"); environment.add("COLORTERM=truecolor"); environment.add("HOME=" + TermuxService.HOME_PATH); diff --git a/app/src/main/java/com/termux/app/PackageDownloader.kt b/app/src/main/java/com/termux/app/PackageDownloader.kt new file mode 100644 index 00000000..1fc9ffb1 --- /dev/null +++ b/app/src/main/java/com/termux/app/PackageDownloader.kt @@ -0,0 +1,297 @@ +package com.termux.app + +import android.app.NotificationManager +import android.content.Context +import android.os.Handler +import android.os.StatFs +import android.util.Log +import androidx.core.app.NotificationCompat +import com.termux.R +import com.termux.app.PackageInstaller.Companion.log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.net.ConnectException +import java.net.URL +import java.net.URLConnection +import java.net.UnknownHostException + + +// Download status constants + +const val ENTERED = -1 +const val QUEUED = 0 +const val STARTED = 1 +const val RUNNING = 2 +const val COMPLETED = 3 +const val ERROR = 4 +const val NOTIFICATION_CHANNEL_ID = "termux_notification_channel" + +class PackageDownloader(val context: Context) { + + private lateinit var notificationManager: NotificationManager + private lateinit var builder: NotificationCompat.Builder + + interface ProgressListener { + fun onProgress(data: DownloadData) + } + + interface StartListener { + fun onStart(data: DownloadData) + } + + interface CompleteListener { + fun onComplete(data: DownloadData) + } + + interface ErrorListener { + fun onError(data: ErrorData) + } + + private lateinit var progressListener: ProgressListener + private lateinit var errorListener: ErrorListener + private lateinit var completeListener: CompleteListener + private lateinit var startListener: StartListener + private lateinit var downloadingJob: Job + + + fun initListeners(progressL: ProgressListener, errorL: ErrorListener, completeL: CompleteListener, startL: StartListener) { + this.progressListener = progressL + this.completeListener = completeL + this.errorListener = errorL + this.startListener = startL + } + + fun download(packageName: String) { + notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + builder = NotificationCompat.Builder(context, "termux_notification_channel").setChannelId(NOTIFICATION_CHANNEL_ID) + + + var isStartNotified = false + + var percent20 = false + var percent40 = false + var percent60 = false + var percent80 = false + + //val fileUrl = "https://termux.net/apks/$packageName.apk" + val fileUrl = "https://staging.termux-mirror.ml/android-10/$packageName.apk" + "URL -> $fileUrl".log() + try { + downloadingJob = GlobalScope.launch(Dispatchers.IO) { + try { + val downloadData = DownloadData(packageName, 0, 0, 0, ENTERED) + showNotification(downloadData) + val downloadFile = File("${TermuxService.FILES_PATH}/${packageName}.apk") + deleteFileIfExists(downloadFile) + "Fetching the file size...".log() + val url = URL(fileUrl) + val connection: URLConnection = url.openConnection() + connection.connect() + val lengthOfFile: Int = connection.contentLength + var total = 0 + + if ((getFreeSpace() * 2) > lengthOfFile) { + downloadData.totalKB = lengthOfFile.toKB() + downloadData.Status = QUEUED + "Queuing the download...".log() + FileOutputStream(downloadFile).use { out -> + url.openStream().use { `in` -> + val buffer = ByteArray(1024) + var read: Int + while (`in`.read(buffer).also { read = it } >= 0) { + total += read + out.write(buffer, 0, read) + downloadData.progressInKB = total.toKB() + + if (total != 0 && !isStartNotified) { + downloadData.Status = STARTED + startListener.onStart(downloadData) + isStartNotified = true + } + downloadData.Status = RUNNING + GlobalScope.launch(Dispatchers.Main) { + + } + val percent = (total * 100) / lengthOfFile + fun updateProgress(percent: Int) { + downloadData.progressPercent = percent + progressListener.onProgress(downloadData) + } + if (percent % 20 == 0 && total != lengthOfFile) { + // Can be simplified + percent.let { + if (it == 20 && !percent20) { + updateNotification(downloadData) + percent20 = true + updateProgress(it) + } else if (it == 40 && !percent40) { + updateNotification(downloadData) + percent40 = true + updateProgress(it) + } else if (it == 60 && !percent60) { + updateNotification(downloadData) + percent60 = true + updateProgress(it) + } else if (it == 80 && !percent80) { + updateNotification(downloadData) + percent80 = true + updateProgress(it) + } + } + } + + if (total == lengthOfFile) { + downloadData.progressPercent = percent + downloadData.Status = COMPLETED + removeNotification(downloadData) + completeListener.onComplete(downloadData) + } + } + } + } + } else { + throw InsufficientStorageException("Insufficient Storage. Please clear some data before installing.") + } + + } catch (e: FileNotFoundException) { + packageName.clearThingsUp() + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = "Package $packageName does not exists!")) + } catch (e: UnknownHostException) { + packageName.clearThingsUp() + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = "Cannot connect to internet or server unavailable. Aborting the installation.")) + } catch (e: ConnectException) { + packageName.clearThingsUp() + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = "Cannot connect to internet or server unavailable. Aborting the installation.")) + } catch (e: InsufficientStorageException) { + packageName.clearThingsUp() + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = "Insufficient Storage. Please clear some data before installing.")) + } catch (e: Exception) { + packageName.clearThingsUp() + Log.e("termux", "Error installing $packageName", e) + if (this@PackageDownloader::downloadingJob.isInitialized) { + if (downloadingJob.isActive) { + downloadingJob.cancel() + } + } + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = e.toString())) + } + } + + } catch (e: FileNotFoundException) { + packageName.clearThingsUp() + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = "Package $packageName does not exists!")) + } catch (e: ConnectException) { + packageName.clearThingsUp() + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = "Cannot connect to internet or server unavailable. Aborting the installation.")) + + } catch (e: UnknownHostException) { + packageName.clearThingsUp() + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = "Cannot connect to internet. Aborting the installation.")) + } catch (e: InsufficientStorageException) { + packageName.clearThingsUp() + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = "Insufficient Storage. Please clear some data before installing.")) + } catch (e: Exception) { + packageName.clearThingsUp() + Log.e("termux", "Error installing $packageName", e) + if (this::downloadingJob.isInitialized) { + if (downloadingJob.isActive) { + downloadingJob.cancel() + } + } + errorListener.onError(ErrorData(packageName = packageName, Status = ERROR, error = e.toString())) + } + } + + private fun getFreeSpace(): Long { + val path = context.dataDir + val stat = StatFs(path.path) + val blockSize: Long + val availableBlocks: Long + blockSize = stat.blockSizeLong + availableBlocks = stat.availableBlocksLong + return availableBlocks * blockSize + } + + private fun deleteFileIfExists(downloadFile: File) { + if (downloadFile.exists()) { + if (downloadFile.delete()) + "File Deleted!".log() + } + } + + private fun Int.toKB(): Long { + return (this * 0.001).toLong() + } + + private fun String.clearThingsUp() { + val downloadFile = File("${TermuxService.FILES_PATH}/${this}.apk") + deleteFileIfExists(downloadFile) + notificationManager.cancelAll() + } + + /* + Notification + */ + + private fun showNotification(downloadData: DownloadData) { + builder + .setContentTitle("Downloading ${downloadData.packageName}") + .setSmallIcon(R.drawable.ic_service_notification) + .setProgress(0, 0, true) + + //setting indeterminate progress + + getNotificationID().let { + downloadData.notificationID = it + notificationManager.notify(it, builder.build()) + } + } + + private fun removeNotification(downloadData: DownloadData) { + GlobalScope.launch(Dispatchers.Main) { + if (downloadData.Status == COMPLETED) { + builder.setContentTitle("Package Downloaded.") + builder.setContentTitle("${downloadData.packageName} has been download. You can install the package now.") + builder.setProgress(0, 0, false) + notificationManager.notify(downloadData.notificationID, builder.build()) + Handler().postDelayed({ + notificationManager.cancel(downloadData.notificationID) + }, 5000) + } else + notificationManager.cancel(downloadData.notificationID) + } + } + + private fun updateNotification(downloadData: DownloadData) { + downloadData.let { + builder.setContentTitle("Downloading ${downloadData.packageName}") + builder.setProgress(it.totalKB.toInt(), it.progressInKB.toInt(), false) + notificationManager.notify(it.notificationID, builder.build()) + } + + + } + + private fun getNotificationID(): Int { + return (100..999).random() + } + +} + + +data class DownloadData( + var packageName: String, + var totalKB: Long = 0, + var progressInKB: Long = 0, + var progressPercent: Int = 0, + var Status: Int, + var notificationID: Int = 0 +) + +class InsufficientStorageException(message: String) : Exception(message) +data class ErrorData(var packageName: String, var error: String, var extraLogs: String = "", var Status: Int) diff --git a/app/src/main/java/com/termux/app/PackageInstaller.kt b/app/src/main/java/com/termux/app/PackageInstaller.kt new file mode 100644 index 00000000..1d03c759 --- /dev/null +++ b/app/src/main/java/com/termux/app/PackageInstaller.kt @@ -0,0 +1,276 @@ +package com.termux.app + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.util.Log +import android.widget.Toast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.io.OutputStream + +const val PACKAGE_INSTALLED_ACTION = "com.termux.SESSION_API_PACKAGE_INSTALLED" + +class PackageInstaller(val context: Context) : PackageDownloader.ErrorListener, PackageDownloader.ProgressListener, PackageDownloader.StartListener, PackageDownloader.CompleteListener { + + private val downloadHashMap: HashMap = hashMapOf() + private val installationResponseHashMap: HashMap = hashMapOf() + private var packagesToInstall: ArrayList = arrayListOf() + private val packageDownloader = PackageDownloader(context) + private var currentPosition = 0 + private var totalLength = 0 + + fun initDownloader(packageList: Array) { + + if (isInstallationOfApkAllowed()) { + context.registerReceiver(broadcastReceiver, IntentFilter(PACKAGE_INSTALLED_ACTION)) + packageDownloader.initListeners(this, this, this, this) + val verifiedPackageList = packageList.removeRepetition() + verifiedPackageList.forEach { packageName -> + startDownload(packageName) + } + } else { + GlobalScope.launch(Dispatchers.Main) { + "Permission Insufficient. Please provide the following permission and rerun the command.".log() + Toast.makeText(context, "Please allow installation from unknown sources for Termux.", Toast.LENGTH_SHORT).show() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + //This weirdly messes up the activity stack + val permIntent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:com.termux")) + permIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(permIntent) + } + } + + } + } + + private fun startDownload(packageName: String) { + downloadHashMap[packageName] = LocalDownloadData(packageName, null) + packageDownloader.download(packageName) + } + + private fun Array.removeRepetition(): Array { + return this.toList().distinct().toTypedArray() + } + + override fun onProgress(data: DownloadData) { + "${data.packageName} has been ${data.progressPercent}% downloaded.".log() + } + + override fun onStart(data: DownloadData) { + "Downloading ${data.packageName}...".log() + } + + override fun onComplete(data: DownloadData) { + downloadHashMap[data.packageName] = LocalDownloadData(data.packageName, true, "Successfully downloaded!") + "Completed downloading ${data.packageName}...".log() + checkIfAllPackagesAreDownloaded() + } + + override fun onError(data: ErrorData) { + downloadHashMap[data.packageName] = LocalDownloadData(data.packageName, true, "download aborted -> ${data.error}") + "Error downloading ${data.packageName} --> ${data.error}...".log() + checkIfAllPackagesAreDownloaded() + } + + private fun checkIfAllPackagesAreDownloaded() { + var counter = 0 + downloadHashMap.forEach { (_, installData) -> + if (installData.isDownloaded == null) { + //packageLeft + ++counter + } + } + if (counter == 0) { + endDownloadSession() + proceedToInstallation() + } + } + + + /*---------------------------------------- INSTALLATION------------------------------------------*/ + + + private fun installAPK(packageName: String) { + "Proceeding to write $packageName".log() + if (isInstallationOfApkAllowed()) { + + GlobalScope.launch(Dispatchers.IO) { + var session: PackageInstaller.Session? = null + try { + val packageInstaller: PackageInstaller = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL) + val sessionId = packageInstaller.createSession(params) + session = packageInstaller.openSession(sessionId) + addApkToInstallSession(session, packageName) + val installBroadcast = PendingIntent.getBroadcast(context, 0, Intent(PACKAGE_INSTALLED_ACTION).putExtra("packageName", packageName), PendingIntent.FLAG_UPDATE_CURRENT) + session.commit(installBroadcast.intentSender) + session.close() + } catch (e: IOException) { + throw RuntimeException("Couldn't install package", e) + } catch (e: RuntimeException) { + session?.abandon() + throw e + } finally { + session?.close() + } + } + } else { + GlobalScope.launch(Dispatchers.Main) { + "Permission Insufficient. Please provide the following permission and rerun the command.".log() + Toast.makeText(context, "Please allow installation from unknown sources for Termux.", Toast.LENGTH_SHORT).show() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:com.termux"))) + } + } + + } + } + + private fun addApkToInstallSession(session: PackageInstaller.Session, + packageName: String) { + val file = File("${TermuxService.FILES_PATH}/$packageName.apk") + val packageInSession: OutputStream = session.openWrite(packageName, 0, -1) + val inputStream = FileInputStream(file) + try { + var c: Int + val buffer = ByteArray(16384) + while (inputStream.read(buffer).also { c = it } >= 0) { + packageInSession.write(buffer, 0, c) + } + } catch (e: IOException) { + "IOEX".log() + } finally { + try { + packageInSession.close() + inputStream.close() + } catch (e: IOException) { + ("IOEX in closing the stream").log() + } + } + } + + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val extras = intent.extras + val status = extras!!.getInt(PackageInstaller.EXTRA_STATUS) + val message = extras.getString(PackageInstaller.EXTRA_STATUS_MESSAGE) + val packageName = extras.getString("packageName")!! + if (PACKAGE_INSTALLED_ACTION == intent.action) { + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + // This test app isn't privileged, so the user has to confirm the install. + val confirmIntent = extras[Intent.EXTRA_INTENT] as Intent + confirmIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(confirmIntent) + } + PackageInstaller.STATUS_SUCCESS -> { + Toast.makeText(context, "Install succeeded!", Toast.LENGTH_LONG).show() + ("$packageName Install succeeded!").log() + installationResponseHashMap[packageName] = "installation successful!" + proceedToInstallation(true) + } + PackageInstaller.STATUS_FAILURE, PackageInstaller.STATUS_FAILURE_ABORTED, PackageInstaller.STATUS_FAILURE_BLOCKED, PackageInstaller.STATUS_FAILURE_CONFLICT, PackageInstaller.STATUS_FAILURE_INCOMPATIBLE, PackageInstaller.STATUS_FAILURE_INVALID, PackageInstaller.STATUS_FAILURE_STORAGE -> { + Toast.makeText(context, "Install failed! $status, $message", + Toast.LENGTH_LONG).show() + ("$packageName Install failed!").log() + //can separate cases if that's important + installationResponseHashMap[packageName] = "installation failed! | $message" + proceedToInstallation(true) + } + else -> { + ("$packageName Unrecognized status received from installer: $status").log() + Toast.makeText(context, "Unrecognized status received from installer: $status", + Toast.LENGTH_LONG).show() + installationResponseHashMap[packageName] = "installation failed! | $message" + proceedToInstallation(true) + // exitActivity("Package failed to install -> Unknown Error!") + } + } + } + } + + } + + private fun proceedToInstallation(next: Boolean = false) { + getApkListInFileSystem() + if (!next) { + if (packagesToInstall.isEmpty()) { + endInstallationSession() + } else { + totalLength = packagesToInstall.size - 1 + installAPK(packagesToInstall[currentPosition]) + } + } else { + if (currentPosition == totalLength) { + endInstallationSession() + } else { + installAPK(packagesToInstall[++currentPosition]) + } + } + } + + private fun getApkListInFileSystem() { + downloadHashMap.forEach { (packageName) -> + //Setting up a default response + installationResponseHashMap[packageName] = "the request package was either not downloaded or just doesn't exist!" + val apkFileToBeInstalled = File("${TermuxService.FILES_PATH}/$packageName.apk") + if (apkFileToBeInstalled.exists()) { + packagesToInstall.add(packageName) + } + } + } + + + private fun endDownloadSession() { + "DOWNLOADS COMPLETED".log() + "Here are the logs...".log() + downloadHashMap.forEach { (packageName, installData) -> + "$packageName -> ${installData.extraLogs}".log() + } + + } + + private fun endInstallationSession() { + "INSTALLATION COMPLETED".log() + "Here are the logs...".log() + installationResponseHashMap.forEach { (packageName, response) -> + "$packageName -> $response".log() + } + context.unregisterReceiver(broadcastReceiver) + this.packagesToInstall.clear() + this.installationResponseHashMap.clear() + this.downloadHashMap.clear() + } + + private fun isInstallationOfApkAllowed(): Boolean { + val packageManager = context.packageManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + packageManager.canRequestPackageInstalls() + } else + true + } + + companion object { + private const val TERMUX_PACKAGE_TAG = "Termux Package Management" + fun Any.log() { + Log.i(TERMUX_PACKAGE_TAG, this.toString()) + } + } + + +} + + +data class LocalDownloadData(var packageName: String, var isDownloaded: Boolean?, var extraLogs: String = "") diff --git a/app/src/main/java/com/termux/app/PackageLister.kt b/app/src/main/java/com/termux/app/PackageLister.kt new file mode 100644 index 00000000..a9714af2 --- /dev/null +++ b/app/src/main/java/com/termux/app/PackageLister.kt @@ -0,0 +1,31 @@ +package com.termux.app + +import android.content.Context +import android.content.pm.PackageManager +import com.termux.app.PackageInstaller.Companion.log + + +class PackageLister(val context: Context) { + // This class can be used to implement other stuff in the future relating to packages + + + fun listPackages() { + val termuxPackagesList: ArrayList = arrayListOf() + val pm: PackageManager = context.packageManager + val packages = pm.getInstalledApplications(PackageManager.GET_META_DATA) + + packages.forEach { packageInfo -> + val packageName = packageInfo.packageName + if (packageName.startsWith(TERMUX_APK_SUFFIX)) { + termuxPackagesList.add(packageName.replace(TERMUX_APK_SUFFIX, "")) + } + } + + if (termuxPackagesList.isEmpty()) + ("No package is currently installed").log() + else { + ("Here are the installed termux packages -> ").log() + termuxPackagesList.forEach { (it).log() } + } + } +} diff --git a/app/src/main/java/com/termux/app/PackageUninstaller.kt b/app/src/main/java/com/termux/app/PackageUninstaller.kt new file mode 100644 index 00000000..5823aaa1 --- /dev/null +++ b/app/src/main/java/com/termux/app/PackageUninstaller.kt @@ -0,0 +1,40 @@ +package com.termux.app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import com.termux.app.PackageInstaller.Companion.log + +const val TERMUX_APK_SUFFIX = "net.termux." + +class PackageUninstaller(var context: Context) { + + fun uninstallPackages(packageList: Array) { + registerBroadcast() + packageList.forEach { uninstallAPK(it) } + } + + private fun uninstallAPK(packageName: String) { + val intent = Intent(Intent.ACTION_DELETE) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + intent.data = Uri.parse("package:${TERMUX_APK_SUFFIX}${packageName}") + context.startActivity(intent) + } + + + private fun registerBroadcast() { + val uninstallApplication: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val packageName = intent.data!!.encodedSchemeSpecificPart.toString() + if (packageName.startsWith(TERMUX_APK_SUFFIX)) packageName.replace(TERMUX_APK_SUFFIX, "") + ("Package Uninstalled --> $packageName").log() + } + } + val intentFilter = IntentFilter() + intentFilter.addAction(Intent.ACTION_PACKAGE_REMOVED) + intentFilter.addDataScheme("package") + context.registerReceiver(uninstallApplication, intentFilter) + } +} diff --git a/app/src/main/java/com/termux/app/TermuxActivity.java b/app/src/main/java/com/termux/app/TermuxActivity.java index 12989539..eb8b9e90 100644 --- a/app/src/main/java/com/termux/app/TermuxActivity.java +++ b/app/src/main/java/com/termux/app/TermuxActivity.java @@ -487,19 +487,17 @@ public final class TermuxActivity extends Activity implements ServiceConnection if (mTermService.getSessions().isEmpty()) { if (mIsVisible) { - TermuxInstaller.setupIfNeeded(TermuxActivity.this, () -> { - if (mTermService == null) return; // Activity might have been destroyed. - try { - Bundle bundle = getIntent().getExtras(); - boolean launchFailsafe = false; - if (bundle != null) { - launchFailsafe = bundle.getBoolean(TERMUX_FAILSAFE_SESSION_ACTION, false); - } - addNewSession(launchFailsafe, null); - } catch (WindowManager.BadTokenException e) { - // Activity finished - ignore. + if (mTermService == null) return; // Activity might have been destroyed. + try { + Bundle bundle = getIntent().getExtras(); + boolean launchFailsafe = false; + if (bundle != null) { + launchFailsafe = bundle.getBoolean(TERMUX_FAILSAFE_SESSION_ACTION, false); } - }); + addNewSession(launchFailsafe, null); + } catch (WindowManager.BadTokenException e) { + // Activity finished - ignore. + } } else { // The service connected while not in foreground - just bail out. finish(); diff --git a/app/src/main/java/com/termux/app/TermuxInstaller.java b/app/src/main/java/com/termux/app/TermuxInstaller.java index 6e50b22d..07593c42 100644 --- a/app/src/main/java/com/termux/app/TermuxInstaller.java +++ b/app/src/main/java/com/termux/app/TermuxInstaller.java @@ -1,177 +1,24 @@ package com.termux.app; -import android.app.Activity; -import android.app.AlertDialog; -import android.app.ProgressDialog; import android.content.Context; import android.os.Environment; -import android.os.UserManager; import android.system.Os; import android.util.Log; -import android.util.Pair; -import android.view.WindowManager; -import com.termux.R; -import com.termux.terminal.EmulatorDebug; - -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; /** - * Install the Termux bootstrap packages if necessary by following the below steps: - *

- * (1) If $PREFIX already exist, assume that it is correct and be done. Note that this relies on that we do not create a - * broken $PREFIX folder below. - *

- * (2) A progress dialog is shown with "Installing..." message and a spinner. - *

- * (3) A staging folder, $STAGING_PREFIX, is {@link #deleteFolder(File)} if left over from broken installation below. - *

- * (4) The zip file is loaded from a shared library. - *

- * (5) The zip, containing entries relative to the $PREFIX, is is downloaded and extracted by a zip input stream - * continuously encountering zip file entries: - *

- * (5.1) If the zip entry encountered is SYMLINKS.txt, go through it and remember all symlinks to setup. - *

- * (5.2) For every other zip entry, extract it into $STAGING_PREFIX and set execute permissions if necessary. + * Install the Termux bootstrap packages if necessary. */ final class TermuxInstaller { - /** Performs setup if necessary. */ - static void setupIfNeeded(final Activity activity, final Runnable whenDone) { - // Termux can only be run as the primary user (device owner) since only that - // account has the expected file system paths. Verify that: - UserManager um = (UserManager) activity.getSystemService(Context.USER_SERVICE); - boolean isPrimaryUser = um.getSerialNumberForUser(android.os.Process.myUserHandle()) == 0; - if (!isPrimaryUser) { - new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(R.string.bootstrap_error_not_primary_user_message) - .setOnDismissListener(dialog -> System.exit(0)).setPositiveButton(android.R.string.ok, null).show(); - return; - } - - final File PREFIX_FILE = new File(TermuxService.PREFIX_PATH); - if (PREFIX_FILE.isDirectory()) { - whenDone.run(); - return; - } - - final ProgressDialog progress = ProgressDialog.show(activity, null, activity.getString(R.string.bootstrap_installer_body), true, false); - new Thread() { - @Override - public void run() { - try { - final String STAGING_PREFIX_PATH = TermuxService.FILES_PATH + "/usr-staging"; - final File STAGING_PREFIX_FILE = new File(STAGING_PREFIX_PATH); - - if (STAGING_PREFIX_FILE.exists()) { - deleteFolder(STAGING_PREFIX_FILE); - } - - final byte[] buffer = new byte[8096]; - final List> symlinks = new ArrayList<>(50); - - final byte[] zipBytes = loadZipBytes(); - try (ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { - ZipEntry zipEntry; - while ((zipEntry = zipInput.getNextEntry()) != null) { - if (zipEntry.getName().equals("SYMLINKS.txt")) { - BufferedReader symlinksReader = new BufferedReader(new InputStreamReader(zipInput)); - String line; - while ((line = symlinksReader.readLine()) != null) { - String[] parts = line.split("←"); - if (parts.length != 2) - throw new RuntimeException("Malformed symlink line: " + line); - String oldPath = parts[0]; - String newPath = STAGING_PREFIX_PATH + "/" + parts[1]; - symlinks.add(Pair.create(oldPath, newPath)); - - ensureDirectoryExists(new File(newPath).getParentFile()); - } - } else { - String zipEntryName = zipEntry.getName(); - File targetFile = new File(STAGING_PREFIX_PATH, zipEntryName); - boolean isDirectory = zipEntry.isDirectory(); - - ensureDirectoryExists(isDirectory ? targetFile : targetFile.getParentFile()); - - if (!isDirectory) { - try (FileOutputStream outStream = new FileOutputStream(targetFile)) { - int readBytes; - while ((readBytes = zipInput.read(buffer)) != -1) - outStream.write(buffer, 0, readBytes); - } - if (zipEntryName.startsWith("bin/") || zipEntryName.startsWith("libexec") || zipEntryName.startsWith("lib/apt/methods")) { - //noinspection OctalInteger - Os.chmod(targetFile.getAbsolutePath(), 0700); - } - } - } - } - } - - if (symlinks.isEmpty()) - throw new RuntimeException("No SYMLINKS.txt encountered"); - for (Pair symlink : symlinks) { - Os.symlink(symlink.first, symlink.second); - } - - if (!STAGING_PREFIX_FILE.renameTo(PREFIX_FILE)) { - throw new RuntimeException("Unable to rename staging folder"); - } - - activity.runOnUiThread(whenDone); - } catch (final Exception e) { - Log.e(EmulatorDebug.LOG_TAG, "Bootstrap error", e); - activity.runOnUiThread(() -> { - try { - new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(R.string.bootstrap_error_body) - .setNegativeButton(R.string.bootstrap_error_abort, (dialog, which) -> { - dialog.dismiss(); - activity.finish(); - }).setPositiveButton(R.string.bootstrap_error_try_again, (dialog, which) -> { - dialog.dismiss(); - TermuxInstaller.setupIfNeeded(activity, whenDone); - }).show(); - } catch (WindowManager.BadTokenException e1) { - // Activity already dismissed - ignore. - } - }); - } finally { - activity.runOnUiThread(() -> { - try { - progress.dismiss(); - } catch (RuntimeException e) { - // Activity already dismissed - ignore. - } - }); - } - } - }.start(); - } - - private static void ensureDirectoryExists(File directory) { + static void ensureDirectoryExists(File directory) { if (!directory.isDirectory() && !directory.mkdirs()) { throw new RuntimeException("Unable to create directory: " + directory.getAbsolutePath()); } } - public static byte[] loadZipBytes() { - // Only load the shared library when necessary to save memory usage. - System.loadLibrary("termux-bootstrap"); - return getZip(); - } - - public static native byte[] getZip(); - /** Delete a folder and all its content or throw. Don't follow symlinks. */ static void deleteFolder(File fileOrDirectory) throws IOException { if (fileOrDirectory.getCanonicalPath().equals(fileOrDirectory.getAbsolutePath()) && fileOrDirectory.isDirectory()) { diff --git a/app/src/main/java/com/termux/app/TermuxPackageInstaller.java b/app/src/main/java/com/termux/app/TermuxPackageInstaller.java new file mode 100644 index 00000000..d7a529dd --- /dev/null +++ b/app/src/main/java/com/termux/app/TermuxPackageInstaller.java @@ -0,0 +1,135 @@ +package com.termux.app; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Process; +import android.system.Os; +import android.util.Log; + +import com.termux.terminal.EmulatorDebug; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; + +public class TermuxPackageInstaller extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + try { + String packageName = intent.getData().getSchemeSpecificPart(); + String action = intent.getAction(); + PackageManager packageManager = context.getPackageManager(); + + if (Intent.ACTION_PACKAGE_ADDED.equals(action)) { + ApplicationInfo info = packageManager.getApplicationInfo(packageName, 0); + if (Process.myUid() == info.uid) { + installPackage(info); + } + } else if (Intent.ACTION_PACKAGE_REMOVED.equals(action)) { + if (Process.myUid() == intent.getIntExtra(Intent.EXTRA_UID, -1)) { + uninstallPackage(packageName); + } + + } + } catch (Exception e) { + Log.e("termux", "Error in package management: " + e); + } + } + + static void installPackage(ApplicationInfo info) throws Exception { + File filesMappingFile = new File(info.nativeLibraryDir, "libfiles.so"); + if (!filesMappingFile.exists()) { + Log.e("termux", "No file mapping at " + filesMappingFile.getAbsolutePath()); + return; + } + + Log.e("termux", "Installing: " + info.packageName); + BufferedReader reader = new BufferedReader(new FileReader(filesMappingFile)); + String line; + while ((line = reader.readLine()) != null) { + String[] parts = line.split("←"); + if (parts.length != 2) { + Log.e(EmulatorDebug.LOG_TAG, "Malformed line " + line + " in " + filesMappingFile.getAbsolutePath()); + continue; + } + + String oldPath = info.nativeLibraryDir + "/" + parts[0]; + String newPath = TermuxService.PREFIX_PATH + "/" + parts[1]; + + TermuxInstaller.ensureDirectoryExists(new File(newPath).getParentFile()); + + Log.e(EmulatorDebug.LOG_TAG, "About to setup link: " + oldPath + " ← " + newPath); + new File(newPath).delete(); + Os.symlink(oldPath, newPath); + } + + File symlinksFile = new File(info.nativeLibraryDir, "libsymlinks.so"); + if (!symlinksFile.exists()) { + Log.e("termux", "No symlinks mapping at " + symlinksFile.getAbsolutePath()); + } + + reader = new BufferedReader(new FileReader(symlinksFile)); + while ((line = reader.readLine()) != null) { + String[] parts = line.split("←"); + if (parts.length != 2) { + Log.e(EmulatorDebug.LOG_TAG, "Malformed line " + line + " in " + symlinksFile.getAbsolutePath()); + continue; + } + + String oldPath = parts[0]; + String newPath = TermuxService.PREFIX_PATH + "/" + parts[1]; + + TermuxInstaller.ensureDirectoryExists(new File(newPath).getParentFile()); + + Log.e(EmulatorDebug.LOG_TAG, "About to setup link: " + oldPath + " ← " + newPath); + new File(newPath).delete(); + Os.symlink(oldPath, newPath); + } + } + + private static void uninstallPackage(String packageName) throws IOException { + Log.e("termux", "Uninstalling: " + packageName); + // We're currently visiting the whole $PREFIX. + // If we store installed symlinks in installPackage() we could just visit those, + // at the cost of increased complexity and risk for errors. + File prefixDir = new File(TermuxService.PREFIX_PATH); + removeBrokenSymlinks(prefixDir); + } + + private static void removeBrokenSymlinks(File parentDir) throws IOException { + File[] children = parentDir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (!child.exists()) { + Log.e("termux", "Removing broken symlink: " + child.getAbsolutePath()); + child.delete(); + } else if (child.isDirectory()) { + removeBrokenSymlinks(child); + } + } + } + + public static void setupAllInstalledPackages(Context context) { + try { + removeBrokenSymlinks(new File(TermuxService.PREFIX_PATH)); + + PackageManager packageManager = context.getPackageManager(); + for (PackageInfo info : packageManager.getInstalledPackages(0)) { + if (info.sharedUserId != null && info.sharedUserId.equals("com.termux")) { + installPackage(info.applicationInfo); + } + } + } catch (Exception e) { + Log.e("termux", "Error setting up all packages", e); + } + + } +} diff --git a/app/src/main/java/com/termux/app/TermuxService.java b/app/src/main/java/com/termux/app/TermuxService.java index 955ce865..1a1cd97d 100644 --- a/app/src/main/java/com/termux/app/TermuxService.java +++ b/app/src/main/java/com/termux/app/TermuxService.java @@ -9,6 +9,7 @@ import android.app.Service; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.res.Resources; import android.net.Uri; import android.net.wifi.WifiManager; @@ -46,7 +47,9 @@ public final class TermuxService extends Service implements SessionChangedCallba private static final String NOTIFICATION_CHANNEL_ID = "termux_notification_channel"; - /** Note that this is a symlink on the Android M preview. */ + /** + * Note that this is a symlink on the Android M preview. + */ @SuppressLint("SdCardPath") public static final String FILES_PATH = "/data/data/com.termux/files"; public static final String PREFIX_PATH = FILES_PATH + "/usr"; @@ -55,9 +58,12 @@ public final class TermuxService extends Service implements SessionChangedCallba private static final int NOTIFICATION_ID = 1337; private static final String ACTION_STOP_SERVICE = "com.termux.service_stop"; + private static final String ACTION_LOCK_WAKE = "com.termux.service_wake_lock"; private static final String ACTION_UNLOCK_WAKE = "com.termux.service_wake_unlock"; - /** Intent action to launch a new terminal session. Executed from TermuxWidgetProvider. */ + /** + * Intent action to launch a new terminal session. Executed from TermuxWidgetProvider. + */ public static final String ACTION_EXECUTE = "com.termux.service_execute"; public static final String EXTRA_ARGUMENTS = "com.termux.execute.arguments"; @@ -65,7 +71,17 @@ public final class TermuxService extends Service implements SessionChangedCallba public static final String EXTRA_CURRENT_WORKING_DIRECTORY = "com.termux.execute.cwd"; public static final String EXTRA_EXECUTE_IN_BACKGROUND = "com.termux.execute.background"; - /** This service is only bound from inside the same process and never uses IPC. */ + /* + * APK service intents + * */ + + private static final String ACTION_INSTALL_PACKAGES = "com.termux.install_packages"; + private static final String ACTION_LIST_PACKAGES = "com.termux.list_packages"; + private static final String ACTION_UNINSTALL_PACKAGES = "com.termux.uninstall_packages"; + + /** + * This service is only bound from inside the same process and never uses IPC. + */ class LocalBinder extends Binder { public final TermuxService service = TermuxService.this; } @@ -84,16 +100,25 @@ public final class TermuxService extends Service implements SessionChangedCallba final List mBackgroundTasks = new ArrayList<>(); - /** Note that the service may often outlive the activity, so need to clear this reference. */ + /** + * Note that the service may often outlive the activity, so need to clear this reference. + */ SessionChangedCallback mSessionChangeCallback; - /** The wake lock and wifi lock are always acquired and released together. */ + /** + * The wake lock and wifi lock are always acquired and released together. + */ private PowerManager.WakeLock mWakeLock; private WifiManager.WifiLock mWifiLock; - /** If the user has executed the {@link #ACTION_STOP_SERVICE} intent. */ + + /** + * If the user has executed the {@link #ACTION_STOP_SERVICE} intent. + */ boolean mWantsToStop = false; + private final TermuxPackageInstaller packageInstaller = new TermuxPackageInstaller(); + @SuppressLint("Wakelock") @Override public int onStartCommand(Intent intent, int flags, int startId) { @@ -103,6 +128,25 @@ public final class TermuxService extends Service implements SessionChangedCallba for (int i = 0; i < mTerminalSessions.size(); i++) mTerminalSessions.get(i).finishIfRunning(); stopSelf(); + } else if (ACTION_INSTALL_PACKAGES.equals(action)) { + String[] packages = intent.getStringArrayExtra("packages"); + if (packages == null || packages.length == 0) { + Log.e(EmulatorDebug.LOG_TAG, ACTION_INSTALL_PACKAGES + " called without packages"); + } else { + PackageInstaller downloaderTest = new PackageInstaller(this); + downloaderTest.initDownloader(packages); + } + } else if (ACTION_LIST_PACKAGES.equals(action)) { + PackageLister packageLister = new PackageLister(this); + packageLister.listPackages(); + } else if (ACTION_UNINSTALL_PACKAGES.equals(action)) { + String[] packages = intent.getStringArrayExtra("packages"); + if (packages == null || packages.length == 0) { + Log.e(EmulatorDebug.LOG_TAG, ACTION_INSTALL_PACKAGES + " called without packages"); + } else { + PackageUninstaller packageUninstaller = new PackageUninstaller(this); + packageUninstaller.uninstallPackages(packages); + } } else if (ACTION_LOCK_WAKE.equals(action)) { if (mWakeLock == null) { PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); @@ -185,11 +229,21 @@ public final class TermuxService extends Service implements SessionChangedCallba @Override public void onCreate() { + TermuxPackageInstaller.setupAllInstalledPackages(this); setupNotificationChannel(); startForeground(NOTIFICATION_ID, buildNotification()); + + IntentFilter addedFilter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED); + addedFilter.addDataScheme("package"); + IntentFilter removedFilter = new IntentFilter(Intent.ACTION_PACKAGE_REMOVED); + removedFilter.addDataScheme("package"); + this.registerReceiver(packageInstaller, addedFilter); + this.registerReceiver(packageInstaller, removedFilter); } - /** Update the shown foreground service notification after making any changes that affect it. */ + /** + * Update the shown foreground service notification after making any changes that affect it. + */ void updateNotification() { if (mWakeLock == null && mTerminalSessions.isEmpty() && mBackgroundTasks.isEmpty()) { // Exit if we are updating after the user disabled all locks with no sessions or tasks running. @@ -254,6 +308,8 @@ public final class TermuxService extends Service implements SessionChangedCallba @Override public void onDestroy() { + unregisterReceiver(packageInstaller); + File termuxTmpDir = new File(TermuxService.PREFIX_PATH + "/tmp"); if (termuxTmpDir.exists()) { @@ -313,7 +369,8 @@ public final class TermuxService extends Service implements SessionChangedCallba String[] args = new String[processArgs.length]; args[0] = processName; - if (processArgs.length > 1) System.arraycopy(processArgs, 1, args, 1, processArgs.length - 1); + if (processArgs.length > 1) + System.arraycopy(processArgs, 1, args, 1, processArgs.length - 1); TerminalSession session = new TerminalSession(executablePath, cwd, args, env, this); mTerminalSessions.add(session); @@ -385,7 +442,7 @@ public final class TermuxService extends Service implements SessionChangedCallba String channelDescription = "Notifications from Termux"; int importance = NotificationManager.IMPORTANCE_LOW; - NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName,importance); + NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, importance); channel.setDescription(channelDescription); NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); manager.createNotificationChannel(channel); diff --git a/build.gradle b/build.gradle index d803478d..04e7cb64 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,14 @@ + + buildscript { + ext.kotlin_version = '1.4.0' repositories { jcenter() google() } dependencies { classpath 'com.android.tools.build:gradle:4.0.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/gradle.properties b/gradle.properties index 945ab5d2..247ae722 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,6 +16,6 @@ org.gradle.jvmargs=-Xmx2048M android.useAndroidX=true minSdkVersion=24 -targetSdkVersion=28 +targetSdkVersion=29 ndkVersion=21.3.6528147 -compileSdkVersion=28 +compileSdkVersion=29 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bb8b2fc2..43bbf04d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Sat Aug 15 22:30:46 IST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-all.zip diff --git a/pkg.sh b/pkg.sh new file mode 100644 index 00000000..5f22e2b1 --- /dev/null +++ b/pkg.sh @@ -0,0 +1,68 @@ +#!/data/data/com.termux/files/usr/bin/bash +set -e -u + +# The services can be tested without installing pkg by setting the export flag of Termux Service in the manifest and using adb am +# Example commands - +# adb shell am startservice --user 0 --esa packages openssh,vim -a com.termux.install_packages com.termux/com.termux.app.TermuxService +# adb shell am startservice --user 0 --esa packages openssh,vim -a com.termux.uninstall_packages com.termux/com.termux.app.TermuxService +# adb shell am startservice --user 0 -a com.termux.list_packages com.termux/com.termux.app.TermuxService + +show_help() { + echo 'Usage: pkg command [arguments]' + echo + echo 'A tool for managing packages. Commands:' + echo + echo ' install - Install specified packages' + echo ' uninstall - Uninstall specified packages' + echo ' list-installed - List installed packages' + echo +} + +if [ $# = 0 ]; then + show_help + exit 1 +fi + +CMD="$1" +shift 1 + +install_packages() { + local all_packages="$*" + + am startservice \ + --user 0 \ + --esa packages "${all_packages// /,}" \ + -a com.termux.install_packages \ + com.termux/com.termux.app.TermuxService \ + >/dev/null +} + +uninstall_packages() { + local all_packages="$*" + + am startservice \ + --user 0 \ + --esa packages "${all_packages// /,}" \ + -a com.termux.uninstall_packages \ + com.termux/com.termux.app.TermuxService \ + >/dev/null +} + +list_packages() { + am startservice \ + --user 0 \ + -a com.termux.list_packages \ + com.termux/com.termux.app.TermuxService \ + >/dev/null +} + +case "$CMD" in + help) show_help;; + install) install_packages "$@";; + uninstall) uninstall_packages "$@";; + list-installed) list_packages;; + *) + echo "Unknown command: '$CMD' (run 'pkg help' for usage information)" + exit 1 + ;; +esac