From 941d4bac058afeeedc4ba5f18805178175bd1117 Mon Sep 17 00:00:00 2001 From: Muntashir Al-Islam Date: Thu, 11 Jul 2024 17:17:41 +0600 Subject: [PATCH] [BatchOps] Add option to export app list as CSV and JSON Signed-off-by: Muntashir Al-Islam --- .../AppManager/apk/list/ListExporter.java | 216 +++++++++++++----- .../AppManager/main/MainActivity.java | 45 +++- .../AppManager/main/MainViewModel.java | 7 +- .../github/muntashirakon/csv/CsvWriter.java | 138 +++++++++++ app/src/main/res/values/arrays.xml | 2 + 5 files changed, 333 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/io/github/muntashirakon/csv/CsvWriter.java diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/apk/list/ListExporter.java b/app/src/main/java/io/github/muntashirakon/AppManager/apk/list/ListExporter.java index 49a36b495e0..78db8bc5fff 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/apk/list/ListExporter.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/apk/list/ListExporter.java @@ -15,10 +15,13 @@ import androidx.annotation.NonNull; import androidx.core.content.pm.PackageInfoCompat; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; import org.xmlpull.v1.XmlSerializer; import java.io.IOException; -import java.io.StringWriter; +import java.io.Writer; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; @@ -26,67 +29,52 @@ import io.github.muntashirakon.AppManager.compat.PackageManagerCompat; import io.github.muntashirakon.AppManager.utils.DateUtils; +import io.github.muntashirakon.AppManager.utils.ExUtils; import io.github.muntashirakon.AppManager.utils.PackageUtils; import io.github.muntashirakon.AppManager.utils.UIUtils; +import io.github.muntashirakon.csv.CsvWriter; public final class ListExporter { - public static final int EXPORT_TYPE_XML = 1; - public static final int EXPORT_TYPE_MARKDOWN = 2; + public static final int EXPORT_TYPE_CSV = 0; + public static final int EXPORT_TYPE_JSON = 1; + public static final int EXPORT_TYPE_XML = 2; + public static final int EXPORT_TYPE_MARKDOWN = 3; - @IntDef({EXPORT_TYPE_XML, EXPORT_TYPE_MARKDOWN}) + @IntDef({EXPORT_TYPE_CSV, EXPORT_TYPE_JSON, EXPORT_TYPE_XML, EXPORT_TYPE_MARKDOWN}) @Retention(RetentionPolicy.SOURCE) public @interface ExportType { } - @NonNull - public static String export(@NonNull Context context, @ExportType int exportType, - @NonNull List packageInfoList) throws IOException { - List appListItems = new ArrayList<>(packageInfoList.size()); - PackageManager pm = context.getPackageManager(); - for (PackageInfo packageInfo : packageInfoList) { - ApplicationInfo applicationInfo = packageInfo.applicationInfo; - AppListItem item = new AppListItem(packageInfo.packageName); - item.setIcon(UIUtils.getBitmapFromDrawable(applicationInfo.loadIcon(pm))); - item.setPackageLabel(applicationInfo.loadLabel(pm).toString()); - item.setVersionCode(PackageInfoCompat.getLongVersionCode(packageInfo)); - item.setVersionName(packageInfo.versionName); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - item.setMinSdk(applicationInfo.minSdkVersion); - } - item.setTargetSdk(applicationInfo.targetSdkVersion); - String[] signatureSha256 = PackageUtils.getSigningCertSha256Checksum(packageInfo, false); - item.setSignatureSha256(TextUtils.join(",", signatureSha256)); - item.setFirstInstallTime(packageInfo.firstInstallTime); - item.setLastUpdateTime(packageInfo.lastUpdateTime); - String installerPackageName = PackageManagerCompat.getInstallerPackageName(packageInfo.packageName, - UserHandleHidden.getUserId(applicationInfo.uid)); - if (installerPackageName != null) { - item.setInstallerPackageName(installerPackageName); - String installerPackageLabel; + public static void export(@NonNull Context context, + @NonNull Writer writer, + @ExportType int exportType, + @NonNull List packageInfoList) throws IOException { + List appListItems = getAppListItems(context, packageInfoList); + switch (exportType) { + case EXPORT_TYPE_CSV: + exportCsv(writer, appListItems); + return; + case EXPORT_TYPE_JSON: try { - installerPackageLabel = pm.getApplicationInfo(installerPackageName, 0).loadLabel(pm).toString(); - if (!installerPackageLabel.equals(installerPackageName)) { - item.setInstallerPackageLabel(installerPackageLabel); - } - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); + exportJson(writer, appListItems); + } catch (JSONException e) { + ExUtils.rethrowAsIOException(e); } - } - appListItems.add(item); - } - if (exportType == EXPORT_TYPE_XML) { - return exportXml(appListItems); - } else if (exportType == EXPORT_TYPE_MARKDOWN) { - return exportMarkdown(context, appListItems); + return; + case EXPORT_TYPE_XML: + exportXml(writer, appListItems); + return; + case EXPORT_TYPE_MARKDOWN: + exportMarkdown(context, writer, appListItems); + return; } throw new IllegalArgumentException("Invalid export type: " + exportType); } - @NonNull - private static String exportXml(@NonNull List appListItems) throws IOException { + private static void exportXml(@NonNull Writer writer, + @NonNull List appListItems) throws IOException { XmlSerializer xmlSerializer = Xml.newSerializer(); - StringWriter stringWriter = new StringWriter(); - xmlSerializer.setOutput(stringWriter); + xmlSerializer.setOutput(writer); xmlSerializer.startDocument("UTF-8", true); xmlSerializer.docdecl("packages SYSTEM \"https://raw.githubusercontent.com/MuntashirAkon/AppManager/master/schema/packages.dtd\""); xmlSerializer.startTag("", "packages"); @@ -115,36 +103,140 @@ private static String exportXml(@NonNull List appListItems) throws xmlSerializer.endTag("", "packages"); xmlSerializer.endDocument(); xmlSerializer.flush(); - return stringWriter.toString(); } - @NonNull - private static String exportMarkdown(@NonNull Context context, @NonNull List appListItems) { - StringBuilder sb = new StringBuilder("# Package Info\n\n"); + private static void exportCsv(@NonNull Writer writer, + @NonNull List appListItems) throws IOException { + CsvWriter csvWriter = new CsvWriter(writer); + // Add header + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + csvWriter.addLine(new String[]{"name", "label", "versionCode", "versionName", "minSdk", + "targetSdk", "signature", "firstInstallTime", "lastUpdateTime", + "installerPackageName", "installerPackageLabel"}); + } else { + csvWriter.addLine(new String[]{"name", "label", "versionCode", "versionName", + "targetSdk", "signature", "firstInstallTime", "lastUpdateTime", + "installerPackageName", "installerPackageLabel"}); + } + for (AppListItem item : appListItems) { + String installerPackage = item.getInstallerPackageName() != null ? item.getInstallerPackageName() : ""; + String installerLabel = item.getInstallerPackageLabel() != null ? item.getInstallerPackageLabel() : ""; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + csvWriter.addLine(new String[]{item.packageName, item.getPackageLabel(), + String.valueOf(item.getVersionCode()), item.getVersionName(), + String.valueOf(item.getMinSdk()), String.valueOf(item.getTargetSdk()), + item.getSignatureSha256(), String.valueOf(item.getFirstInstallTime()), + String.valueOf(item.getLastUpdateTime()), + installerPackage, installerLabel}); + } else { + csvWriter.addLine(new String[]{item.packageName, item.getPackageLabel(), + String.valueOf(item.getVersionCode()), item.getVersionName(), + String.valueOf(item.getTargetSdk()), item.getSignatureSha256(), + String.valueOf(item.getFirstInstallTime()), + String.valueOf(item.getLastUpdateTime()), + installerPackage, installerLabel}); + } + } + } + + private static void exportJson(@NonNull Writer writer, + @NonNull List appListItems) + throws JSONException, IOException { + // Should reflect packages.dtd + JSONArray array = new JSONArray(); + for (AppListItem item : appListItems) { + JSONObject object = new JSONObject(); + object.put("name", item.packageName); + object.put("label", item.getPackageLabel()); + object.put("versionCode", item.getVersionCode()); + object.put("versionName", item.getVersionName()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + object.put("minSdk", item.getMinSdk()); + } + object.put("targetSdk", item.getTargetSdk()); + object.put("signature", item.getSignatureSha256()); + object.put("firstInstallTime", item.getFirstInstallTime()); + object.put("lastUpdateTime", item.getLastUpdateTime()); + if (item.getInstallerPackageName() != null) { + object.put("installerPackageName", item.getInstallerPackageName()); + if (item.getInstallerPackageLabel() != null) { + object.put("installerPackageLabel", item.getInstallerPackageLabel()); + } + } + array.put(object); + } + writer.write(array.toString(4)); + } + + private static void exportMarkdown(@NonNull Context context, @NonNull Writer writer, + @NonNull List appListItems) throws IOException { + writer.write("# Package Info\n\n"); for (AppListItem appListItem : appListItems) { - sb.append("## ").append(appListItem.getPackageLabel()).append("\n\n") + writer.append("## ").append(appListItem.getPackageLabel()).append("\n\n") .append("**Package name:** ").append(appListItem.packageName).append("\n") .append("**Version:** ").append(appListItem.getVersionName()).append(" (") - .append(appListItem.getVersionCode()).append(")\n"); + .append(String.valueOf(appListItem.getVersionCode())).append(")\n"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - sb.append("**Min SDK:** ").append(appListItem.getMinSdk()).append(", "); + writer.append("**Min SDK:** ").append(String.valueOf(appListItem.getMinSdk())) + .append(", "); } - sb.append("**Target SDK:** ").append(appListItem.getTargetSdk()).append("\n") - .append("**Date installed:** ").append(DateUtils.formatDateTime(context, appListItem.getFirstInstallTime())) - .append(", **Date updated:** ").append(DateUtils.formatDateTime(context, appListItem.getLastUpdateTime())) + writer.append("**Target SDK:** ").append(String.valueOf(appListItem.getTargetSdk())) + .append("\n") + .append("**Date installed:** ") + .append(DateUtils.formatDateTime(context, appListItem.getFirstInstallTime())) + .append(", **Date updated:** ") + .append(DateUtils.formatDateTime(context, appListItem.getLastUpdateTime())) .append("\n"); if (appListItem.getInstallerPackageName() != null) { - sb.append("**Installer:** "); + writer.append("**Installer:** "); if (appListItem.getInstallerPackageLabel() != null) { - sb.append(appListItem.getInstallerPackageLabel()).append(" ("); + writer.append(appListItem.getInstallerPackageLabel()).append(" ("); } - sb.append(appListItem.getInstallerPackageName()); + writer.append(appListItem.getInstallerPackageName()); if (appListItem.getInstallerPackageLabel() != null) { - sb.append(")"); + writer.append(")"); + } + } + writer.append("\n\n"); + } + } + + @NonNull + private static List getAppListItems(@NonNull Context context, + @NonNull List packageInfoList) { + List appListItems = new ArrayList<>(packageInfoList.size()); + PackageManager pm = context.getPackageManager(); + for (PackageInfo packageInfo : packageInfoList) { + ApplicationInfo applicationInfo = packageInfo.applicationInfo; + AppListItem item = new AppListItem(packageInfo.packageName); + appListItems.add(item); + item.setIcon(UIUtils.getBitmapFromDrawable(applicationInfo.loadIcon(pm))); + item.setPackageLabel(applicationInfo.loadLabel(pm).toString()); + item.setVersionCode(PackageInfoCompat.getLongVersionCode(packageInfo)); + item.setVersionName(packageInfo.versionName); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + item.setMinSdk(applicationInfo.minSdkVersion); + } + item.setTargetSdk(applicationInfo.targetSdkVersion); + String[] signatureSha256 = PackageUtils.getSigningCertSha256Checksum(packageInfo, false); + item.setSignatureSha256(TextUtils.join(",", signatureSha256)); + item.setFirstInstallTime(packageInfo.firstInstallTime); + item.setLastUpdateTime(packageInfo.lastUpdateTime); + String installerPackageName = PackageManagerCompat.getInstallerPackageName( + packageInfo.packageName, UserHandleHidden.getUserId(applicationInfo.uid)); + if (installerPackageName != null) { + item.setInstallerPackageName(installerPackageName); + String installerPackageLabel; + try { + installerPackageLabel = pm.getApplicationInfo(installerPackageName, 0) + .loadLabel(pm).toString(); + if (!installerPackageLabel.equals(installerPackageName)) { + item.setInstallerPackageLabel(installerPackageLabel); + } + } catch (PackageManager.NameNotFoundException ignore) { } } - sb.append("\n\n"); } - return sb.toString(); + return appListItems; } } diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java b/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java index 6798f8ca0ed..729ad43ef7d 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java @@ -122,6 +122,26 @@ public class MainActivity extends BaseActivity implements AdvancedSearchView.OnQ dialogFragment.show(getSupportFragmentManager(), RulesTypeSelectionDialogFragment.TAG); }); + private final ActivityResultLauncher mExportAppListCsv = registerForActivityResult( + new ActivityResultContracts.CreateDocument("text/csv"), + uri -> { + if (uri == null) { + // Back button pressed. + return; + } + mProgressIndicator.show(); + viewModel.saveExportedAppList(ListExporter.EXPORT_TYPE_CSV, Paths.get(uri)); + }); + private final ActivityResultLauncher mExportAppListJson = registerForActivityResult( + new ActivityResultContracts.CreateDocument("application/json"), + uri -> { + if (uri == null) { + // Back button pressed. + return; + } + mProgressIndicator.show(); + viewModel.saveExportedAppList(ListExporter.EXPORT_TYPE_JSON, Paths.get(uri)); + }); private final ActivityResultLauncher mExportAppListXml = registerForActivityResult( new ActivityResultContracts.CreateDocument("text/xml"), uri -> { @@ -132,7 +152,6 @@ public class MainActivity extends BaseActivity implements AdvancedSearchView.OnQ mProgressIndicator.show(); viewModel.saveExportedAppList(ListExporter.EXPORT_TYPE_XML, Paths.get(uri)); }); - private final ActivityResultLauncher mExportAppListMarkdown = registerForActivityResult( new ActivityResultContracts.CreateDocument("text/markdown"), uri -> { @@ -407,24 +426,30 @@ public boolean onNavigationItemSelected(@NonNull MenuItem item) { final String fileName = "app_manager_rules_export-" + DateUtils.formatDateTime(this, System.currentTimeMillis()) + ".am.tsv"; mBatchExportRules.launch(fileName); } else if (id == R.id.action_export_app_list) { - List exportTypes = Arrays.asList(ListExporter.EXPORT_TYPE_XML, ListExporter.EXPORT_TYPE_MARKDOWN); + List exportTypes = Arrays.asList(ListExporter.EXPORT_TYPE_CSV, + ListExporter.EXPORT_TYPE_JSON, + ListExporter.EXPORT_TYPE_XML, + ListExporter.EXPORT_TYPE_MARKDOWN); new SearchableSingleChoiceDialogBuilder<>(this, exportTypes, R.array.export_app_list_options) .setTitle(R.string.export_app_list_select_format) .setOnSingleChoiceClickListener((dialog, which, item1, isChecked) -> { if (!isChecked) { return; } + String filename = "app_manager_app_list-" + DateUtils.formatLongDateTime(this, System.currentTimeMillis()) + ".am"; switch (item1) { - case ListExporter.EXPORT_TYPE_XML: { - final String fileName = "app_manager_app_list-" + DateUtils.formatDateTime(this, System.currentTimeMillis()) + ".am.xml"; - mExportAppListXml.launch(fileName); + case ListExporter.EXPORT_TYPE_CSV: + mExportAppListCsv.launch(filename + ".csv"); + break; + case ListExporter.EXPORT_TYPE_JSON: + mExportAppListJson.launch(filename + ".json"); + break; + case ListExporter.EXPORT_TYPE_XML: + mExportAppListXml.launch(filename + ".xml"); break; - } - case ListExporter.EXPORT_TYPE_MARKDOWN: { - final String fileName = "app_manager_app_list-" + DateUtils.formatDateTime(this, System.currentTimeMillis()) + ".am.md"; - mExportAppListMarkdown.launch(fileName); + case ListExporter.EXPORT_TYPE_MARKDOWN: + mExportAppListMarkdown.launch(filename + ".md"); break; - } } }) .setNegativeButton(R.string.close, null) diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/main/MainViewModel.java b/app/src/main/java/io/github/muntashirakon/AppManager/main/MainViewModel.java index 4a42da6aefb..7136b887a82 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/main/MainViewModel.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/main/MainViewModel.java @@ -25,8 +25,9 @@ import org.json.JSONException; +import java.io.BufferedWriter; import java.io.IOException; -import java.io.OutputStream; +import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; import java.text.Collator; import java.util.ArrayList; @@ -317,7 +318,7 @@ public void onResume() { public void saveExportedAppList(@ListExporter.ExportType int exportType, @NonNull Path path) { executor.submit(() -> { - try (OutputStream os = path.openOutputStream()) { + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(path.openOutputStream(), StandardCharsets.UTF_8))) { List packageInfoList = new ArrayList<>(); for (String packageName : getSelectedPackages().keySet()) { int[] userIds = Objects.requireNonNull(getSelectedPackages().get(packageName)).userIds; @@ -327,7 +328,7 @@ public void saveExportedAppList(@ListExporter.ExportType int exportType, @NonNul break; } } - os.write(ListExporter.export(getApplication(), exportType, packageInfoList).getBytes(StandardCharsets.UTF_8)); + ListExporter.export(getApplication(), writer, exportType, packageInfoList); mOperationStatus.postValue(true); } catch (IOException | RemoteException | PackageManager.NameNotFoundException e) { e.printStackTrace(); diff --git a/app/src/main/java/io/github/muntashirakon/csv/CsvWriter.java b/app/src/main/java/io/github/muntashirakon/csv/CsvWriter.java new file mode 100644 index 00000000000..cf3411d0fe7 --- /dev/null +++ b/app/src/main/java/io/github/muntashirakon/csv/CsvWriter.java @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT AND GPL-3.0-or-later + +package io.github.muntashirakon.csv; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.io.Writer; +import java.util.Collection; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +// Copyright 2020 Yong Mook Kim +// Copyright 2024 Muntashir Al-Islam +public class CsvWriter { + private static final String COMMA = ","; + private static final String DEFAULT_SEPARATOR = COMMA; + private static final String DOUBLE_QUOTES = "\""; + private static final String EMBEDDED_DOUBLE_QUOTES = "\"\""; + private static final String NEW_LINE_UNIX = "\n"; + private static final String NEW_LINE_WINDOWS = "\r\n"; + + private final Writer mWriter; + private final String mSeparator; + + private boolean mInitialized = false; + private int mFirstFieldCount = 0; + private int mCurrentFieldCount = 0; + + public CsvWriter(@NonNull Writer writer) { + this(writer, DEFAULT_SEPARATOR); + } + + public CsvWriter(@NonNull Writer writer, @NonNull String separator) { + mWriter = Objects.requireNonNull(writer); + mSeparator = Objects.requireNonNull(separator); + } + + public void addField(@Nullable String field) throws IOException { + addField(field, false); + } + + public void addField(@Nullable String field, boolean addQuotes) throws IOException { + ++mCurrentFieldCount; + checkFieldAvailable(); + if (mCurrentFieldCount > 1) { + // There are other fields + mWriter.write(mSeparator); + } + mWriter.append(getFormattedField(field, addQuotes)); + } + + public void addLine() throws IOException { + initIfNotAlready(); + checkFieldCountSame(); + mCurrentFieldCount = 0; + mWriter.append(System.lineSeparator()); + } + + public void addLine(@NonNull String[] line) throws IOException { + addLine(line, false); + } + + /** + * @param addQuotes Whether all fields are to be enclosed in double quotes + */ + public void addLine(@NonNull String[] line, boolean addQuotes) throws IOException { + mCurrentFieldCount = line.length; + initIfNotAlready(); + checkFieldCountSame(); + mCurrentFieldCount = 0; + mWriter.append(getFormattedLine(line, addQuotes)).append(System.lineSeparator()); + } + + public void addLines(@NonNull Collection lines) throws IOException { + addLines(lines, false); + } + + /** + * @param addQuotes Whether all fields are to be enclosed in double quotes + */ + public void addLines(@NonNull Collection lines, boolean addQuotes) throws IOException { + for (String[] line : lines) { + addLine(line, addQuotes); + } + } + + private String getFormattedLine(@NonNull String[] line, boolean addQuotes) { + return Stream.of(line) + .map(field -> getFormattedField(field, addQuotes)) + .collect(Collectors.joining(mSeparator)); + } + + @NonNull + private String getFormattedField(@Nullable String field, boolean addQuotes) { + if (field == null) { + // For a null field, add null as string + return addQuotes ? (DOUBLE_QUOTES + "null" + DOUBLE_QUOTES) : "null"; + } + if (field.contains(COMMA) + || field.contains(DOUBLE_QUOTES) + || field.contains(NEW_LINE_UNIX) + || field.contains(NEW_LINE_WINDOWS)) { + + // If the field contains double quotes, replace it with two double quotes \"\" + String result = field.replace(DOUBLE_QUOTES, EMBEDDED_DOUBLE_QUOTES); + + // Enclose the field in double quotes + return DOUBLE_QUOTES + result + DOUBLE_QUOTES; + } else if (addQuotes) { + // Add quotation even if not needed + return DOUBLE_QUOTES + field + DOUBLE_QUOTES; + } else return field; + } + + private void checkFieldAvailable() { + if (mInitialized && mCurrentFieldCount > mFirstFieldCount) { + throw new IndexOutOfBoundsException("CSV fields don't match. Previously added " + + mFirstFieldCount + " fields and now " + mCurrentFieldCount + " fields"); + } + } + + private void checkFieldCountSame() { + if (mInitialized && mCurrentFieldCount != mFirstFieldCount) { + throw new IndexOutOfBoundsException("CSV fields don't match. Previously added " + + mFirstFieldCount + " fields and now " + mCurrentFieldCount + " fields"); + } + } + + private void initIfNotAlready() { + if (!mInitialized) { + mInitialized = true; + mFirstFieldCount = mCurrentFieldCount; + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index e2b60480369..491d8bec697 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -235,6 +235,8 @@ @string/freeze_on_phone_locked + CSV + JSON @string/export_option_xml @string/export_option_markdown