From 6912f8dbfa52d7f058147080893c05f771002651 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Fri, 18 Apr 2025 18:01:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0xapk=E5=AE=89=E8=A3=85?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 14 +- .../activity/main/MainActivity.java | 63 ++- .../activity/main/MainViewModel.java | 5 +- .../mir4updater/base/BaseApplication.java | 5 + .../example/mir4updater/bean/Expansions.java | 39 ++ .../example/mir4updater/bean/SplitApks.java | 27 + .../receiver/InstallResultReceiver.java | 69 +++ .../example/mir4updater/utils/ApkUtils.java | 2 + .../example/mir4updater/utils/FileUtils.java | 12 + .../example/mir4updater/utils/JsonUtils.java | 39 ++ .../mir4updater/utils/PermissionUtils.java | 4 + .../example/mir4updater/utils/XapkUtils.java | 480 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 17 + 15 files changed, 775 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/example/mir4updater/bean/Expansions.java create mode 100644 app/src/main/java/com/example/mir4updater/bean/SplitApks.java create mode 100644 app/src/main/java/com/example/mir4updater/receiver/InstallResultReceiver.java create mode 100644 app/src/main/java/com/example/mir4updater/utils/FileUtils.java create mode 100644 app/src/main/java/com/example/mir4updater/utils/JsonUtils.java create mode 100644 app/src/main/java/com/example/mir4updater/utils/XapkUtils.java diff --git a/.gitignore b/.gitignore index b1123a0..66d9d70 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx /.idea/ +/ttutils/jniLibs/ diff --git a/app/build.gradle b/app/build.gradle index 192fd61..799e076 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -168,6 +168,8 @@ dependencies { //工具类 implementation 'com.blankj:utilcodex:1.31.1' + //压缩文件解压 + implementation 'org.zeroturnaround:zt-zip:1.15' //动态权限框架 implementation 'com.github.getActivity:XXPermissions:18.6' // 吐司框架:https://github.com/getActivity/Toaster diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9fd0ecd..528287b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,12 @@ xmlns:tools="http://schemas.android.com/tools" package="com.example.mir4updater"> + + + + + + @@ -12,6 +18,7 @@ + + + permissions, boolean allGranted) { + Log.e(TAG, "onGranted: permissions = " + permissions + " allGranted = " + allGranted); + if (!allGranted) { +// Toaster.show("获取部分权限成功,但部分权限未正常授予"); + return; + } + Log.e(TAG, "onGranted: 获取权限成功"); + installXapk(); + } + + @Override + public void onDenied(@NonNull List permissions, boolean doNotAskAgain) { + Log.e(TAG, "onDenied: permissions = " + permissions + " doNotAskAgain = " + doNotAskAgain); + if (doNotAskAgain) { + Toaster.show("被永久拒绝授权,请手动授予权限"); + // 如果是被永久拒绝就跳转到应用权限系统设置页面 + XXPermissions.startPermissionActivity(MainActivity.this, permissions); + } else { + Log.e(TAG, "onGranted: 获取权限失败"); + } + } + }); + } + + private void installXapk() { + File file = new File("/storage/emulated/0/Download/Google Chrome_135.0.7049.100_APKPure.xapk"); + XapkUtils.getInstance().installXapk(file.getAbsolutePath()); + } + + private void requestPackageInstalls() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !getPackageManager().canRequestPackageInstalls()) { + Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + .setData(Uri.parse("package:" + getPackageName())); + startActivityForResult(intent, REQUEST_INSTALL_CODE); + } else { + getInstallPermission(); + } + } + public class BtnClick { public void upload(View view) { startActivity(new Intent(MainActivity.this, UploadActivity.class)); @@ -490,5 +547,9 @@ public class MainActivity extends BaseMvvmActivity() { @Override @@ -65,7 +66,7 @@ public class MainViewModel extends BaseViewModel ks = extras.keySet(); +//// Iterator iterator = ks.iterator(); +//// while (iterator.hasNext()) { +//// Log.d("KEY", iterator.next()); +//// } +// String STATUS = intent.getStringExtra(PackageInstaller.EXTRA_STATUS); +// String PACKAGE_NAME = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME); +// String SESSION_ID = intent.getStringExtra(PackageInstaller.EXTRA_SESSION_ID); +// String STATUS_MESSAGE = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); +// String LEGACY_STATUS = intent.getStringExtra("android.content.pm.extra.LEGACY_STATUS"); +// +// Log.e(TAG, "EXTRA_STATUS: " + STATUS); +// Log.e(TAG, "PACKAGE_NAME: " + PACKAGE_NAME); +// Log.e(TAG, "SESSION_ID: " + SESSION_ID); +// Log.e(TAG, "STATUS_MESSAGE: " + LEGACY_STATUS); +// Log.e(TAG, "LEGACY_STATUS: " + STATUS_MESSAGE); +// +// if ("INSTALL_SUCCEEDED".equals(STATUS_MESSAGE)) { +// Toaster.show(PACKAGE_NAME + "安装成功"); +// } +// } +} diff --git a/app/src/main/java/com/example/mir4updater/utils/ApkUtils.java b/app/src/main/java/com/example/mir4updater/utils/ApkUtils.java index 85e4fd2..a5f40a5 100644 --- a/app/src/main/java/com/example/mir4updater/utils/ApkUtils.java +++ b/app/src/main/java/com/example/mir4updater/utils/ApkUtils.java @@ -87,4 +87,6 @@ public class ApkUtils { } return false; } + + } diff --git a/app/src/main/java/com/example/mir4updater/utils/FileUtils.java b/app/src/main/java/com/example/mir4updater/utils/FileUtils.java new file mode 100644 index 0000000..2573c1c --- /dev/null +++ b/app/src/main/java/com/example/mir4updater/utils/FileUtils.java @@ -0,0 +1,12 @@ +package com.example.mir4updater.utils; + +import java.io.File; + +public class FileUtils { + public static String getFileNoExName(String filePath) { + + String fileNameEx = filePath.substring(filePath.lastIndexOf(File.separator) + 1); + String fileName = fileNameEx.substring(0,fileNameEx.lastIndexOf(".")); + return fileName; + } +} diff --git a/app/src/main/java/com/example/mir4updater/utils/JsonUtils.java b/app/src/main/java/com/example/mir4updater/utils/JsonUtils.java new file mode 100644 index 0000000..151f59e --- /dev/null +++ b/app/src/main/java/com/example/mir4updater/utils/JsonUtils.java @@ -0,0 +1,39 @@ +package com.example.mir4updater.utils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; + +public class JsonUtils { + /** + * 读取json文件,返回json串 + * @param fileName + * @return + */ + public static String readJsonFile(String fileName) { + String jsonStr = ""; + try { + File jsonFile = new File(fileName); + FileReader fileReader = new FileReader(jsonFile); + + Reader reader = new InputStreamReader(new FileInputStream(jsonFile), StandardCharsets.UTF_8); + int ch = 0; + StringBuffer sb = new StringBuffer(); + while ((ch = reader.read()) != -1) { + sb.append((char) ch); + } + fileReader.close(); + reader.close(); + jsonStr = sb.toString(); + return jsonStr; + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + +} diff --git a/app/src/main/java/com/example/mir4updater/utils/PermissionUtils.java b/app/src/main/java/com/example/mir4updater/utils/PermissionUtils.java index 0af48c5..74182c2 100644 --- a/app/src/main/java/com/example/mir4updater/utils/PermissionUtils.java +++ b/app/src/main/java/com/example/mir4updater/utils/PermissionUtils.java @@ -16,6 +16,10 @@ public class PermissionUtils { Permission.MANAGE_EXTERNAL_STORAGE, }; + public static final String[] Install_Permission = new String[]{ + Permission.REQUEST_INSTALL_PACKAGES, + }; + public static boolean checkAppData(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // 请求Android/data目录的权限,Android/obb目录则把data替换成obb即可。 diff --git a/app/src/main/java/com/example/mir4updater/utils/XapkUtils.java b/app/src/main/java/com/example/mir4updater/utils/XapkUtils.java new file mode 100644 index 0000000..8ed8d18 --- /dev/null +++ b/app/src/main/java/com/example/mir4updater/utils/XapkUtils.java @@ -0,0 +1,480 @@ +package com.example.mir4updater.utils; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInstaller; +import android.os.Build; +import android.os.Environment; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.RequiresApi; + +import com.example.mir4updater.bean.Expansions; +import com.example.mir4updater.bean.SplitApks; +import com.example.mir4updater.receiver.InstallResultReceiver; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; +import com.hjq.toast.Toaster; + +import org.zeroturnaround.zip.ZipUtil; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.ObservableEmitter; +import io.reactivex.rxjava3.core.ObservableOnSubscribe; +import io.reactivex.rxjava3.core.Observer; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class XapkUtils { + private static final String TAG = "XapkUtils"; + + @SuppressLint("StaticFieldLeak") + private static XapkUtils sInstance; + private Context mContext; + + private XapkUtils(Context context) { + this.mContext = context; + } + + // TODO: 2025/4/18 优化为单例模式 + public static void init(Context context) { + if (sInstance == null) { + sInstance = new XapkUtils(context); + } + } + + public static XapkUtils getInstance() { + if (sInstance == null) { + throw new IllegalStateException("You must be init XapkUtils first"); + } + return sInstance; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + public void installXapk(String filePath) { + File file = new File(filePath); + if (file.exists() && file.isFile()) { + upzipXapk(file); + } else { + Log.e(TAG, "installXAPK: " + "File not exists"); + + } + } + + @Deprecated + private static String unpackPath; + @Deprecated + private static long XapkSize; + + @RequiresApi(api = Build.VERSION_CODES.O) + private void upzipXapk(File XapkFile) { + Observable.create(new ObservableOnSubscribe() { + @Override + public void subscribe(ObservableEmitter emitter) throws Exception { + if (ZipUtil.containsEntry(XapkFile, "manifest.json")) { + //判断json文件是否存在 + String dirName = FileUtils.getFileNoExName(XapkFile.getAbsolutePath()); + unpackPath = XapkFile.getParentFile().getAbsolutePath() + File.separator + dirName; + File unpackDir = new File(unpackPath); + ZipUtil.unpack(XapkFile, unpackDir); + String jsonString = JsonUtils.readJsonFile(unpackDir.getAbsolutePath() + File.separator + "manifest.json"); + emitter.onNext(jsonString); + } else { + Toaster.show("读取XAPK配置文件失败"); + emitter.onComplete(); + } + } + }).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new Observer() { + @Override + public void onSubscribe(Disposable d) { + + } + + @Override + public void onNext(String s) { + JsonObject jsonObject = JsonParser.parseString(s).getAsJsonObject(); + XapkSize = jsonObject.get("total_size").getAsLong(); + JsonElement split_configs_em = jsonObject.get("split_configs"); + //获取分割的配置 + JsonElement expansions_em = jsonObject.get("expansions"); + //获取OBB文件配置 + JsonElement split_apks_em = jsonObject.get("split_apks"); + readConfig(split_configs_em, expansions_em, split_apks_em); +// Log.e(TAG, "installXAPK: " + jsonString); + } + + @Override + public void onError(Throwable e) { + Log.e(TAG, "onError: " + e.getMessage()); + } + + @Override + public void onComplete() { + Log.e(TAG, "onComplete: "); + } + }); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private void readConfig(JsonElement split_configs, JsonElement expansions, JsonElement split_apks) { + //没有split_configs只有单个apk,读取split_apks 的base字段的file文件 + //没有expansions 没有obb文件 + //split_apks应该是都会有的,包含一个base的json对象 + if (null != split_configs) { + getSplitConfigs(split_configs.getAsJsonArray()); + } else { + Log.e(TAG, "readConfig: " + "not found split_configs json data"); + } + + if (null != expansions) { + readOBBConfig(expansions.getAsJsonArray()); + } else { + Log.e(TAG, "readConfig: " + "not found expansions json data"); + } + + if (null != split_apks) { + getSplitApks(split_apks.getAsJsonArray()); + } else { + Log.e(TAG, "readConfig: " + "not found split_apks json data"); + } + } + + List configList = new ArrayList<>(); + + /** + * 获取分裂的配置文件 + * + * @param jsonArray + */ + private void getSplitConfigs(JsonArray jsonArray) { + Type type = new TypeToken>() { + }.getType(); + Gson gson = new Gson(); + configList = gson.fromJson(gson.toJson(jsonArray), type); +// StringBuilder configStringBuilder = new StringBuilder(); +// for (String config : configList) { +// if (configStringBuilder.length() > 0) { +// configStringBuilder.append(","); +// } +// configStringBuilder.append(config); +// } +// Log.e(TAG, "getSplitConfigs: " + configStringBuilder.toString()); + } + + /** + * 获取obb配置文件 + * + * @param jsonArray + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private void readOBBConfig(JsonArray jsonArray) { + if (TextUtils.isEmpty(unpackPath)) { + Log.e(TAG, "readOBBConfig: " + "unpack directory is empty"); + return; + } + Type type = new TypeToken>() { + }.getType(); + Gson gson = new Gson(); + List expansionsList = gson.fromJson(gson.toJson(jsonArray), type); + if (null != expansionsList && expansionsList.size() > 0) { + for (Expansions expansions : expansionsList) { + if (copyObbFile(expansions)) { + Log.e(TAG, "readOBBConfig: " + "success"); + } else { + Log.e(TAG, "readOBBConfig: " + "copy oob File failure"); + } + } + } + } + + /** + * 获取分裂的apk文件 + * + * @param jsonArray + */ + private void getSplitApks(JsonArray jsonArray) { + Type type = new TypeToken>() { + }.getType(); + Gson gson = new Gson(); + List splitApksList = gson.fromJson(gson.toJson(jsonArray), type); + getApkFilePath(configList, splitApksList); +// if (null != splitApksList && splitApksList.size() > 0) { +// for (SplitApks splitApks : splitApksList) { +// +// } +// } + } + + /** + * @param configList + * @param apkList configList 可以为空,为空解析split_apks对象中的base + * apkList应该不会为空 + */ + private void getApkFilePath(List configList, List apkList) { + List filePath = new ArrayList<>(); + //应该直接获取SplitApks里面的file + if (null != configList && configList.size() > 0) { + //split_configs不为空的情况 + filePath.add(unpackPath + File.separator + apkList.get(getFileFromId(apkList, "base")).getFile()); + for (String config : configList) { + int position = getFileFromId(apkList, config); + if (position != -1) { + String file = apkList.get(position).getFile(); + filePath.add(unpackPath + File.separator + file); + } + } +// Log.e(TAG, "installxApk: " + filePath.toString()); + } else { + //split_configs为空的情况 + int position = getFileFromId(apkList, "base"); + if (position != -1) { + String file = apkList.get(position).getFile(); + filePath.add(file); + Log.e(TAG, "installxApk: " + "base file = " + file); + } + } + + if (filePath.size() != 0) { + installApk(filePath); + } + } + + /** + * 通过id获取文件 + * + * @param splitApks + * @param id + */ + private int getFileFromId(List splitApks, String id) { + int position = -1; + for (int i = 0; i < splitApks.size(); i++) { + if (splitApks.get(i).getId().equals(id)) { + position = i; + } + } + return position; + } + + private void installApk(final List paths) { + new Thread(new Runnable() { + @Override + public void run() { + installAppatPie(paths); + } + }).start(); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public void installAppatPie(List apkFilePath) { + Log.e(TAG, "installApps: 正在安装应用 " + apkFilePath); +// File file = new File(apkFilePath); + PackageInstaller packageInstaller = mContext.getPackageManager().getPackageInstaller(); + PackageInstaller.SessionParams sessionParams = new PackageInstaller.SessionParams(PackageInstaller + .SessionParams.MODE_FULL_INSTALL); + sessionParams.setSize(XapkSize); + int sessionId = createSession(packageInstaller, sessionParams); + if (sessionId != -1) { + for (String apkPath : apkFilePath) { + copyApkFile(packageInstaller, sessionId, apkPath); + } +// boolean copySuccess = ; +// if (copySuccess) { + Toaster.show("正在安装应用"); + install(packageInstaller, sessionId, mContext); +// } +// + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private static void install(PackageInstaller packageInstaller, int sessionId, Context context) { + try { + PackageInstaller.Session session = packageInstaller.openSession(sessionId); + Intent intent = new Intent(context, InstallResultReceiver.class); + PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, + 1, intent, + PendingIntent.FLAG_UPDATE_CURRENT + ); + session.commit(pendingIntent.getIntentSender()); + } catch (IOException e) { + e.printStackTrace(); + Log.e(TAG, "install: " + e.getMessage()); + } + } + + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private static int createSession(PackageInstaller packageInstaller, PackageInstaller.SessionParams sessionParams) { + int sessionId = -1; + try { + sessionId = packageInstaller.createSession(sessionParams); + } catch (IOException e) { + e.printStackTrace(); + Log.e(TAG, "createSession: " + e.getMessage()); + } + return sessionId; + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private static boolean copyApkFile(PackageInstaller pi, int sessionId, String apkFilePath) { + boolean success = false; + File apkFile = new File(apkFilePath); + PackageInstaller.Session session = null; + try { + session = pi.openSession(sessionId); + OutputStream out = session.openWrite(FileUtils.getFileNoExName(apkFilePath), 0, apkFile.length()); + FileInputStream input = new FileInputStream(apkFile); + int read = 0; + byte[] buffer = new byte[65536]; +// while (read != -1) { +// read = input.read(buffer); +// out.write(buffer, 0, read); +// } + while (true) { + read = input.read(buffer); + if (read == -1) { + session.fsync(out); + success = true; + out.close(); + input.close(); + break; + } + out.write(buffer, 0, read); + } + } catch (IOException e) { + e.printStackTrace(); + Log.e("fht", "copyApkFile" + e.getMessage()); + } + Log.e("fht", "copyApkFile" + "success = " + success); + return success; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private boolean copyObbFile(Expansions expansions) { + String install_location = expansions.getInstall_location(); + //install_location 不清楚是否还有其他定义 + + String file = expansions.getFile(); + String install_path = expansions.getInstall_path(); + + if (TextUtils.isEmpty(file)) { + Log.e(TAG, "copyObbFile: " + "file path is empty"); + return false; + } else { + File localFile = new File(unpackPath + File.separator + file); + if (localFile.exists() && localFile.isFile()) { + File installFile = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + install_path); + Log.e(TAG, "copyObbFile: " + "localFile: " + localFile.getAbsolutePath()); + Log.e(TAG, "copyObbFile: " + "installFile: " + installFile.getAbsolutePath()); + try { + Path path = Paths.get(localFile.getAbsolutePath()); + Files.copy(path, new FileOutputStream(installFile)); + return true; + } catch (IOException e) { + Log.e(TAG, "copyObbFile: " + "IOException" + e.getMessage()); + e.printStackTrace(); + return false; + } + } else { + Log.e(TAG, "copyObbFile: " + "localFile: " + "File not exists"); + return false; + } + + } + } + + + + + + @Deprecated + public static File unzipXapk(Context context, File xapkFile) throws IOException { + File outputDir = new File(context.getExternalCacheDir(), "xapk_unzip"); + if (!outputDir.exists()) outputDir.mkdirs(); + + try (ZipFile zipFile = new ZipFile(xapkFile)) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + File entryFile = new File(outputDir, entry.getName()); + if (entry.isDirectory()) { + entryFile.mkdirs(); + } else { + try (InputStream in = zipFile.getInputStream(entry); + OutputStream out = new FileOutputStream(entryFile)) { + byte[] buffer = new byte[4096]; + int len; + while ((len = in.read(buffer)) > 0) { + out.write(buffer, 0, len); + } + } + } + } + } + return outputDir; + } + + @Deprecated + public static void installXapk(Context context, File outputDir) { + PackageInstaller packageInstaller = context.getPackageManager().getPackageInstaller(); + PackageInstaller.SessionParams params = new PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL + ); + + try { + int sessionId = packageInstaller.createSession(params); + PackageInstaller.Session session = packageInstaller.openSession(sessionId); + + File[] apkFiles = outputDir.listFiles(file -> file.getName().endsWith(".apk")); + if (apkFiles == null) return; + + for (File apkFile : apkFiles) { + try (InputStream in = new FileInputStream(apkFile); + OutputStream out = session.openWrite(apkFile.getName(), 0, apkFile.length())) { + byte[] buffer = new byte[65536]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + session.fsync(out); + } + } + + // 提交安装请求 + Intent callbackIntent = new Intent(context, InstallResultReceiver.class); + PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, sessionId, callbackIntent, PendingIntent.FLAG_IMMUTABLE + ); + session.commit(pendingIntent.getIntentSender()); + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index d7de177..a40eb2a 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -133,6 +133,23 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/tv_app_storage_state" /> + +