Bläddra i källkod

Add split APKs installer

世界 3 år sedan
förälder
incheckning
6c63e945ac

+ 134 - 1
app/src/main/AndroidManifest.xml

@@ -21,6 +21,7 @@
     <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
     <uses-permission android:name="android.permission.WAKE_LOCK" />
+    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
 
     <uses-permission
         android:name="android.permission.QUERY_ALL_PACKAGES"
@@ -228,7 +229,7 @@
             android:launchMode="singleTask"
             android:process=":bg"
             android:taskAffinity=""
-            android:theme="@android:style/Theme.Translucent.NoTitleBar">
+            android:theme="@style/Theme.SagerNet.Translucent">
             <intent-filter>
                 <action android:name="android.intent.action.CREATE_SHORTCUT" />
             </intent-filter>
@@ -361,12 +362,144 @@
 
         <activity
             android:name="io.nekohasekai.sagernet.tasker.TaskerActivity"
+            android:configChanges="uiMode"
+            android:excludeFromRecents="true"
             android:exported="true">
             <intent-filter>
                 <action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
             </intent-filter>
         </activity>
 
+        <activity
+            android:name="io.nekohasekai.sagernet.ui.sai.SplitAPKsInstallerActivity"
+            android:excludeFromRecents="true"
+            android:exported="true"
+            android:label="@string/sai"
+            android:theme="@style/Theme.SagerNet.Translucent">
+
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\.apks"
+                    android:scheme="file" />
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\.apks"
+                    android:scheme="file" />
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\.apks"
+                    android:scheme="file" />
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\.apks"
+                    android:scheme="file" />
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="file" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="file" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="file" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="file" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="file" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="file" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\.apks"
+                    android:scheme="content" />
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\.apks"
+                    android:scheme="content" />
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\.apks"
+                    android:scheme="content" />
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\.apks"
+                    android:scheme="content" />
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="content" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="content" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="content" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="content" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="content" />
+
+                <data
+                    android:host="*"
+                    android:mimeType="*/*"
+                    android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.apks"
+                    android:scheme="content" />
+            </intent-filter>
+        </activity>
+
+        <service
+            android:name=".ui.sai.SplitAPKsInstallerService"
+            android:exported="false" />
 
     </application>
 

+ 1 - 1
app/src/main/java/io/nekohasekai/sagernet/ui/SwitchActivity.kt

@@ -29,7 +29,7 @@ import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
 class SwitchActivity : ThemedActivity(R.layout.layout_empty),
     ConfigurationFragment.SelectCallback {
 
-    override val isDialog = true
+    override val type = Type.Dialog
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)

+ 19 - 7
app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt

@@ -33,20 +33,31 @@ abstract class ThemedActivity : AppCompatActivity {
     constructor() : super()
     constructor(contentLayoutId: Int) : super(contentLayoutId)
 
+    enum class Type {
+        Default,
+        Dialog,
+        Translucent
+    }
+
+    open val type = Type.Default
+
     var themeResId = 0
     var uiMode = 0
-    open val isDialog = false
 
     override fun onCreate(savedInstanceState: Bundle?) {
-        if (!isDialog) {
-            Theme.apply(this)
-        } else {
-            Theme.applyDialog(this)
+        if (type != Type.Translucent) {
+            when (type) {
+                Type.Default -> {
+                    Theme.apply(this)
+                }
+                Type.Dialog -> {
+                    Theme.applyDialog(this)
+                }
+            }
+            Theme.applyNightTheme()
         }
-        Theme.applyNightTheme()
 
         super.onCreate(savedInstanceState)
-
         uiMode = resources.configuration.uiMode
     }
 
@@ -76,6 +87,7 @@ abstract class ThemedActivity : AppCompatActivity {
             maxLines = 10
         }
     }
+
     internal open fun snackbarInternal(text: CharSequence): Snackbar = throw NotImplementedError()
 
 }

+ 147 - 0
app/src/main/java/io/nekohasekai/sagernet/ui/sai/SplitAPKsInstallerActivity.kt

