diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10cfdbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/README.md b/README.md new file mode 100644 index 0000000..236f7d7 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ + +# DSU Sideloader + +A simple app made to help users easily install GSIs via DSU's Android feature. + +## Requirements +- Android 10 or higher +- Unlocked Bootloader +- Device with Dynamic Partitions +- A GSI you want to use! + +Community GSIs: https://github.com/phhusson/treble_experimentations/wiki/Generic-System-Image-%28GSI%29-list + +Google GSIs: https://developer.android.com/topic/generic-system-image/releases + +**Remember to use GSIs compatible with your architeture, vndk implementation..* + +You don't need root to use this app, however, running on non-rooted devices, requires adb (you will be prompted to run a shell script to invoke DSU installation activity) + +Rooted devices via Magisk, should be running Magisk v24 or higher, older versions may break DSU feature. + +We highly recommend using this app with Stock ROM, Custom ROMs aren't supported. + +## How to use? +1. Install app +2. When opening for the first time, you need to give read/write permission to a folder, create a new folder and allow access + + **this folder will be used to store temporary files, like extracted GSIs from compressed files)* + +3. Select a GSI to install + + **accepted formats are: gz, xz and img* + +4. You can customize installation as you want + + **like changing userdata size for dynamic system* + + **changing gsi file size is not recommended (let app do it automatically)* + +5. Tap on "Install GSI via DSU" +6. Wait until finishes! (it may take a some time) +7. Once it finishes, next step may vary: + - On rooted devices, DSU screen will appear, prompting you to confirm installation, after that, check your notifications, DSU should start installing GSI + - On non-rooted devices, you will be prompted to run a command in adb, once you run, DSU screen will appear asking you to confirm installation, after that, DSU should start installing GSI +8. Once dynamic system is ready, you can boot it through notifications + +## Other information +- DSU feature may be broken in some ROMs. +- gsid does not let you install GSIs via DSU when you have less than 40% of free storage. +- gsid checks if selected GSI size is multiple of 512 (preventing corrupted system images). +- If you have disabled/debloated system apps, make sure "Dynamic System Updates" app is not disabled. +- To use "ADB mode" on rooted device, deny root permission. + +## About DSU +DSU (Dynamic System Updates), is a feature introduced on Android 10, that let developers boot GSIs without touching current system partition, this is done by creating new partitions to hold a GSI and a separated userdata, to boot on them when desired. + +Unfortunelly, DSU depends on Dynamic Partitions (your device need to support, otherwise, won't work), and most GSIs requires unlocked bootloader to get them booting properly (since only OEM-Signed GSIs are allowed to boot on locked bootloader). + +GSIs can be installed via DSU without root access, using ADB, running some commands, you can read more about installation process here: https://developer.android.com/topic/dsu + +Once installation finishes, Android creates a persistent notification allowing you to boot into "Dynamic System" (GSI installed via DSU), and you can boot into installed GSI, without touching your system partition, or breaking the "real userdata" partition. + +After booting Dynamic System, you can try and test whatever you want, when you need to switch back to device's original system image, everything you need to do, is just, a simple reboot! + +When doing a long test, that may requires lots of reboots, this can be a pain, however, is possible to enable "sticky mode", that enforces dynamic system, instead of device's original system image, once tests are done, you can disable sticky mode and return to original system image. + +That is basically a quickly explanation about DSU, a amazing feature, like a "dual-boot" solution, limited, however, very safe (since no read-only partition will be modified, and if GSI does not boot, just a simple reboot will return you to the original device's system image). + +You can read more about DSU here: https://source.android.com/devices/tech/ota/dynamic-system-updates + +## How to enable Sticky Mode? + +Reboot to Dynamic System, and: +- use this command on adb: `adb shell gsi_tool enable` + - or from local adb shell: `gsi_tool enable` + - or from local rooted shell (eg. Termux on rooted GSI): `su -c 'gsi_tool enable'` + +When sticky mode is enabled, device will always boot into dynamic system, instead of device's original system image. + +To disable, use the same command, instead of `enable` , use `disable` + + +## Why creating this app? + +Since this process can be done without any app, using adb, why creating a app to do that? + +Well, to be honest, i think the same, however, having a app that can automate the "installation" process, and making DSUs more easier for end-user, would be a nice thing, also, i want to learn some kotlin, so, i've made this app! diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..82debf3 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + compileSdk 31 + + defaultConfig { + applicationId "vegabobo.dsusideloader" + minSdk 29 + targetSdk 31 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} +dependencies { + implementation 'org.tukaani:xz:1.9' + implementation 'com.github.topjohnwu.libsu:core:3.2.1' + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'androidx.fragment:fragment-ktx:1.4.1' + implementation 'androidx.preference:preference-ktx:1.2.0' + implementation 'com.google.android.material:material:1.5.0' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bfa5424 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/install_script.sh b/app/src/main/assets/install_script.sh new file mode 100644 index 0000000..5b11b2f --- /dev/null +++ b/app/src/main/assets/install_script.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +# Script made to launch DSU installation activity. +# Unrooted users can run this script using ADB to start +# installation of GSI via DSU. +# Values are populared by String.format + +# Prevent users running Magisk +# equal or older than 23016 +# since this version may break DSU boot +# this script was made for users running without root +# but some users may, just, well, refuse root permission +magisk_version=$(su -V) &>/dev/null + +if [ ! -z "$magisk_version" ]; then + if [ $magisk_version -lt 23016 ]; then + echo "Detected older Magisk version, please update to the latest Magisk build" + exit 1 + fi +fi + +# required prop +setprop persist.sys.fflag.override.settings_dynamic_system true + +# invoke DSU activity +am start-activity -n com.android.dynsystem/com.android.dynsystem.VerificationActivity \ + -a android.os.image.action.START_INSTALL \ + -d %s \ + --el KEY_SYSTEM_SIZE %s \ + --el KEY_USERDATA_SIZE %s + +# if debug mode == log it (greping for gsid and dynsys) +# else delete installation file +debug_mode=%s +if [ $debug_mode == true ]; then + logcat -c + echo "" > /sdcard/dsu_sideloader_logs.txt + (logcat | grep -e gsid -e dynsys) | tee /sdcard/dsu_sideloader_logs.txt +else + rm '%s' +fi diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..8c2ed67 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/vegabobo/dsusideloader/AboutActivity.kt b/app/src/main/java/vegabobo/dsusideloader/AboutActivity.kt new file mode 100644 index 0000000..b0c0e4b --- /dev/null +++ b/app/src/main/java/vegabobo/dsusideloader/AboutActivity.kt @@ -0,0 +1,44 @@ +package vegabobo.dsusideloader + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity + +class AboutActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_about) + findViewById(R.id.tv_version).text = + getString(R.string.version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) + } + + private fun launchUrlIntent(url: String) { + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(url) + startActivity(i) + } + + fun btnGitHubSource(view: View) { + launchUrlIntent("https://github.com/VegaBobo/DSU-Sideloader") + } + + fun btnAuthorLink(view: View) { + launchUrlIntent("https://github.com/VegaBobo/") + } + + fun btnXzUtils(view: View) { + launchUrlIntent("https://tukaani.org/xz/") + } + + fun btnLibsu(view: View) { + launchUrlIntent("https://github.com/topjohnwu/libsu") + } + + fun btnGoogle(view: View) { + launchUrlIntent("https://developer.android.com/") + } + +} \ No newline at end of file diff --git a/app/src/main/java/vegabobo/dsusideloader/HomeFragment.kt b/app/src/main/java/vegabobo/dsusideloader/HomeFragment.kt new file mode 100644 index 0000000..7993149 --- /dev/null +++ b/app/src/main/java/vegabobo/dsusideloader/HomeFragment.kt @@ -0,0 +1,335 @@ +package vegabobo.dsusideloader + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Environment +import android.os.StatFs +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.LinearLayout +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textview.MaterialTextView +import vegabobo.dsusideloader.checks.CompatibilityCheck +import vegabobo.dsusideloader.checks.OperationMode +import vegabobo.dsusideloader.dsuhelper.GsiDsuObject +import vegabobo.dsusideloader.dsuhelper.PrepareDsu +import vegabobo.dsusideloader.util.SPUtils +import vegabobo.dsusideloader.util.SetupStorageAccess +import vegabobo.dsusideloader.util.WorkspaceFilesUtils +import kotlin.math.roundToInt + +class HomeFragment : Fragment() { + + private val gsiDsuObject = GsiDsuObject() + var selectedGsi: Uri = Uri.EMPTY + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (isEnvCompatible(true)) + SetupStorageAccess(requireContext()) + + // gsid refuses to start installation when < 40% free storage + // prevent user from using app on this circumstances + if (!hasAvailableStorage()) + showNoAvaiableStorageDialog() + + gsiDsuObject.userdataSize = SPUtils.getUserdataSize(requireActivity()) + + val edGsiPath = requireView().findViewById(R.id.ed_gsi_path) + val btnInstall = requireView().findViewById(R.id.btn_install) + val btnIncrease = requireView().findViewById(R.id.bt_increase) + val btnDecrease = requireView().findViewById(R.id.btn_decrease) + val cbDSsize = requireView().findViewById(R.id.cb_ds_size) + val edDSsize = requireView().findViewById(R.id.ed_ds_size) + val cbGSIsize = requireView().findViewById(R.id.cb_gsi_size) + val edGSIsize = requireView().findViewById(R.id.ed_gsi_size) + val tc = requireView().findViewById(R.id.tv_defaultuserdata) + val txDebugBuildInfo = requireView().findViewById(R.id.text_debugbuild) + + if(BuildConfig.DEBUG) { + txDebugBuildInfo.visibility = View.VISIBLE + txDebugBuildInfo.text = getString(R.string.debug_build_info, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) + } + + tc.text = getString(R.string.default_userdata_help, gsiDsuObject.userdataSize) + + edDSsize.setText(getString(R.string.gigabyte_holder, gsiDsuObject.userdataSize)) + + val fileSelection = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uri = result.data!!.data + selectedGsi = uri!! + btnInstall.isEnabled = true + edGsiPath.setText(uri.lastPathSegment.toString()) + btnInstall.setTextColor( + ContextCompat.getColor( + requireActivity(), + R.color.white_alpha + ) + ) + btnInstall.setIconTintResource(R.color.white_alpha) + } + } + + edGsiPath.setOnClickListener { + var chooseFile = Intent(Intent.ACTION_GET_CONTENT) + chooseFile.type = "*/*" + chooseFile = Intent.createChooser(chooseFile, getString(R.string.saf_choose_file)) + fileSelection.launch(chooseFile) + } + + btnIncrease.setOnClickListener { + gsiDsuObject.userdataSize++ + edDSsize.setText(getString(R.string.gigabyte_holder, gsiDsuObject.userdataSize)) + + } + + btnDecrease.setOnClickListener { + if (gsiDsuObject.userdataSize >= 2) + gsiDsuObject.userdataSize-- + edDSsize.setText(getString(R.string.gigabyte_holder, gsiDsuObject.userdataSize)) + } + + cbDSsize.setOnClickListener { + if (cbDSsize.isChecked) { + gsiDsuObject.userdataSize = SPUtils.getUserdataSize(requireContext()) + edDSsize.setText(getString(R.string.gigabyte_holder, gsiDsuObject.userdataSize)) + edDSsize.isEnabled = false + btnIncrease.visibility = View.GONE + btnDecrease.visibility = View.GONE + val params = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + edDSsize.layoutParams = params + } else { + edDSsize.isEnabled = true + edDSsize.keyListener = null + btnIncrease.visibility = View.VISIBLE + btnDecrease.visibility = View.VISIBLE + val params = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + edDSsize.layoutParams = params + } + } + + cbGSIsize.setOnClickListener { + if (cbGSIsize.isChecked) { + edGSIsize.setText(getString(R.string.auto)) + edGSIsize.isEnabled = false + } else { + edGSIsize.setText("") + edGSIsize.isEnabled = true + edGSIsize.requestFocus() + edGSIsize.hint = getString(R.string.type_bytes) + } + } + + btnInstall.setOnClickListener { + + if (!cbGSIsize.isChecked) { + gsiDsuObject.fileSize = if (edGSIsize.toString().isNotEmpty()) { + edGSIsize.text.toString().toLong() + } else { + Toast.makeText( + activity, + getString(R.string.invalid_gsi_size, getString(R.string.auto)), + Toast.LENGTH_SHORT + ).show() + -1 + } + } + + if (!cbDSsize.isChecked) { + gsiDsuObject.userdataSize = edDSsize.text.toString().split("GB")[0].toInt() + } + + beginInstall(selectedGsi, gsiDsuObject) + } + + val cb = requireView().findViewById(R.id.cb_keepawake) + cb.setOnClickListener { + if (cb.isChecked) requireActivity().window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + ) + else requireActivity().window.clearFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + ) + } + + if (selectedGsi != Uri.EMPTY) + btnInstall.isEnabled = true + + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_home, container, false) + } + + + private fun checkDialog(title: String, text: String, finish: Boolean) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(title) + .setMessage(text) + .setPositiveButton(if (finish) getString(R.string.close_app) else getString(R.string.got_it)) { _, _ -> if (finish) requireActivity().finish() } + .setCancelable(false) + .show() + } + + private fun hasAvailableStorage(): Boolean { + val statFs = StatFs(Environment.getDataDirectory().absolutePath) + val blockSize = statFs.blockSizeLong + val totalSize = statFs.blockCountLong * blockSize + val availableSize = statFs.availableBlocksLong * blockSize + return ((availableSize.toFloat() / totalSize.toFloat()) * 100).roundToInt() > 40 + } + + private fun isEnvCompatible(showDialogs: Boolean): Boolean { + return (isMagiskVersionCompatible(showDialogs) && isPropsValid(showDialogs)) + } + + private fun isPropsValid(showDialogs: Boolean): Boolean { + if (!CompatibilityCheck.checkDynamicPartitions()) { + if (showDialogs) { + checkDialog( + getString(R.string.unsupported), + getString(R.string.device_unsupported), + true + ) + } + return false + } else if (CompatibilityCheck.isBootloaderLocked() && CompatibilityCheck.signOfCustomOS()) { + if (showDialogs && !SPUtils.hasUserSeenDialogsBefore(requireContext())) { + checkDialog( + getString(R.string.notice), + getString(R.string.notice_lockedbl_custom), + false + ) + } + } else if (CompatibilityCheck.isBootloaderLocked()) { + if (showDialogs && !SPUtils.hasUserSeenDialogsBefore(requireContext())) { + checkDialog( + getString(R.string.notice), + getString(R.string.notice_lockedbl), + false + ) + } + } else if (CompatibilityCheck.signOfCustomOS()) { + if (showDialogs && !SPUtils.hasUserSeenDialogsBefore(requireContext())) { + checkDialog( + getString(R.string.notice), + getString(R.string.notice_custom), + false + ) + } + } + SPUtils.setUserHasSeenDialogsBefore(requireActivity()) + return true + } + + private fun isMagiskVersionCompatible(showDialogs: Boolean): Boolean { + if (CompatibilityCheck.isUsingIncompatibleMagisk()) { + if (showDialogs) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(getString(R.string.unsupported)) + .setMessage( + getString( + R.string.outdated_magisk, + OperationMode.obtainMagiskVersion() + ) + ) + .setPositiveButton(getString(R.string.close_app)) { _, _ -> + requireActivity().finish() + } + .setCancelable(false) + .show() + } + return false + } + return true + } + + private fun beginInstall(selectedGsi: Uri, gsiDsuObject: GsiDsuObject) { + var selectedFile = selectedGsi.lastPathSegment.toString().split(":")[1] + + if (selectedFile.contains("/")) + selectedFile = selectedFile.substring(selectedFile.lastIndexOf('/') + 1) + + when (selectedFile.substring(selectedFile.lastIndexOf("."))) { + ".xz", ".gz", ".img" -> { + + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.info) + .setMessage(getString(R.string.warning)) + .setPositiveButton(getString(R.string.proceed)) { _, _ -> + + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(getString(R.string.installation)) + .setMessage( + getString( + R.string.installation_details, + selectedFile, + gsiDsuObject.userdataSize.toString(), + if (gsiDsuObject.fileSize == -1L) getString(R.string.auto) else gsiDsuObject.fileSize + ) + ) + .setPositiveButton(getString(R.string.proceed)) { _, _ -> + WorkspaceFilesUtils.cleanWorkspaceFolder(requireActivity(), true) + Thread( + PrepareDsu( + requireActivity(), + selectedGsi, + gsiDsuObject + ) + ).start() + } + .setNegativeButton(getString(R.string.cancel), null) + .setCancelable(true) + .show() + + } + .setNegativeButton(getString(R.string.cancel), null) + .show() + + + } + + else -> { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.unsupported) + .setMessage(getString(R.string.file_unsupported)) + .setPositiveButton(getString(R.string.got_it), null) + .setCancelable(true) + .show() + } + } + } + + private fun showNoAvaiableStorageDialog() { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.error) + .setMessage(getString(R.string.storage_warning)) + .setPositiveButton(getString(R.string.close_app)) { _, _ -> requireActivity().finish() } + .setCancelable(false) + .show() + } + +} \ No newline at end of file diff --git a/app/src/main/java/vegabobo/dsusideloader/LogsActivity.kt b/app/src/main/java/vegabobo/dsusideloader/LogsActivity.kt new file mode 100644 index 0000000..06eed41 --- /dev/null +++ b/app/src/main/java/vegabobo/dsusideloader/LogsActivity.kt @@ -0,0 +1,90 @@ +package vegabobo.dsusideloader + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.topjohnwu.superuser.CallbackList +import com.topjohnwu.superuser.Shell +import vegabobo.dsusideloader.dsuhelper.GsiDsuObject +import vegabobo.dsusideloader.dsuhelper.RootDSUDeployer +import java.io.IOException +import java.io.OutputStream + + +class LogsActivity : AppCompatActivity() { + + var logsRaw = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_logs) + + RootDSUDeployer(intent.extras!!.get("dsu") as GsiDsuObject) + + val tvLog = findViewById(R.id.tv_logs) + val btnSaveLogs = findViewById