...
 
Commits (15)
......@@ -32,6 +32,7 @@ Join our Telegram channel: https://t.me/AppManagerAndroid
### Mirrors
Gitlab: https://gitlab.com/muntashir/AppManager
Riseup: https://0xacab.org/muntashir/AppManager
### Screenshots
......
......@@ -14,13 +14,25 @@
android:largeHeap="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme">
<activity android:name=".activities.SettingsActivity"></activity>
<activity android:name=".activities.SharedPrefsActivity" />
<activity
android:name=".activities.RunningAppsActivity"
android:exported="false" />
<activity
android:name=".activities.SettingsActivity"
android:exported="false" />
<activity
android:name=".activities.SharedPrefsActivity"
android:exported="false" />
<activity
android:name=".activities.AppUsageActivity"
android:permission="android.permission.PACKAGE_USAGE_STATS" />
<activity android:name=".activities.ClassViewerActivity" />
<activity android:name=".activities.ClassListingActivity" />
android:permission="android.permission.PACKAGE_USAGE_STATS"
android:exported="false" />
<activity
android:name=".activities.ClassViewerActivity"
android:exported="false" />
<activity
android:name=".activities.ClassListingActivity"
android:exported="false" />
<activity
android:name=".activities.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
......@@ -36,13 +48,16 @@
<activity
android:name=".activities.AppInfoActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:label="@string/app_info" />
android:label="@string/app_info"
android:exported="false" />
<activity
android:name=".activities.AppDetailsActivity"
android:configChanges="keyboardHidden|orientation|screenSize" />
android:configChanges="keyboardHidden|orientation|screenSize"
android:exported="false" />
<activity
android:name=".activities.ManifestViewerActivity"
android:configChanges="keyboardHidden|orientation|screenSize" />
android:configChanges="keyboardHidden|orientation|screenSize"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
......
......@@ -18,13 +18,10 @@ package com.google.classysharkandroid.dex;
import android.content.Context;
import com.google.classysharkandroid.utils.IOUtils;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
......@@ -37,25 +34,13 @@ public class DexLoaderBuilder {
private DexLoaderBuilder() {}
public static DexClassLoader fromFile(Context context, final File dexFile) throws Exception {
FileInputStream fileInputStream = new FileInputStream(dexFile);
byte[] bFile = IOUtils.toByteArray(fileInputStream);
return fromBytes(context, bFile);
}
@NonNull
public static DexClassLoader fromBytes(@NonNull Context context, final byte[] dexBytes) throws Exception {
String dexFileName = "internal.dex";
final File dexInternalStoragePath = new File(context.getDir("dex", Context.MODE_PRIVATE), dexFileName);
if (!dexInternalStoragePath.exists()) {
prepareDex(dexBytes, dexInternalStoragePath);
}
if (!dexInternalStoragePath.exists()) prepareDex(dexBytes, dexInternalStoragePath);
final File optimizedDexOutputPath = context.getCodeCacheDir();
DexClassLoader loader = new DexClassLoader(dexInternalStoragePath.getAbsolutePath(),
optimizedDexOutputPath.getAbsolutePath(), null,
context.getClassLoader().getParent());
......
......@@ -47,37 +47,16 @@ public class ClassTypeAlgorithm {
case 'L':
yy = TypeName(nm.substring(nm.indexOf("L") + 1, nm.indexOf(";")), ht);
break;
case 'I':
yy = "int";
break;
case 'V':
yy = "void";
break;
case 'C':
yy = "char";
break;
case 'D':
yy = "double";
break;
case 'F':
yy = "float";
break;
case 'J':
yy = "long";
break;
case 'S':
yy = "short";
break;
case 'Z':
yy = "boolean";
break;
case 'B':
yy = "byte";
break;
default:
yy = "BOGUS:" + nm;
break;
case 'I': yy = "int"; break;
case 'V': yy = "void"; break;
case 'C': yy = "char"; break;
case 'D': yy = "double"; break;
case 'F': yy = "float"; break;
case 'J': yy = "long"; break;
case 'S': yy = "short"; break;
case 'Z': yy = "boolean"; break;
case 'B': yy = "byte"; break;
default: yy = "BOGUS:" + nm;
}
}
return yy + arr;
......
......@@ -42,6 +42,9 @@ import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class IOUtils {
public static void bytesToFile(byte[] bytes, File result) throws IOException {
......@@ -52,6 +55,7 @@ public class IOUtils {
bos.close();
}
@NonNull
public static byte[] toByteArray(InputStream input) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
copy(input, output);
......@@ -68,7 +72,7 @@ public class IOUtils {
private static final int DEFAULT_BUFFER_SIZE = 1024 * 4;
static long copyLarge(InputStream input, OutputStream output) throws IOException {
static long copyLarge(@NonNull InputStream input, OutputStream output) throws IOException {
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
long count = 0;
int n;
......@@ -81,6 +85,7 @@ public class IOUtils {
/**
* Format xml file to correct indentation ...
*/
@Nullable
public static String getProperXml(String dirtyXml) {
try {
Document document = DocumentBuilderFactory.newInstance()
......
......@@ -41,53 +41,6 @@ public class PackageUtils {
return s;
}
@NonNull
public static String apkPro(PackageInfo p, PackageManager mPackageManager) {
String[] aPermissionsUse;
StringBuilder s = new StringBuilder(apkCert(p));
String tmp;
PermissionInfo pI;
if (p.requestedPermissions != null) {
aPermissionsUse= new String[p.requestedPermissions.length];
for (int i=0; i < p.requestedPermissions.length; i++){
if (p.requestedPermissions[i].startsWith("android.permission"))
aPermissionsUse[i] = p.requestedPermissions[i].substring(18) + " ";
else aPermissionsUse[i] = p.requestedPermissions[i] + " ";
try {
pI = mPackageManager.getPermissionInfo(p.requestedPermissions[i], PackageManager.GET_META_DATA);
tmp = getProtectionLevelString(pI);
if (tmp.contains("dangerous")) aPermissionsUse[i] = "*\u2638"+aPermissionsUse[i];
aPermissionsUse[i] += tmp+"\n-->"+pI.group;
} catch (PackageManager.NameNotFoundException ignored) {}
if ((p.requestedPermissionsFlags[i]
& PackageInfo.REQUESTED_PERMISSION_GRANTED) != 0)
aPermissionsUse[i] +=" ^\u2714";
}
try {
Arrays.sort(aPermissionsUse);
} catch (NullPointerException ignored) {}
s.append("\n");
for (String value : aPermissionsUse) s.append("\n\n").append(value);
}
if (p.permissions != null) {
s.append("\n\n#######################\n### Declared Permissions ###");
Arrays.sort(p.permissions, (o1, o2) -> o1.name.compareToIgnoreCase(o2.name));
for (int i=0;i < p.permissions.length;i++) {
s.append("\n\n\u25a0").append(p.permissions[i].name).append("\n").
append(p.permissions[i].loadLabel(mPackageManager)).append("\n").
append(p.permissions[i].loadDescription(mPackageManager)).
append("\n").append(p.permissions[i].group);
}
}
return s.toString();
}
@NonNull
public static String convertS(@NonNull byte[] digest) {
StringBuilder s= new StringBuilder();
......@@ -97,70 +50,4 @@ public class PackageUtils {
return s.toString();
}
@NonNull
public static String getProtectionLevelString(PermissionInfo permissionInfo) {
int basePermissionType;
int permissionFlags;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
permissionFlags = permissionInfo.getProtectionFlags();
basePermissionType = permissionInfo.getProtection();
} else {
permissionFlags = permissionInfo.protectionLevel;
basePermissionType = permissionFlags & PermissionInfo.PROTECTION_MASK_BASE;
}
String protLevel = "????";
switch (basePermissionType) {
case PermissionInfo.PROTECTION_DANGEROUS:
protLevel = "dangerous";
break;
case PermissionInfo.PROTECTION_NORMAL:
protLevel = "normal";
break;
case PermissionInfo.PROTECTION_SIGNATURE:
protLevel = "signature";
break;
}
if (Build.VERSION.SDK_INT >= 23) {
if (basePermissionType == (PermissionInfo.PROTECTION_SIGNATURE
| PermissionInfo.PROTECTION_FLAG_PRIVILEGED)) {
protLevel = "signatureOrPrivileged";
}
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_PRIVILEGED) != 0)
protLevel += "|privileged";
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_PRE23) != 0)
protLevel += "|pre23"; // pre marshmallow
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_INSTALLER) != 0)
protLevel += "|installer";
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_VERIFIER) != 0)
protLevel += "|verifier";
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_PREINSTALLED) != 0)
protLevel += "|preinstalled";
if (Build.VERSION.SDK_INT >= 24) {
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_SETUP) != 0)
protLevel += "|setup";
}
if (Build.VERSION.SDK_INT >= 26) {
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_RUNTIME_ONLY) != 0)
protLevel += "|runtime";
}
if (Build.VERSION.SDK_INT >= 27) {
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_INSTANT) != 0)
protLevel += "|instant";
}
} else {
if (basePermissionType == PermissionInfo.PROTECTION_SIGNATURE_OR_SYSTEM) {
protLevel = "signatureOrSystem";
}
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_SYSTEM) != 0) {
protLevel += "|system";
}
}
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_DEVELOPMENT) != 0) {
protLevel += "|development";
}
if ((permissionFlags & PermissionInfo.PROTECTION_FLAG_APPOP) != 0) {
protLevel += "|appop";
}
return protLevel;
}
}
......@@ -50,9 +50,9 @@ public class MainLoader extends AsyncTaskLoader<List<ApplicationItem>> {
item.label = applicationInfo.loadLabel(mPackageManager).toString();
item.date = mPackageManager.getPackageInfo(applicationInfo.packageName, 0).lastUpdateTime; // .firstInstallTime;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
item.sha = Utils.apkPro(mPackageManager.getPackageInfo(applicationInfo.packageName, PackageManager.GET_SIGNING_CERTIFICATES));
item.sha = Utils.getIssuerAndAlg(mPackageManager.getPackageInfo(applicationInfo.packageName, PackageManager.GET_SIGNING_CERTIFICATES));
} else {
item.sha = Utils.apkPro(mPackageManager.getPackageInfo(applicationInfo.packageName, PackageManager.GET_SIGNATURES));
item.sha = Utils.getIssuerAndAlg(mPackageManager.getPackageInfo(applicationInfo.packageName, PackageManager.GET_SIGNATURES));
}
if (Build.VERSION.SDK_INT >= 26) {
item.size = (long) -1 * applicationInfo.targetSdkVersion;
......@@ -76,9 +76,9 @@ public class MainLoader extends AsyncTaskLoader<List<ApplicationItem>> {
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
item.sha = Utils.apkPro(mPackageManager.getPackageInfo(applicationInfo.packageName, PackageManager.GET_SIGNING_CERTIFICATES));
item.sha = Utils.getIssuerAndAlg(mPackageManager.getPackageInfo(applicationInfo.packageName, PackageManager.GET_SIGNING_CERTIFICATES));
} else {
item.sha = Utils.apkPro(mPackageManager.getPackageInfo(applicationInfo.packageName, PackageManager.GET_SIGNATURES));
item.sha = Utils.getIssuerAndAlg(mPackageManager.getPackageInfo(applicationInfo.packageName, PackageManager.GET_SIGNATURES));
}
item.date = mPackageManager.getPackageInfo(applicationInfo.packageName, 0).lastUpdateTime; // .firstInstallTime;
} catch (PackageManager.NameNotFoundException e) {
......
......@@ -44,6 +44,7 @@ public class AppDetailsActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_app_details);
mConstraint = null;
mPackageName = getIntent().getStringExtra(AppInfoActivity.EXTRA_PACKAGE_NAME);
if (mPackageName == null) {
Toast.makeText(this, getString(R.string.empty_package_name), Toast.LENGTH_LONG).show();
......
......@@ -17,7 +17,6 @@ import android.os.Build;
import android.os.Bundle;
import android.os.FileUtils;
import android.os.Process;
import android.os.SystemClock;
import android.provider.Settings;
import android.text.format.Formatter;
import android.view.Menu;
......@@ -76,8 +75,10 @@ public class AppInfoActivity extends AppCompatActivity implements SwipeRefreshLa
private static final String PACKAGE_NAME_FDROID = "org.fdroid.fdroid";
private static final String PACKAGE_NAME_AURORA_DROID = "com.aurora.adroid";
private static final String PACKAGE_NAME_AURORA_STORE = "com.aurora.store";
private static final String ACTIVITY_NAME_FDROID = "org.fdroid.fdroid.views.AppDetailsActivity";
private static final String ACTIVITY_NAME_AURORA_DROID = "com.aurora.adroid.ui.activity.DetailsActivity";
private static final String ACTIVITY_NAME_AURORA_STORE = "com.aurora.store.ui.details.DetailsActivity";
private PackageManager mPackageManager;
private String mPackageName;
......@@ -400,10 +401,24 @@ public class AppInfoActivity extends AppCompatActivity implements SwipeRefreshLa
});
} catch (PackageManager.NameNotFoundException ignored) {}
}
// Set Aurora Store
try {
if(!mPackageManager.getApplicationInfo(PACKAGE_NAME_AURORA_STORE, 0).enabled)
throw new PackageManager.NameNotFoundException();
addToHorizontalLayout(R.string.store, R.drawable.ic_frost_aurorastore_black_24dp)
.setOnClickListener(v -> {
Intent intent = new Intent();
intent.setClassName(PACKAGE_NAME_AURORA_STORE, ACTIVITY_NAME_AURORA_STORE);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra("INTENT_PACKAGE_NAME", mPackageName);
try {
startActivity(intent);
} catch (Exception ignored) {}
});
} catch (PackageManager.NameNotFoundException ignored) {}
}
private void setVerticalView() {
final Boolean isRootEnabled = (Boolean) AppPref.get(this, AppPref.PREF_ROOT_MODE_ENABLED, AppPref.TYPE_BOOLEAN);
// Paths and directories
mList.addItemWithTitle(getString(R.string.paths_and_directories), true);
mList.item_title.setTextColor(mAccentColor);
......
......@@ -74,6 +74,9 @@ public class MainActivity extends AppCompatActivity implements AdapterView.OnIte
SwipeRefreshLayout.OnRefreshListener {
public static final String EXTRA_PACKAGE_LIST = "EXTRA_PACKAGE_LIST";
public static final String EXTRA_LIST_NAME = "EXTRA_LIST_NAME";
private static final String PACKAGE_NAME_APK_UPDATER = "com.apkupdater";
private static final String ACTIVITY_NAME_APK_UPDATER = "com.apkupdater.activity.MainActivity";
/**
* A list of packages separated by \r\n. Debug apps should have a * after their package names.
*/
......@@ -202,6 +205,14 @@ public class MainActivity extends AppCompatActivity implements AdapterView.OnIte
if ((Boolean) AppPref.get(this, AppPref.PREF_ROOT_MODE_ENABLED, AppPref.TYPE_BOOLEAN)) {
sortByBlockedComponentMenu.setVisible(true);
} else sortByBlockedComponentMenu.setVisible(false);
MenuItem apkUpdaterMenu = menu.findItem(R.id.action_apk_updater);
try {
if(!getPackageManager().getApplicationInfo(PACKAGE_NAME_APK_UPDATER, 0).enabled)
throw new PackageManager.NameNotFoundException();
apkUpdaterMenu.setVisible(true);
} catch (PackageManager.NameNotFoundException e) {
apkUpdaterMenu.setVisible(false);
}
if (menu instanceof MenuBuilder) {
((MenuBuilder) menu).setOptionalIconsVisible(true);
}
......@@ -278,6 +289,23 @@ public class MainActivity extends AppCompatActivity implements AdapterView.OnIte
case R.id.action_app_usage:
Intent usageIntent = new Intent(this, AppUsageActivity.class);
startActivity(usageIntent);
return true;
case R.id.action_apk_updater:
try {
if(!getPackageManager().getApplicationInfo(PACKAGE_NAME_APK_UPDATER, 0).enabled)
throw new PackageManager.NameNotFoundException();
Intent intent = new Intent();
intent.setClassName(PACKAGE_NAME_APK_UPDATER, ACTIVITY_NAME_APK_UPDATER);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
startActivity(intent);
} catch (Exception ignored) {}
} catch (PackageManager.NameNotFoundException ignored) {}
return true;
case R.id.action_running_apps:
Intent runningAppsIntent = new Intent(this, RunningAppsActivity.class);
startActivity(runningAppsIntent);
return true;
default:
return super.onOptionsItemSelected(item);
}
......@@ -293,7 +321,7 @@ public class MainActivity extends AppCompatActivity implements AdapterView.OnIte
@Override
public void onLoadFinished(@NonNull Loader<List<ApplicationItem>> loader, List<ApplicationItem> applicationItems) {
mItemList = applicationItems;
sortApplicationList();
sortApplicationList(mSortBy);
mAdapter.setDefaultList(mItemList);
// Set title and subtitle
ActionBar actionBar = getSupportActionBar();
......@@ -370,7 +398,7 @@ public class MainActivity extends AppCompatActivity implements AdapterView.OnIte
*/
private void setSortBy(@SortOrder int sort) {
mSortBy = sort;
sortApplicationList();
sortApplicationList(mSortBy);
if (mAdapter != null)
mAdapter.notifyDataSetChanged();
......@@ -383,10 +411,11 @@ public class MainActivity extends AppCompatActivity implements AdapterView.OnIte
mListView.setFastScrollEnabled(mSortBy == SORT_BY_APP_LABEL);
}
private void sortApplicationList() {
private void sortApplicationList(@SortOrder int sortBy) {
final Boolean isRootEnabled = (Boolean) AppPref.get(this, AppPref.PREF_ROOT_MODE_ENABLED, AppPref.TYPE_BOOLEAN);
if (sortBy != SORT_BY_APP_LABEL) sortApplicationList(SORT_BY_APP_LABEL);
Collections.sort(mItemList, (o1, o2) -> {
switch (mSortBy) {
switch (sortBy) {
case SORT_BY_APP_LABEL:
return sCollator.compare(o1.label, o2.label);
case SORT_BY_PACKAGE_NAME:
......
package io.github.muntashirakon.AppManager.activities;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import io.github.muntashirakon.AppManager.R;
import io.github.muntashirakon.AppManager.appops.AppOpsManager;
import io.github.muntashirakon.AppManager.utils.Utils;
import android.app.Activity;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.jaredrummler.android.shell.CommandResult;
import com.jaredrummler.android.shell.Shell;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class RunningAppsActivity extends AppCompatActivity {
// "^(?<label>[^\\t\\s]+)[\\t\\s]+(?<pid>\\d+)[\\t\\s]+(?<ppid>\\d+)[\\t\\s]+(?<rss>\\d+)[\\t\\s]+(?<vsz>\\d+)[\\t\\s]+(?<user>[^\\t\\s]+)[\\t\\s]+(?<uid>\\d+)[\\t\\s]+(?<state>\\w)(?<stateplus>[\\w\\+<])?[\\t\\s]+(?<name>[^\\t\\s]+)$"
private static final Pattern PROCESS_MATCHER = Pattern.compile("^(?<label>[^\\t\\s]+)[\\t\\s]+(?<pid>\\d+)[\\t\\s]+(?<ppid>\\d+)[\\t\\s]+(?<rss>\\d+)[\\t\\s]+(?<vsz>\\d+)[\\t\\s]+(?<user>[^\\t\\s]+)[\\t\\s]+(?<uid>\\d+)[\\t\\s]+(?<state>\\w)(?<stateplus>[\\w\\+<])?[\\t\\s]+(?<name>[^\\t\\s]+)$");
private static String mConstraint;
private ListView mListView;
private RunningAppsAdapter mAdapter;
private PackageManager mPackageManager;
private ProgressBar mProgressBar;
static class ProcessItem {
int pid;
int ppid;
long rss;
long vsz;
String user;
int uid;
String state;
String state_extra;
String name;
ApplicationInfo applicationInfo = null;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_running_apps);
mPackageManager = getPackageManager();
mProgressBar = findViewById(R.id.progress_horizontal);
mListView = findViewById(android.R.id.list);
mListView.setTextFilterEnabled(true);
mListView.setDividerHeight(0);
mListView.setEmptyView(findViewById(android.R.id.empty));
mAdapter = new RunningAppsAdapter(this);
mListView.setAdapter(mAdapter);
new Thread(() -> {
List<ApplicationInfo> applicationInfoList = mPackageManager.getInstalledApplications(PackageManager.GET_META_DATA);
List<String> pkgNames = new ArrayList<>();
CommandResult result = Shell.SU.run("ps -dwZ -o PID,PPID,RSS,VSZ,USER,UID,STAT,NAME | grep -v :kernel:");
if (result.isSuccessful()) {
List<String> processInfoLines = result.stdout;
HashMap<String, ProcessItem> processList = new HashMap<>();
for (int i = 1; i<processInfoLines.size(); ++i) {
try {
ProcessItem processItem = parseProcess(processInfoLines.get(i));
processList.put(processItem.name, processItem);
} catch (Exception ignore) {}
}
Set<String> processNames = processList.keySet();
for (ApplicationInfo applicationInfo : applicationInfoList) {
if (processNames.contains(applicationInfo.packageName)) {
//noinspection ConstantConditions
processList.get(applicationInfo.packageName).applicationInfo = applicationInfo;
pkgNames.add(applicationInfo.loadLabel(mPackageManager).toString());
}
}
Collection<ProcessItem> processItemList = processList.values();
runOnUiThread(() -> {
mAdapter.setDefaultList(pkgNames);
mProgressBar.setVisibility(View.GONE);
});
}
}).start();
}
@NonNull
private static ProcessItem parseProcess(String line) throws Exception {
Matcher matcher = PROCESS_MATCHER.matcher(line);
if (matcher.find()) {
ProcessItem processItem = new ProcessItem();
//noinspection ConstantConditions
processItem.pid = Integer.parseInt(matcher.group(2));
//noinspection ConstantConditions
processItem.ppid = Integer.parseInt(matcher.group(3));
//noinspection ConstantConditions
processItem.rss = Integer.parseInt(matcher.group(4));
//noinspection ConstantConditions
processItem.vsz = Integer.parseInt(matcher.group(5));
processItem.user = matcher.group(6);
//noinspection ConstantConditions
processItem.uid = Integer.parseInt(matcher.group(7));
//noinspection ConstantConditions
processItem.state = Utils.getProcessStateName(matcher.group(8));
processItem.state_extra = Utils.getProcessStateExtraName(matcher.group(9));
processItem.name = matcher.group(10);
return processItem;
}
throw new Exception("Failed to parse line");
}
static class RunningAppsAdapter extends BaseAdapter implements Filterable {
private LayoutInflater mLayoutInflater;
private Filter mFilter;
private String mConstraint;
private List<String> mDefaultList;
private List<String> mAdapterList;
private int mColorTransparent;
private int mColorSemiTransparent;
private int mColorRed;
RunningAppsAdapter(@NonNull Activity activity) {
mLayoutInflater = activity.getLayoutInflater();
mColorTransparent = Color.TRANSPARENT;
mColorSemiTransparent = ContextCompat.getColor(activity, R.color.SEMI_TRANSPARENT);
mColorRed = ContextCompat.getColor(activity, R.color.red);
}
void setDefaultList(List<String> list) {
mDefaultList = list;
mAdapterList = list;
if(RunningAppsActivity.mConstraint != null
&& !RunningAppsActivity.mConstraint.equals("")) {
getFilter().filter(RunningAppsActivity.mConstraint);
}
notifyDataSetChanged();
}
@Override
public int getCount() {
return mAdapterList == null ? 0 : mAdapterList.size();
}
@Override
public String getItem(int position) {
return mAdapterList.get(position);
}
@Override
public long getItemId(int position) {
return mDefaultList.indexOf(mAdapterList.get(position));
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (convertView == null) {
convertView = mLayoutInflater.inflate(android.R.layout.simple_list_item_1, parent, false);
}
String className = mAdapterList.get(position);
TextView textView = (TextView) convertView;
if (mConstraint != null && className.toLowerCase(Locale.ROOT).contains(mConstraint)) {
// Highlight searched query
textView.setText(Utils.getHighlightedText(className, mConstraint, mColorRed));
} else {
textView.setText(className);
}
convertView.setBackgroundColor(position % 2 == 0 ? mColorSemiTransparent : mColorTransparent);
return convertView;
}
@Override
public Filter getFilter() {
if (mFilter == null)
mFilter = new Filter() {
@Override
protected FilterResults performFiltering(CharSequence charSequence) {
String constraint = charSequence.toString().toLowerCase(Locale.ROOT);
mConstraint = constraint;
FilterResults filterResults = new FilterResults();
if (constraint.length() == 0) {
filterResults.count = 0;
filterResults.values = null;
return filterResults;
}
List<String> list = new ArrayList<>(mDefaultList.size());
for (String item : mDefaultList) {
if (item.toLowerCase(Locale.ROOT).contains(constraint))
list.add(item);
}
filterResults.count = list.size();
filterResults.values = list;
return filterResults;
}
@Override
protected void publishResults(CharSequence charSequence, FilterResults filterResults) {
if (filterResults.values == null) {
mAdapterList = mDefaultList;
} else {
//noinspection unchecked
mAdapterList = (List<String>) filterResults.values;
}
notifyDataSetChanged();
}
};
return mFilter;
}
}
}
......@@ -11,6 +11,7 @@ import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import io.github.muntashirakon.AppManager.R;
import io.github.muntashirakon.AppManager.compontents.ComponentsBlocker;
import io.github.muntashirakon.AppManager.fragments.ImportExportDialogFragment;
import io.github.muntashirakon.AppManager.utils.AppPref;
public class SettingsActivity extends AppCompatActivity {
......@@ -52,6 +53,15 @@ public class SettingsActivity extends AppCompatActivity {
});
usageSwitcher.setOnCheckedChangeListener((buttonView, isChecked) ->
appPref.setPref(AppPref.PREF_USAGE_ACCESS_ENABLED, isChecked));
// Import/Export
if ((Boolean) appPref.getPref(AppPref.PREF_ROOT_MODE_ENABLED, AppPref.TYPE_BOOLEAN)) {
findViewById(R.id.import_view).setOnClickListener(v ->
(new ImportExportDialogFragment()).show(getSupportFragmentManager(),
ImportExportDialogFragment.TAG));
} else {
findViewById(R.id.import_view).setVisibility(View.GONE);
}
}
@Override
......
......@@ -92,7 +92,7 @@ class AppOpsService implements IAppOpsService {
String name = String.format("%s: %s", AppOpsManager.opToName(op), output.get(1).substring(DEFAULT_MODE_SKIP));
lines.add(name);
}
if (!isSuccessful) throw new Exception("Failed to get operations for package " + packageName);
// if (!isSuccessful) throw new Exception("Failed to get operations for package " + packageName);
}
}
List<AppOpsManager.OpEntry> opEntries = new ArrayList<>();
......
......@@ -77,7 +77,7 @@ public class ComponentsBlocker {
}
@NonNull
private static String provideLocalIfwRulesPath(@NonNull Context context) throws FileNotFoundException {
public static String provideLocalIfwRulesPath(@NonNull Context context) throws FileNotFoundException {
File file = context.getExternalFilesDir("ifw");
if (file == null || (!file.exists() && !file.mkdirs())) {
file = new File(context.getFilesDir(), "ifw");
......
package io.github.muntashirakon.AppManager.compontents;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.FileUtils;
import android.util.Log;
import com.google.classysharkandroid.utils.IOUtils;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.List;
import androidx.annotation.NonNull;
import io.github.muntashirakon.AppManager.utils.Tuple;
import io.github.muntashirakon.AppManager.utils.Utils;
import static io.github.muntashirakon.AppManager.compontents.ComponentsBlocker.ComponentType;
/**
* Import components from external apps like Blocker, MyAndroidTools, Watt
*/
public class ExternalComponentsImporter {
@NonNull
public static Tuple<Boolean, Integer> applyFromBlocker(@NonNull Context context, @NonNull List<Uri> uriList) {
boolean failed = false;
Integer failedCount = 0;
for(Uri uri: uriList) {
try {
applyFromBlocker(context, uri);
} catch (Exception e) {
e.printStackTrace();
failed = true;
++failedCount;
}
}
return new Tuple<>(failed, failedCount);
}
@NonNull
public static Tuple<Boolean, Integer> applyFromWatt(@NonNull Context context, @NonNull List<Uri> uriList) {
boolean failed = false;
Integer failedCount = 0;
for(Uri uri: uriList) {
try {
String packageName = applyFromWatt(context, uri);
ComponentsBlocker.getInstance(context, packageName).applyRules(true);
} catch (FileNotFoundException e) {
e.printStackTrace();
failed = true;
++failedCount;
}
}
return new Tuple<>(failed, failedCount);
}
/**
* Watt only supports IFW, so copy them directly
* @param context Application context
* @param fileUri File URI
*/
@NonNull
private static String applyFromWatt(@NonNull Context context, Uri fileUri) throws FileNotFoundException {
String filename = Utils.getName(context.getContentResolver(), fileUri);
if (filename == null) throw new FileNotFoundException("The requested content is not found.");
File amFile = new File(ComponentsBlocker.provideLocalIfwRulesPath(context) + "/" + filename);
InputStream inputStream = context.getContentResolver().openInputStream(fileUri);
if (inputStream == null) throw new FileNotFoundException("The requested content is not found.");
OutputStream outputStream = new FileOutputStream(amFile);
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
FileUtils.copy(inputStream, outputStream);
} else {
IOUtils.copy(inputStream, outputStream);
}
inputStream.close();
outputStream.close();
return Utils.trimExtension(filename);
} catch (IOException e) {
throw new FileNotFoundException(e.getMessage());
}
}
/**
* Apply from blocker
* FIXME: Retrieve component types using PackageInfo instead of json file
* @param context Application context
* @param uri File URI
*/
public static void applyFromBlocker(@NonNull Context context, Uri uri)
throws Exception {
try {
String jsonString = Utils.getFileContent(context.getContentResolver(), uri);
HashMap<String, HashMap<String, ComponentType>> packageComponents = new HashMap<>();
JSONObject jsonObject = new JSONObject(jsonString);
JSONArray components = jsonObject.getJSONArray("components");
for(int i = 0; i<components.length(); ++i) {
JSONObject component = (JSONObject) components.get(i);
String packageName = component.getString("packageName");
String componentName = component.getString("name");
String type = component.getString("type");
if (!packageComponents.containsKey(packageName))
packageComponents.put(packageName, new HashMap<>());
//noinspection ConstantConditions
packageComponents.get(packageName).put(componentName, getTypeFromString(type));
}
if (packageComponents.size() > 0) {
ComponentsBlocker blocker;
for (String packageName: packageComponents.keySet()) {
HashMap<String, ComponentType> disabledComponents = packageComponents.get(packageName);
//noinspection ConstantConditions
if (disabledComponents.size() > 0) {
blocker = ComponentsBlocker.getInstance(context, packageName);
for (String component: disabledComponents.keySet()) {
blocker.addComponent(component, disabledComponents.get(component));
}
blocker.applyRules(true);
if (!blocker.isRulesApplied()) throw new Exception("Rules not applied for package " + packageName);
}
}
}
} catch (Exception e) {
throw new Exception(e.getMessage());
}
}
@NonNull
private static ComponentType getTypeFromString(@NonNull String strType) {
switch (strType) {
case "ACTIVITY": return ComponentType.ACTIVITY;
case "PROVIDER": return ComponentType.PROVIDER;
case "RECEIVER": return ComponentType.RECEIVER;
case "SERVICE": return ComponentType.SERVICE;
}
return ComponentType.UNKNOWN;
}
}
......@@ -26,7 +26,7 @@ import androidx.fragment.app.DialogFragment;
import io.github.muntashirakon.AppManager.R;
public class IconPickerDialogFragment extends DialogFragment {
static final String TAG = "IconPickerDialogFragment";
public static final String TAG = "IconPickerDialogFragment";
private IconPickerListener listener = null;
private IconListingAdapter adapter;
......@@ -41,8 +41,6 @@ public class IconPickerDialogFragment extends DialogFragment {
getActivity().runOnUiThread(() -> IconPickerDialogFragment.this.adapter.notifyDataSetChanged());
}
}).start();
// IconListingAsyncTask provider = new IconListingAsyncTask(this.getActivity());
// provider.execute();
}
void attachIconPickerListener(IconPickerListener listener) {
......@@ -53,13 +51,9 @@ public class IconPickerDialogFragment extends DialogFragment {
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
assert getActivity() != null;
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity(), R.style.CustomDialog);
if (getActivity() == null) return builder.create();
if (getActivity() == null) return super.onCreateDialog(savedInstanceState);
LayoutInflater inflater = (LayoutInflater) getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
if (inflater == null) return builder.create();
if (inflater == null) return super.onCreateDialog(savedInstanceState);
GridView grid = (GridView) inflater.inflate(R.layout.dialog_icon_picker, null);
grid.setAdapter(adapter);
grid.setOnItemClickListener((view, item, index, id) -> {
......@@ -68,7 +62,7 @@ public class IconPickerDialogFragment extends DialogFragment {
if (getDialog() != null) getDialog().dismiss();
}
});
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity(), R.style.CustomDialog);
builder.setTitle(R.string.icon_picker)
.setView(grid)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
......
package io.github.muntashirakon.AppManager.fragments;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import io.github.muntashirakon.AppManager.R;
import io.github.muntashirakon.AppManager.compontents.ExternalComponentsImporter;
import io.github.muntashirakon.AppManager.utils.Tuple;
public class ImportExportDialogFragment extends DialogFragment {
public static final String TAG = "ImportExportDialogFragment";
private static final int RESULT_CODE_WATT = 711;
private static final int RESULT_CODE_BLOCKER = 459;
private Context context;
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
if (getActivity() == null) return super.onCreateDialog(savedInstanceState);
context = getActivity();
LayoutInflater inflater = (LayoutInflater) getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
if (inflater == null) return super.onCreateDialog(savedInstanceState);
@SuppressLint("InflateParams")
View view = inflater.inflate(R.layout.dialog_settings_import_export, null);
view.findViewById(R.id.import_watt).setOnClickListener(v -> {
Intent intent = new Intent()
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("text/xml")
.setAction(Intent.ACTION_GET_CONTENT);
startActivityForResult(Intent.createChooser(intent, getString(R.string.select_files)), RESULT_CODE_WATT);
});
view.findViewById(R.id.import_blocker).setOnClickListener(v -> {
Intent intent = new Intent()
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/json")
.setAction(Intent.ACTION_GET_CONTENT);
startActivityForResult(Intent.createChooser(intent, getString(R.string.select_files)), RESULT_CODE_BLOCKER);
});
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity(), R.style.CustomDialog);
builder.setView(view)
.setTitle(R.string.pref_import_export)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
if (getDialog() != null) getDialog().cancel();
});
return builder.create();
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (resultCode == Activity.RESULT_OK) {
if (requestCode == RESULT_CODE_WATT) {
if (data != null) {
List<Uri> uriList = new ArrayList<>();
if (data.getClipData() != null) {
for (int index = 0; index < data.getClipData().getItemCount(); index++) {
uriList.add(data.getClipData().getItemAt(index).getUri());
}
} else uriList.add(data.getData());
Tuple<Boolean, Integer> status = ExternalComponentsImporter.applyFromWatt(
context.getApplicationContext(), uriList);
if (!status.getFirst()) { // Not failed
Toast.makeText(getContext(), R.string.the_import_was_successful,
Toast.LENGTH_LONG).show();
} else {
Toast.makeText(getContext(), String.format(Locale.getDefault(),
getString(R.string.failed_to_import_files), status.getSecond()),
Toast.LENGTH_LONG).show();
}
if (getDialog() != null) getDialog().cancel();
}
} else if (requestCode == RESULT_CODE_BLOCKER) {
if (data != null) {
List<Uri> uriList = new ArrayList<>();
if (data.getClipData() != null) {
for (int index = 0; index < data.getClipData().getItemCount(); index++) {
uriList.add(data.getClipData().getItemAt(index).getUri());
}
} else uriList.add(data.getData());
Tuple<Boolean, Integer> status = ExternalComponentsImporter.applyFromBlocker(
context.getApplicationContext(), uriList);
if (!status.getFirst()) { // Not failed
Toast.makeText(getContext(), R.string.the_import_was_successful,
Toast.LENGTH_LONG).show();
} else {
Toast.makeText(getContext(), String.format(Locale.getDefault(),
getString(R.string.failed_to_import_files), status.getSecond()),
Toast.LENGTH_LONG).show();
}
if (getDialog() != null) getDialog().cancel();
}
}
}
}
}
......@@ -8,6 +8,7 @@ import androidx.annotation.NonNull;
public class AppDetailsItem {
public @NonNull Object vanillaItem;
public @NonNull String name = "";
public boolean isBlocked = false;
public AppDetailsItem(@NonNull Object object) {
vanillaItem = object;
......
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?android:attr/colorAccent"
android:pathData="M12,4L12,1L8,5l4,4L12,6c3.31,0 6,2.69 6,6 0,1.01 -0.25,1.97 -0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12c0,-4.42 -3.58,-8 -8,-8zM12,18c-3.31,0 -6,-2.69 -6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4 -4,-4v3z"/>
</vector>
<vector android:height="24dp" android:viewportHeight="48"
android:viewportWidth="48" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?android:attr/colorAccent" android:pathData="m24,4.4843c-0.7298,0 -1.4586,0.3569 -1.8854,1.0697l-8.3394,13.985h-8.4308c-2.2005,0.001 -3.8007,2.091 -3.227,4.2154l4.7363,17.51c0.3548,1.3312 1.5622,2.2563 2.9399,2.2513h28.413c1.3776,0.0051 2.5851,-0.9201 2.9399,-2.2513l4.7363,-17.51c0.5737,-2.1244 -1.0265,-4.2144 -3.227,-4.2154h-8.4308l-8.3394,-13.985c-0.4268,-0.7128 -1.1556,-1.0697 -1.8854,-1.0697zM24,10.9535 L29.1251,19.5393h-10.25zM23.9212,23.7445a2.1726,2.1726 0,0 1,1.9794 1.1155l6.3219,11.175a2.2246,2.2246 0,0 1,0 2.1852,2.1986 2.1986,0 0,1 -1.9006,1.0926h-12.672a2.1986,2.1986 0,0 1,-1.8981 -1.0926,2.2246 2.2246,0 0,1 0,-2.1852l6.3473,-11.175a2.1726,2.1726 0,0 1,1.8219 -1.1155zM24,30.3763 L21.3981,34.9297h5.2038z"/>
</vector>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".activities.RunningAppsActivity">
<ProgressBar
android:id="@+id/progress_horizontal"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:paddingStart="@dimen/padding_medium"
android:paddingEnd="@dimen/padding_medium" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@android:id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fastScrollEnabled="true" />
<TextView
android:id="@android:id/empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/no_tracker_class" />
</FrameLayout>
</LinearLayout>
\ No newline at end of file
......@@ -139,4 +139,35 @@
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/import_view"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_medium"
android:paddingTop="@dimen/padding_very_small"
android:paddingEnd="@dimen/padding_medium"
android:paddingBottom="@dimen/padding_very_small"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:text="@string/pref_import_export"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_import_export_msg"
android:textSize="12sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:id="@+id/import_watt"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_medium"
android:paddingTop="@dimen/padding_small"
android:paddingEnd="@dimen/padding_medium"
android:paddingBottom="@dimen/padding_small"
android:textColor="?android:attr/textColorPrimary"
android:text="@string/pref_import_watt"
android:textSize="20sp" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/import_blocker"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_medium"
android:paddingTop="@dimen/padding_small"
android:paddingEnd="@dimen/padding_medium"
android:paddingBottom="@dimen/padding_small"
android:textColor="?android:attr/textColorPrimary"
android:text="@string/pref_import_blocker"
android:textSize="20sp" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
\ No newline at end of file
......@@ -67,6 +67,16 @@
android:icon="@drawable/ic_data_usage_black_24dp"
android:title="@string/app_usage" />
<item
android:id="@+id/action_running_apps"
android:icon="@drawable/ic_tune_black_24dp"
android:title="@string/running_apps" />
<item
android:id="@+id/action_apk_updater"
android:icon="@drawable/ic_baseline_sync_24"
android:title="@string/apk_updater" />
<item
android:id="@+id/action_settings"
android:icon="@drawable/ic_baseline_settings_24"
......
......@@ -306,4 +306,14 @@
<string name="sort_by_screen_time">Screen time</string>
<string name="sort_by_times_opened">Times opened</string>
<string name="sort_by_mobile_data">Mobile data</string>
<string name="pref_import_export">Import</string>
<string name="pref_import_export_msg">Import configurations from Watt or Blocker</string>
<string name="pref_import_watt">Import from Watt</string>
<string name="pref_import_blocker">Import from Blocker</string>
<string name="select_files">Select file(s)</string>
<string name="the_import_was_successful">The import was successful</string>
<string name="failed_to_import_files">Failed to import %d files.</string>
<string name="apk_updater">APK Updater</string>
<string name="store">Store</string>
<string name="running_apps">Running Apps</string>
</resources>
#Fri Jun 26 08:40:21 BDT 2020
VERSION_NAME=2.5.4
VERSION_CODE=196
#Tue Jun 30 10:26:30 BDT 2020
VERSION_NAME=2.5.5
VERSION_CODE=209
- App Usage UI is improved by adding data usage, times opened and sorting capabilities
- Added the ability to import from Blocker and Watt (in Settings)
- Added sort by disabled app and blocked components in the main window
- Display external data directories in App Info
- Fixed data usage since last boot in App Info
- Sorting preference is now saved permanently (main window only)