@@ -0,0 +1,147 @@
+/******************************************************************************
+ * Copyright (C) 2022 by nekohasekai <[email protected]>                  *
+ *                                                                            *
+ * 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 <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package io.nekohasekai.sagernet.ui.sai
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.ContentResolver
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import androidx.documentfile.provider.DocumentFile
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.ktx.Logs
+import io.nekohasekai.sagernet.ktx.readableMessage
+import io.nekohasekai.sagernet.ui.ThemedActivity
+import java.io.File
+import java.io.InputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+
+class SplitAPKsInstallerActivity : ThemedActivity() {
+
+    override val type = Type.Translucent
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        val uri = intent.data
+        if (uri == null) {
+            finish()
+            return
+        }
+        handleUri(uri)
+    }
+
+    fun handleUri(contentUri: Uri) {
+        val document = getDocument(contentUri)
+        if (document == null) {
+            returnError("Failed to get document")
+            return
+        }
+        val documentName = document.name
+        if (documentName == null) {
+            returnError("Failed to get document name")
+            return
+        }
+        Logs.d("Install $documentName")
+        val extension = File(documentName).extension
+
+        if (extension != "apks") {
+            returnError("Not a split apks file")
+            return
+        }
+        runCatching {
+            contentResolver.openInputStream(contentUri)!!.use {
+                handleInputStream(it)
+            }
+            /* }.recoverCatching { ex ->
+                 if (ex.message == "only DEFLATED entries can have EXT descriptor") {
+                     contentResolver.openInputStream(contentUri)!!.use {
+                         copyAndHandleInputStream(it)
+                     }
+                 } else throw ex*/
+        }.onFailure {
+            Logs.w(it)
+            returnError(it.readableMessage)
+        }
+    }
+
+    fun returnError(message: String) {
+        MaterialAlertDialogBuilder(this).setTitle(R.string.error_title)
+            .setMessage(message)
+            .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
+            .show()
+    }
+
+    fun handleInputStream(inputStream: InputStream) {
+        val packageInstaller = packageManager.packageInstaller
+        val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            sessionParams.setInstallReason(PackageManager.INSTALL_REASON_USER)
+        }
+        val sessionId = try {
+            packageInstaller.createSession(sessionParams)
+        } catch (e: IllegalStateException) {
+            packageInstaller.mySessions.forEach {
+                packageInstaller.abandonSession(it.sessionId)
+            }
+            packageInstaller.createSession(sessionParams)
+        }
+        Logs.d("Create install session $sessionId")
+        val session = packageInstaller.openSession(sessionId)
+        ZipInputStream(inputStream).use { zip ->
+            var currentZipEntry: ZipEntry
+            while (true) {
+                currentZipEntry = zip.nextEntry ?: break
+                if (currentZipEntry.isDirectory) continue
+                if (!currentZipEntry.name.lowercase().endsWith(".apk")) continue
+                Logs.d("Write ${currentZipEntry.name}")
+                session.openWrite(currentZipEntry.name, 0, currentZipEntry.size).use {
+                    zip.copyTo(it)
+                    session.fsync(it)
+                }
+            }
+        }
+        @SuppressLint("UnspecifiedImmutableFlag") val intent = PendingIntent.getService(
+            applicationContext,
+            0,
+            Intent(applicationContext, SplitAPKsInstallerService::class.java),
+            0
+        )
+        session.commit(intent.intentSender)
+        Logs.d("Commit session")
+        session.close()
+        finish()
+    }
+
+    fun getDocument(contentUri: Uri): DocumentFile? {
+        return if (ContentResolver.SCHEME_FILE == contentUri.scheme) {
+            val path: String = contentUri.path ?: return null
+            val file = File(path)
+            if (file.isDirectory) null else DocumentFile.fromFile(file)
+        } else {
+            DocumentFile.fromSingleUri(this, contentUri)
+        }
+    }
+}

+ 85 - 0
app/src/main/java/io/nekohasekai/sagernet/ui/sai/SplitAPKsInstallerService.kt

