diff --git a/app/build.gradle b/app/build.gradle index 3c647b58d76e74d92c31f17f8d8d3d8a8e9b6a54..108622ac77631851a5e4dd18fda725e1bcc54760 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -110,7 +110,10 @@ allOpen { dependencies { - api "com.gitlab.AuroraOSS:gplayapi:0e224071f3" + // TODO: Add splitinstall-lib to a repo https://gitlab.e.foundation/e/os/backlog/-/issues/628 + api files('libs/splitinstall-lib.jar') + + implementation 'foundation.e:gplayapi:3.0.1' implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'com.google.android.material:material:1.5.0' @@ -181,6 +184,7 @@ dependencies { def lifecycle_version = "2.4.0" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + implementation "android.arch.lifecycle:extensions:1.1.1" // Coroutines def coroutines_version = "1.6.0" diff --git a/app/libs/splitinstall-lib.jar b/app/libs/splitinstall-lib.jar new file mode 100644 index 0000000000000000000000000000000000000000..a18b464e1ce836e121e24e1be57a9fbdbd5a9c43 Binary files /dev/null and b/app/libs/splitinstall-lib.jar differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 05e9d66c5812295a2a363721d41f667e5a8e12af..c6155ab22b06ab8bdabe8a83d0e2a94e6491f6b9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -26,7 +27,6 @@ tools:ignore="ProtectedPermissions" /> - + + \ No newline at end of file diff --git a/app/src/main/aidl/foundation/e/apps/ISplitInstallService.aidl b/app/src/main/aidl/foundation/e/apps/ISplitInstallService.aidl new file mode 100644 index 0000000000000000000000000000000000000000..bf327d45daffb03ea6941486f1b453c5c3ca5722 --- /dev/null +++ b/app/src/main/aidl/foundation/e/apps/ISplitInstallService.aidl @@ -0,0 +1,6 @@ +package foundation.e.apps; + +interface ISplitInstallService { + + void installSplitModule(String packageName, String moduleName); +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/apps/api/DownloadManager.kt b/app/src/main/java/foundation/e/apps/api/DownloadManager.kt index 85b1ba5525f8e08f7740220e916803e080b44820..c9a29d489ea5b88d531c82d95e90cc19dc8a46b4 100644 --- a/app/src/main/java/foundation/e/apps/api/DownloadManager.kt +++ b/app/src/main/java/foundation/e/apps/api/DownloadManager.kt @@ -39,7 +39,11 @@ class DownloadManager @Inject constructor( @Named("cacheDir") private val cacheDir: String, private val downloadManagerQuery: DownloadManager.Query, ) { - private var isDownloading = false + private val downloadsMaps = HashMap() + + companion object { + const val EXTERNAL_STORAGE_TEMP_CACHE_DIR = "/sdcard/Download/AppLounge/SplitInstallApks" + } fun downloadFileInCache( url: String, @@ -47,17 +51,44 @@ class DownloadManager @Inject constructor( fileName: String, downloadCompleted: ((Boolean, String) -> Unit)? ): Long { - val directoryFile = File(cacheDir + subDirectoryPath) + val directoryFile = File("$cacheDir/$subDirectoryPath") + if (!directoryFile.exists()) { + directoryFile.mkdirs() + } + val downloadFile = File("$cacheDir/$fileName") + + return downloadFile(url, downloadFile, downloadCompleted) + } + + fun downloadFileInExternalStorage( + url: String, + subDirectoryPath: String, + fileName: String, + downloadCompleted: ((Boolean, String) -> Unit)? + ): Long { + + val directoryFile = File("$EXTERNAL_STORAGE_TEMP_CACHE_DIR/$subDirectoryPath") if (!directoryFile.exists()) { directoryFile.mkdirs() } + + val downloadFile = File("$directoryFile/$fileName") + + return downloadFile(url, downloadFile, downloadCompleted) + } + + private fun downloadFile( + url: String, + downloadFile: File, + downloadCompleted: ((Boolean, String) -> Unit)? + ): Long { val request = DownloadManager.Request(Uri.parse(url)) .setTitle("Downloading...") .setDestinationUri(Uri.fromFile(downloadFile)) val downloadId = downloadManager.enqueue(request) - isDownloading = true - tickerFlow(.5.seconds).onEach { + downloadsMaps[downloadId] = true + tickerFlow(downloadId, .5.seconds).onEach { checkDownloadProgress(downloadId, downloadFile.absolutePath, downloadCompleted) }.launchIn(CoroutineScope(Dispatchers.IO)) return downloadId @@ -82,11 +113,11 @@ class DownloadManager @Inject constructor( cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) if (status == DownloadManager.STATUS_FAILED) { Timber.d("Download Failed: $filePath=> $bytesDownloadedSoFar/$totalSizeBytes $status") - isDownloading = false + downloadsMaps[downloadId] = false downloadCompleted?.invoke(false, filePath) } else if (status == DownloadManager.STATUS_SUCCESSFUL) { Timber.d("Download Successful: $filePath=> $bytesDownloadedSoFar/$totalSizeBytes $status") - isDownloading = false + downloadsMaps[downloadId] = false downloadCompleted?.invoke(true, filePath) } } @@ -94,11 +125,12 @@ class DownloadManager @Inject constructor( } catch (e: Exception) { Timber.e(e) } + } - private fun tickerFlow(period: Duration, initialDelay: Duration = Duration.ZERO) = flow { + private fun tickerFlow(downloadId: Long, period: Duration, initialDelay: Duration = Duration.ZERO) = flow { delay(initialDelay) - while (isDownloading) { + while (downloadsMaps[downloadId]!!) { emit(Unit) delay(period) } diff --git a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt index 226c26de80d7952d2cd7a72775306a9421a13951..13cd0111013e21680316f38a77c3e07b407f2073 100644 --- a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt +++ b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt @@ -391,6 +391,23 @@ class FusedAPIImpl @Inject constructor( return gPlayAPIRepository.validateAuthData(authData) } + suspend fun getOnDemandModule( + authData: AuthData, + packageName: String, + moduleName: String, + versionCode: Int, + offerType: Int + ) : String? { + val list = gPlayAPIRepository.getOnDemandModule(packageName, moduleName, versionCode, offerType, authData) + for (element in list) { + if (element.name == "$moduleName.apk") { + return element.url + } + } + return null + } + + suspend fun updateFusedDownloadWithDownloadingInfo( authData: AuthData, origin: Origin, diff --git a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt index 6502866f94e414ec3b295fc4fb8e6632f8e20dc7..232af31fa0b2f174b7a5afc8d8f4e17b6d13d929 100644 --- a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt +++ b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt @@ -105,6 +105,16 @@ class FusedAPIRepository @Inject constructor( ) } + suspend fun getOnDemandModule( + authData: AuthData, + packageName: String, + moduleName: String, + versionCode: Int, + offerType: Int + ) : String? { + return fusedAPIImpl.getOnDemandModule(authData, packageName, moduleName, versionCode, offerType) + } + suspend fun getCategoriesList( type: Category.Type, authData: AuthData diff --git a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt index 54a8341fc67da0d9c0f8833b4f7da0ebdab4e116..6b40827c13ed7261f4e83d94dbe5051058a7dfa5 100644 --- a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt +++ b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt @@ -158,6 +158,21 @@ class GPlayAPIImpl @Inject constructor( return downloadData } + suspend fun getOnDemandModule( + packageName: String, + moduleName: String, + versionCode: Int, + offerType: Int, + authData: AuthData + ) : List { + val downloadData = mutableListOf() + withContext(Dispatchers.IO) { + val purchaseHelper = PurchaseHelper(authData).using(gPlayHttpClient) + downloadData.addAll(purchaseHelper.getOnDemandModule(packageName, moduleName, versionCode, offerType)) + } + return downloadData + } + suspend fun getAppDetails(packageName: String, authData: AuthData): App? { var appDetails: App? withContext(Dispatchers.IO) { diff --git a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt index bfb08b876772180dbe9a911d56afdbfd7995f56f..ca31e917000e3c50382e752eacff2393c20a816e 100644 --- a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt +++ b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt @@ -54,6 +54,16 @@ class GPlayAPIRepository @Inject constructor( return gPlayAPIImpl.getSearchResults(query, authData) } + suspend fun getOnDemandModule( + packageName: String, + moduleName: String, + versionCode: Int, + offerType: Int, + authData: AuthData + ) : List { + return gPlayAPIImpl.getOnDemandModule(packageName, moduleName, versionCode, offerType, authData) + } + suspend fun getDownloadInfo( packageName: String, versionCode: Int, diff --git a/app/src/main/java/foundation/e/apps/manager/fused/FusedManagerImpl.kt b/app/src/main/java/foundation/e/apps/manager/fused/FusedManagerImpl.kt index cc4780ba8ad8d6aebcd2bbd0899b64b378fa63b1..5df157e8c80f5ed66da394af1dd9ea31b0a8f975 100644 --- a/app/src/main/java/foundation/e/apps/manager/fused/FusedManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/manager/fused/FusedManagerImpl.kt @@ -120,6 +120,7 @@ class FusedManagerImpl @Inject constructor( val parentPathFile = File("$cacheDir/${fusedDownload.packageName}") parentPathFile.listFiles()?.let { list.addAll(it) } list.sort() + if (list.size != 0) { try { Timber.d("installApp: STARTED ${fusedDownload.name} ${list.size}") diff --git a/app/src/main/java/foundation/e/apps/manager/pkg/PkgManagerModule.kt b/app/src/main/java/foundation/e/apps/manager/pkg/PkgManagerModule.kt index a743d7496c2f17bf15659c8bb194e336e1a49874..ad411ac0a4e79d1e065d45f3b5f3957df2db5e85 100644 --- a/app/src/main/java/foundation/e/apps/manager/pkg/PkgManagerModule.kt +++ b/app/src/main/java/foundation/e/apps/manager/pkg/PkgManagerModule.kt @@ -24,7 +24,8 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo -import android.content.pm.PackageInstaller +import android.content.pm.PackageInstaller.Session +import android.content.pm.PackageInstaller.SessionParams import android.content.pm.PackageManager import android.os.Build import androidx.core.content.pm.PackageInfoCompat @@ -134,29 +135,13 @@ class PkgManagerModule @Inject constructor( @OptIn(DelicateCoroutinesApi::class) fun installApplication(list: List, packageName: String) { - val packageInstaller = packageManager.packageInstaller - val params = PackageInstaller - .SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) - .apply { - setAppPackageName(packageName) - setOriginatingUid(android.os.Process.myUid()) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) - } - } + val sessionId = createInstallSession(packageName, SessionParams.MODE_FULL_INSTALL) + val session = packageManager.packageInstaller.openSession(sessionId) - // Open a new specific session - val sessionId = packageInstaller.createSession(params) - val session = packageInstaller.openSession(sessionId) try { // Install the package using the provided stream list.forEach { - val inputStream = it.inputStream() - val outputStream = session.openWrite(it.nameWithoutExtension, 0, -1) - inputStream.copyTo(outputStream) - session.fsync(outputStream) - inputStream.close() - outputStream.close() + syncFile(session, it) } val callBackIntent = Intent(context, InstallerService::class.java) @@ -188,14 +173,37 @@ class PkgManagerModule @Inject constructor( } } + private fun createInstallSession(packageName: String, mode: Int): Int { + + val packageInstaller = packageManager.packageInstaller + val params = SessionParams(mode).apply { + setAppPackageName(packageName) + setOriginatingUid(android.os.Process.myUid()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED) + } + } + + return packageInstaller.createSession(params) + } + + private fun syncFile(session: Session, file: File) { + + val inputStream = file.inputStream() + val outputStream = session.openWrite(file.nameWithoutExtension, 0, -1) + inputStream.copyTo(outputStream) + session.fsync(outputStream) + inputStream.close() + outputStream.close() + } + /** * Un-install the given package * @param packageName Name of the package */ fun uninstallApplication(packageName: String) { val packageInstaller = packageManager.packageInstaller - val params = - PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + val params = SessionParams(SessionParams.MODE_FULL_INSTALL) val sessionId = packageInstaller.createSession(params) val pendingIntent = PendingIntent.getBroadcast( diff --git a/app/src/main/java/foundation/e/apps/splitinstall/SplitInstallBinder.kt b/app/src/main/java/foundation/e/apps/splitinstall/SplitInstallBinder.kt new file mode 100644 index 0000000000000000000000000000000000000000..e024dbf9854a841405ac6a83710edffc679caf73 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/splitinstall/SplitInstallBinder.kt @@ -0,0 +1,97 @@ +/* + * Apps Quickly and easily install Android apps onto your device! + * Copyright (C) 2021-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.splitinstall + +import android.content.Context +import com.aurora.gplayapi.data.models.AuthData +import foundation.e.apps.ISplitInstallService +import foundation.e.apps.api.DownloadManager +import foundation.e.apps.api.fused.FusedAPIRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +class SplitInstallBinder( + val context: Context, + private val coroutineScope: CoroutineScope, + val fusedAPIRepository: FusedAPIRepository, + val downloadManager: DownloadManager, + val authData: AuthData?, + private var splitInstallSystemService: foundation.e.splitinstall.ISplitInstallService? +) : ISplitInstallService.Stub() { + + private val modulesToInstall = HashMap() + + companion object { + const val TAG = "SplitInstallerBinder" + } + + override fun installSplitModule(packageName: String, moduleName: String) { + if (authData == null) { + Timber.i("No authentication data. Could not install on demand module") + return + } + + coroutineScope.launch { + downloadModule(packageName, moduleName) + } + } + + fun setService(service: foundation.e.splitinstall.ISplitInstallService) { + splitInstallSystemService = service + installPendingModules() + } + + private suspend fun downloadModule(packageName: String, moduleName: String) { + withContext(Dispatchers.IO) { + val versionCode = getPackageVersionCode(packageName) + val url = fusedAPIRepository.getOnDemandModule( + authData!!, packageName, moduleName, + versionCode, 1 + ) ?: return@withContext + + downloadManager.downloadFileInExternalStorage( + url, packageName, "$packageName.split.$moduleName.apk" + ) { success, path -> + if (success) { + Timber.i("Split module has been downloaded: $path") + if (splitInstallSystemService == null) { + Timber.i("Not connected to system service now. Adding $path to the list.") + modulesToInstall[path] = packageName + } + splitInstallSystemService?.installSplitModule(packageName, path) + } + } + } + } + + private fun getPackageVersionCode(packageName: String): Int { + val applicationInfo = context.packageManager.getPackageInfo(packageName, 0) + return applicationInfo.versionCode + } + + private fun installPendingModules() { + for (module in modulesToInstall.keys) { + val packageName = modulesToInstall[module] + splitInstallSystemService?.installSplitModule(packageName, module) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/splitinstall/SplitInstallService.kt b/app/src/main/java/foundation/e/apps/splitinstall/SplitInstallService.kt new file mode 100644 index 0000000000000000000000000000000000000000..d85534533b3f008e7ac4ad4e398cfcf53ec80658 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/splitinstall/SplitInstallService.kt @@ -0,0 +1,101 @@ +/* + * Apps Quickly and easily install Android apps onto your device! + * Copyright (C) 2021-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.splitinstall + +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import com.aurora.gplayapi.data.models.AuthData +import com.google.gson.Gson +import dagger.hilt.android.AndroidEntryPoint +import foundation.e.apps.api.DownloadManager +import foundation.e.apps.api.fused.FusedAPIRepository +import foundation.e.apps.utils.modules.DataStoreModule +import foundation.e.splitinstall.ISplitInstallService +import foundation.e.splitinstall.SplitInstall +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class SplitInstallService : LifecycleService() { + + companion object { + const val TAG = "SplitInstallService" + } + + @Inject lateinit var dataStoreModule: DataStoreModule + @Inject lateinit var fusedAPIRepository: FusedAPIRepository + @Inject lateinit var downloadManager: DownloadManager + @Inject lateinit var gson: Gson + private lateinit var binder: SplitInstallBinder + private var authData: AuthData? = null + private var splitInstallSystemService: ISplitInstallService? = null + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + splitInstallSystemService = ISplitInstallService.Stub.asInterface(service) + binder.setService(splitInstallSystemService!!) + } + + override fun onServiceDisconnected(name: ComponentName?) { + splitInstallSystemService = null + } + } + + override fun onCreate() { + super.onCreate() + + val intent = Intent().apply { + component = SplitInstall.SPLIT_INSTALL_SYSTEM_SERVICE + } + bindService(intent, serviceConnection, BIND_AUTO_CREATE) + + lifecycleScope.launch { + fetchAuthData() + } + } + + override fun onDestroy() { + splitInstallSystemService?.let { + unbindService(serviceConnection) + } + super.onDestroy() + } + + private suspend fun fetchAuthData() { + dataStoreModule.authData.collect { + authData = gson.fromJson(it, AuthData::class.java) + } + } + + override fun onBind(intent: Intent): IBinder { + binder = SplitInstallBinder( + applicationContext, + lifecycleScope, + fusedAPIRepository, + downloadManager, + authData, + splitInstallSystemService + ) + return binder + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7531c17019e96d6d4f89627aaf9568dcb3418582..ae79a32265e8ba44fd1cf8e368a65c425a12af7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -192,5 +192,4 @@ This app may not work properly! Forcing installation will allow you to download and install it, but it won\'t guarantee that it will work.<br /><br />Attempting to install unsupported apps may cause crashes or slow down the system.<br /><br />We\'re working on improving compatiblity with this application in a near future. - diff --git a/gradle.properties b/gradle.properties index 98bed167dc90ffee72b7affb37a659966b1bd114..1462a5544209e2d5ac79d3c2450af02b82fcdfbc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/settings.gradle b/settings.gradle index f50810cc5de859b98efb6cbd2d1faaa5d58bebb4..9ccfa34b954be1499ef6a267caa746d505c86f5a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,6 +4,52 @@ dependencyResolutionManagement { google() mavenCentral() maven { url 'https://jitpack.io' } + + // Gitlab repository configuration for gplayapi dependency + def ciJobToken = System.getenv("CI_JOB_TOKEN") + def ciApiV4Url = System.getenv("CI_API_V4_URL") + + if (ciJobToken != null) { + // Build on CI + maven { + url "https://gitlab.e.foundation/api/v4/projects/1269/packages/maven" + name "GitLab" + credentials(HttpHeaderCredentials) { + name = 'Job-Token' + value = ciJobToken + } + authentication { + header(HttpHeaderAuthentication) + } + } + } else { + // Build locally + // To build locally, you should set the gitLabPrivateToken variable with + // your Gitlab Private Token in your ./local.properties file: + // + // gitLabPrivateToken=YOUR_TOKEN + // + // To create a Private Token, go to: + // https://gitlab.e.foundation/e/os/gplayapi/-/settings/access_tokens + // and tick "api" as scope. + + def localProperties = new Properties() + localProperties.load(new FileInputStream(rootProject.projectDir.path + "/local.properties")) + + maven { + url "https://gitlab.e.foundation/api/v4/projects/1269/packages/maven" + name "GitLab" + + credentials(HttpHeaderCredentials) { + name = 'Private-Token' + value = localProperties['gitLabPrivateToken'] + } + authentication { + header(HttpHeaderAuthentication) + } + } + } + } } rootProject.name = "App Lounge"