@@ -0,0 +1,85 @@
+/******************************************************************************
+ * Copyright (C) 2022 by nekohasekai <[email protected]>                  *
+ *                                                                            *
+ * 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 <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package io.nekohasekai.sagernet.ui.sai
+
+import android.app.Service
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.IBinder
+import android.widget.Toast
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.ktx.Logs
+
+class SplitAPKsInstallerService : Service() {
+
+    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+        Logs.d("Incoming $intent")
+        when (val code = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -10)) {
+            -10 -> {
+                Logs.d("Bad intent: $intent")
+            }
+            PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+                val userIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
+                userIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+                startActivity(userIntent)
+            }
+            PackageInstaller.STATUS_SUCCESS -> {
+                Toast.makeText(applicationContext, R.string.sai_succeed, Toast.LENGTH_LONG).show()
+            }
+            else -> {
+                when (code) {
+                    PackageInstaller.STATUS_FAILURE -> {
+                        Toast.makeText(applicationContext, R.string.sai_error_generic, Toast.LENGTH_LONG).show()
+                    }
+                    PackageInstaller.STATUS_FAILURE_BLOCKED -> {
+                        val blockedBy = intent.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME) ?: getString(R.string.sai_error_blocked_device)
+                        Toast.makeText(applicationContext, getString(R.string.sai_error_blocked, blockedBy), Toast.LENGTH_LONG).show()
+                    }
+                    PackageInstaller.STATUS_FAILURE_ABORTED -> {
+                        Toast.makeText(applicationContext, R.string.sai_error_aborted, Toast.LENGTH_LONG).show()
+                    }
+                    PackageInstaller.STATUS_FAILURE_INVALID -> {
+                        Toast.makeText(applicationContext, R.string.sai_error_invalid, Toast.LENGTH_LONG).show()
+                    }
+                    PackageInstaller.STATUS_FAILURE_CONFLICT -> {
+                        Toast.makeText(applicationContext, R.string.sai_error_conflict, Toast.LENGTH_LONG).show()
+                    }
+                    PackageInstaller.STATUS_FAILURE_STORAGE -> {
+                        Toast.makeText(applicationContext, R.string.sai_error_storage, Toast.LENGTH_LONG).show()
+                    }
+                    PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> {
+                        Toast.makeText(applicationContext, R.string.sai_error_incompatible, Toast.LENGTH_LONG).show()
+                    }
+                }
+                val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
+                if (sessionId != -1) {
+                    try {
+                        packageManager.packageInstaller.abandonSession(sessionId)
+                    } catch (ignored: SecurityException) {
+                    }
+                }
+            }
+        }
+        stopSelf()
+        return START_NOT_STICKY
+    }
+
+    override fun onBind(intent: Intent?): IBinder? = null
+
+}

+ 10 - 0
app/src/main/res/values/strings.xml

@@ -513,5 +513,15 @@
     <string name="acquire_wake_lock_summary">Keep the CPU on</string>
     <string name="action_switch">Switch</string>
     <string name="disconnect">Disconnect</string>
+    <string name="sai">Split APKs Installer</string>
+    <string name="sai_succeed">Installation succeeded</string>
+    <string name="sai_error_generic">Installation failed</string>
+    <string name="sai_error_aborted">Installation was cancelled by user</string>
+    <string name="sai_error_blocked">Installation was blocked by %s</string>
+    <string name="sai_error_blocked_device">device</string>
+    <string name="sai_error_conflict">Unable to install the app because it conflicts with an already installed app with same package name</string>
+    <string name="sai_error_incompatible">Application is incompatible with this device</string>
+    <string name="sai_error_invalid">Invalid APKs</string>
+    <string name="sai_error_storage">Not enough storage space to install the app</string>
 
 </resources>

+ 9 - 0
app/src/main/res/values/themes.xml

@@ -48,6 +48,15 @@
 
     </style>
 
+    <style name="Theme.SagerNet.Translucent">
+        <item name="android:background">#33000000</item>
+        <item name="android:windowNoTitle">true</item>
+        <item name="android:windowBackground">@android:color/transparent</item>
+        <item name="android:colorBackgroundCacheHint">@null</item>
+        <item name="android:windowIsTranslucent">true</item>
+        <item name="android:windowAnimationStyle">@android:style/Animation</item>
+    </style>
+
     <style name="Theme.SagerNet.Dialog" parent="Theme.MaterialComponents.DayNight.Dialog.Alert">
         <item name="actionBarStyle">@style/Widget.MaterialComponents.ActionBar.Solid</item>
         <item name="actionModeCloseDrawable">@drawable/ic_navigation_close</item>