diff --git a/app/build.gradle b/app/build.gradle index 693a3807..927fee1a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -108,11 +108,13 @@ dependencies { implementation "com.android.support:support-v4:${version_library_appcompat}" implementation "com.android.support:customtabs:${version_library_appcompat}" implementation "com.android.support:cardview-v7:${version_library_appcompat}" + implementation "com.android.support:preference-v7:${version_library_appcompat}" // UI libraries implementation "com.github.DASAR:ShiftColorPicker:v0.5" // Tool libraries + implementation 'commons-io:commons-io:2.6' implementation "info.guardianproject.netcipher:netcipher:${version_library_netcipher}" implementation "info.guardianproject.netcipher:netcipher-webkit:${version_library_netcipher}" implementation "com.jakewharton:butterknife:${version_library_butterknife}" diff --git a/app/src/main/java/net/gsantner/opoc/activity/GsFragmentBase.java b/app/src/main/java/net/gsantner/opoc/activity/GsFragmentBase.java index cab23816..507d25ff 100644 --- a/app/src/main/java/net/gsantner/opoc/activity/GsFragmentBase.java +++ b/app/src/main/java/net/gsantner/opoc/activity/GsFragmentBase.java @@ -16,7 +16,10 @@ import android.support.annotation.LayoutRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; +import android.util.Log; import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; @@ -33,6 +36,7 @@ public abstract class GsFragmentBase extends Fragment { protected ContextUtils _cu; protected Bundle _savedInstanceState = null; + protected Menu _fragmentMenu; @Override public void onCreate(Bundle savedInstanceState) { @@ -51,6 +55,9 @@ public abstract class GsFragmentBase extends Fragment { _cu = new ContextUtils(inflater.getContext()); _cu.setAppLanguage(getAppLanguage()); _savedInstanceState = savedInstanceState; + if (getLayoutResId() == 0) { + Log.e(getClass().getCanonicalName(), "Error: GsFragmentbase.onCreateview: Returned 0 for getLayoutResId"); + } View view = inflater.inflate(getLayoutResId(), container, false); ButterKnife.bind(this, view); return view; @@ -126,4 +133,14 @@ public abstract class GsFragmentBase extends Fragment { } } } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + _fragmentMenu = menu; + } + + public Menu getFragmentMenu() { + return _fragmentMenu; + } } diff --git a/app/src/main/java/net/gsantner/opoc/preference/PropertyBackend.java b/app/src/main/java/net/gsantner/opoc/preference/PropertyBackend.java index 908176cf..0847021b 100644 --- a/app/src/main/java/net/gsantner/opoc/preference/PropertyBackend.java +++ b/app/src/main/java/net/gsantner/opoc/preference/PropertyBackend.java @@ -10,8 +10,6 @@ #########################################################*/ package net.gsantner.opoc.preference; -import java.util.Calendar; -import java.util.Date; import java.util.List; @SuppressWarnings({"UnusedReturnValue", "SpellCheckingInspection", "unused", "SameParameterValue"}) diff --git a/app/src/main/java/net/gsantner/opoc/preference/SharedPreferencesPropertyBackend.java b/app/src/main/java/net/gsantner/opoc/preference/SharedPreferencesPropertyBackend.java index 6fe97a07..cb0c6ae8 100644 --- a/app/src/main/java/net/gsantner/opoc/preference/SharedPreferencesPropertyBackend.java +++ b/app/src/main/java/net/gsantner/opoc/preference/SharedPreferencesPropertyBackend.java @@ -532,7 +532,7 @@ public class SharedPreferencesPropertyBackend implements PropertyBackend= 23 || begin < 0) ? 0 : begin; end = (end >= 23 || end < 0) ? 0 : end; int h = Calendar.getInstance().get(Calendar.HOUR_OF_DAY); @@ -559,7 +559,7 @@ public class SharedPreferencesPropertyBackend implements PropertyBackend dialogInterface.dismiss()); if (dopt.isSearchEnabled) { dialogBuilder.setPositiveButton(dopt.okButtonText, (dialogInterface, i) -> { dialogInterface.dismiss(); @@ -186,9 +199,124 @@ public class SearchOrCustomTextDialog { return false; }); - if (dialog.getWindow() != null) { - dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + + Window w; + if ((w = dialog.getWindow()) != null && dopt.isSearchEnabled) { + w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); } dialog.show(); + if ((w = dialog.getWindow()) != null) { + w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT); + } + + if (dopt.isSearchEnabled) { + searchEditText.requestFocus(); + } + } + + + public static SearchFilesTask recursiveFileSearch(Activity activity, File searchDir, String query, Callback.a1> callback) { + query = query.replaceAll("(?> implements IOFileFilter { + private final Callback.a1> _callback; + private final File _searchDir; + private final String _query; + private final boolean _isRegex; + private final WeakReference _activityRef; + + private final Pattern _regex; + private Snackbar _snackBar; + + public SearchFilesTask(Activity activity, File searchDir, String query, Callback.a1> callback, boolean isRegex) { + _searchDir = searchDir; + _query = isRegex ? query : query.toLowerCase(); + _callback = callback; + _isRegex = isRegex; + _regex = isRegex ? Pattern.compile(_query) : null; + _activityRef = new WeakReference<>(activity); + } + + // Called for both, file and folder filter + @Override + public boolean accept(File file) { + return isMatching(file, true); + } + + // Not called + @Override + public boolean accept(File dir, String name) { + return isMatching(new File(dir, name), true); + } + + // In iterateFilesAndDirs, subdirs are only scanned when returning true on it + // But those dirs will also occur in iterator + // Hence call this aagain with alwaysMatchDir=false + public boolean isMatching(File file, boolean alwaysMatchDir) { + if (file.isDirectory()) { + // Do never scan .git directories, lots of files, lots of time + if (file.getName().equals(".git")) { + return false; + } + if (alwaysMatchDir) { + return true; + } + } + String name = file.getName(); + file = file.getParentFile(); + return _isRegex ? _regex.matcher(name).matches() : name.toLowerCase().contains(_query); + } + + @Override + protected void onPreExecute() { + super.onPreExecute(); + if (_activityRef.get() != null) { + _snackBar = Snackbar.make(_activityRef.get().findViewById(android.R.id.content), _query + "...", Snackbar.LENGTH_INDEFINITE); + _snackBar.setAction(android.R.string.cancel, (v) -> { + _snackBar.dismiss(); + cancel(true); + }).show(); + } + } + + @Override + protected List doInBackground(Void... voidp) { + List ret = new ArrayList<>(); + + boolean first = true; + Iterator iter = FileUtils.iterateFilesAndDirs(_searchDir, this, this); + while (iter.hasNext() && !isCancelled()) { + File f = iter.next(); + if (first) { + first = false; + if (f.equals(_searchDir)) { + continue; + } + } + if (f.isFile() || (f.isDirectory() && isMatching(f, false))) { + ret.add(f.getAbsolutePath().replace(_searchDir.getAbsolutePath() + "/", "")); + } + } + return ret; + } + + @Override + protected void onPostExecute(List ret) { + super.onPostExecute(ret); + if (_snackBar != null) { + _snackBar.dismiss(); + } + if (_callback != null) { + try { + _callback.callback(ret); + } catch (Exception ignored) { + } + } + new ActivityUtils(_activityRef.get()).hideSoftKeyboard().freeContextRef(); + } } } diff --git a/app/src/main/java/net/gsantner/opoc/util/ActivityUtils.java b/app/src/main/java/net/gsantner/opoc/util/ActivityUtils.java index b7d60918..7836b2ee 100644 --- a/app/src/main/java/net/gsantner/opoc/util/ActivityUtils.java +++ b/app/src/main/java/net/gsantner/opoc/util/ActivityUtils.java @@ -17,8 +17,11 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; +import android.content.res.TypedArray; import android.net.Uri; import android.os.Build; +import android.provider.CalendarContract; +import android.support.annotation.ColorInt; import android.support.annotation.StringRes; import android.support.design.widget.Snackbar; import android.support.v4.content.ContextCompat; @@ -47,6 +50,12 @@ public class ActivityUtils extends net.gsantner.opoc.util.ContextUtils { _activity = activity; } + @Override + public void freeContextRef() { + super.freeContextRef(); + _activity = null; + } + //######################## //## Methods //######################## @@ -85,9 +94,11 @@ public class ActivityUtils extends net.gsantner.opoc.util.ContextUtils { } - public void showSnackBar(@StringRes int stringResId, boolean showLong) { - Snackbar.make(_activity.findViewById(android.R.id.content), stringResId, - showLong ? Snackbar.LENGTH_LONG : Snackbar.LENGTH_SHORT).show(); + public Snackbar showSnackBar(@StringRes int stringResId, boolean showLong) { + Snackbar s = Snackbar.make(_activity.findViewById(android.R.id.content), stringResId, + showLong ? Snackbar.LENGTH_LONG : Snackbar.LENGTH_SHORT); + s.show(); + return s; } public void showSnackBar(@StringRes int stringResId, boolean showLong, @StringRes int actionResId, View.OnClickListener listener) { @@ -97,18 +108,58 @@ public class ActivityUtils extends net.gsantner.opoc.util.ContextUtils { .show(); } - public void hideSoftKeyboard() { - InputMethodManager imm = (InputMethodManager) _activity.getSystemService(Activity.INPUT_METHOD_SERVICE); - if (imm != null && _activity.getCurrentFocus() != null && _activity.getCurrentFocus().getWindowToken() != null) { - imm.hideSoftInputFromWindow(_activity.getCurrentFocus().getWindowToken(), 0); + public ActivityUtils setSoftKeyboardVisibile(boolean visible, View... editView) { + final Activity activity = _activity; + if (activity != null) { + final View v = (editView != null && editView.length > 0) ? (editView[0]) : (activity.getCurrentFocus() != null && activity.getCurrentFocus().getWindowToken() != null ? activity.getCurrentFocus() : null); + final InputMethodManager imm = (InputMethodManager) activity.getSystemService(Activity.INPUT_METHOD_SERVICE); + if (v != null && imm != null) { + Runnable r = () -> { + if (visible) { + v.requestFocus(); + imm.showSoftInput(v, InputMethodManager.SHOW_FORCED); + } else { + imm.hideSoftInputFromWindow(v.getWindowToken(), 0); + } + }; + r.run(); + for (int d : new int[]{100, 350}) { + v.postDelayed(r, d); + } + } } + return this; } - public void showSoftKeyboard() { - InputMethodManager imm = (InputMethodManager) _activity.getSystemService(Activity.INPUT_METHOD_SERVICE); - if (imm != null && _activity.getCurrentFocus() != null && _activity.getCurrentFocus().getWindowToken() != null) { - imm.showSoftInput(_activity.getCurrentFocus(), InputMethodManager.SHOW_FORCED); + public ActivityUtils hideSoftKeyboard() { + if (_activity != null) { + InputMethodManager imm = (InputMethodManager) _activity.getSystemService(Activity.INPUT_METHOD_SERVICE); + if (imm != null && _activity.getCurrentFocus() != null && _activity.getCurrentFocus().getWindowToken() != null) { + imm.hideSoftInputFromWindow(_activity.getCurrentFocus().getWindowToken(), 0); + } } + return this; + } + + public ActivityUtils showSoftKeyboard() { + if (_activity != null) { + InputMethodManager imm = (InputMethodManager) _activity.getSystemService(Activity.INPUT_METHOD_SERVICE); + if (imm != null && _activity.getCurrentFocus() != null && _activity.getCurrentFocus().getWindowToken() != null) { + showSoftKeyboard(_activity.getCurrentFocus()); + } + } + return this; + } + + + public ActivityUtils showSoftKeyboard(View textInputView) { + if (_activity != null) { + InputMethodManager imm = (InputMethodManager) _activity.getSystemService(Activity.INPUT_METHOD_SERVICE); + if (imm != null && textInputView != null) { + imm.showSoftInput(textInputView, InputMethodManager.SHOW_FORCED); + } + } + return this; } public void showDialogWithHtmlTextView(@StringRes int resTitleId, String html) { @@ -142,7 +193,7 @@ public class ActivityUtils extends net.gsantner.opoc.util.ContextUtils { } // Toggle with no param, else set visibility according to first bool - public void toggleStatusbarVisibility(boolean... optionalForceVisible) { + public ActivityUtils toggleStatusbarVisibility(boolean... optionalForceVisible) { WindowManager.LayoutParams attrs = _activity.getWindow().getAttributes(); int flag = WindowManager.LayoutParams.FLAG_FULLSCREEN; if (optionalForceVisible.length == 0) { @@ -153,9 +204,10 @@ public class ActivityUtils extends net.gsantner.opoc.util.ContextUtils { attrs.flags |= flag; } _activity.getWindow().setAttributes(attrs); + return this; } - public void showGooglePlayEntryForThisApp() { + public ActivityUtils showGooglePlayEntryForThisApp() { String pkgId = "details?id=" + _activity.getPackageName(); Intent goToMarket = new Intent(Intent.ACTION_VIEW, Uri.parse("market://" + pkgId)); goToMarket.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY | @@ -167,9 +219,10 @@ public class ActivityUtils extends net.gsantner.opoc.util.ContextUtils { _activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/" + pkgId))); } + return this; } - public void setStatusbarColor(int color, boolean... fromRes) { + public ActivityUtils setStatusbarColor(int color, boolean... fromRes) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (fromRes != null && fromRes.length > 0 && fromRes[0]) { color = ContextCompat.getColor(_context, color); @@ -177,13 +230,55 @@ public class ActivityUtils extends net.gsantner.opoc.util.ContextUtils { _activity.getWindow().setStatusBarColor(color); } + return this; } - public void setLauncherActivityEnabled(Class activityClass, boolean enable) { + public ActivityUtils setLauncherActivityEnabled(Class activityClass, boolean enable) { Context context = _context.getApplicationContext(); PackageManager pkg = context.getPackageManager(); ComponentName component = new ComponentName(context, activityClass); - pkg.setComponentEnabledSetting(component, enable ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED - , PackageManager.DONT_KILL_APP); + pkg.setComponentEnabledSetting(component, enable ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + return this; + } + + + @ColorInt + public Integer getCurrentPrimaryColor() { + TypedValue typedValue = new TypedValue(); + _context.getTheme().resolveAttribute(getResId(ResType.ATTR, "colorPrimary"), typedValue, true); + return typedValue.data; + } + + @ColorInt + public Integer getCurrentPrimaryDarkColor() { + TypedValue typedValue = new TypedValue(); + _context.getTheme().resolveAttribute(getResId(ResType.ATTR, "colorPrimaryDark"), typedValue, true); + return typedValue.data; + } + + @ColorInt + public Integer getCurrentAccentColor() { + TypedValue typedValue = new TypedValue(); + _context.getTheme().resolveAttribute(getResId(ResType.ATTR, "colorAccent"), typedValue, true); + return typedValue.data; + } + + @ColorInt + public Integer getActivityBackgroundColor() { + TypedArray array = _activity.getTheme().obtainStyledAttributes(new int[]{ + android.R.attr.colorBackground, + }); + int c = array.getColor(0, 0xFF0000); + array.recycle(); + return c; + } + + public ActivityUtils startCalendarApp() { + Uri.Builder builder = CalendarContract.CONTENT_URI.buildUpon(); + builder.appendPath("time"); + builder.appendPath(Long.toString(System.currentTimeMillis())); + Intent intent = new Intent(Intent.ACTION_VIEW, builder.build()); + _activity.startActivity(intent); + return this; } } diff --git a/app/src/main/java/net/gsantner/opoc/util/Callback.java b/app/src/main/java/net/gsantner/opoc/util/Callback.java index f7933e29..c07f883c 100644 --- a/app/src/main/java/net/gsantner/opoc/util/Callback.java +++ b/app/src/main/java/net/gsantner/opoc/util/Callback.java @@ -31,4 +31,24 @@ public class Callback { public interface a5 { void callback(A arg1, B arg2, C arg3, D arg4, E arg5); } + + public interface b1 { + boolean callback(A arg1); + } + + public interface b2 { + boolean callback(A arg1, B arg2); + } + + public interface b3 { + boolean callback(A arg1, B arg2, C arg3); + } + + public interface b4 { + boolean callback(A arg1, B arg2, C arg3, D arg4); + } + + public interface b5 { + boolean callback(A arg1, B arg2, C arg3, D arg4, E arg5); + } } diff --git a/app/src/main/java/net/gsantner/opoc/util/ContextUtils.java b/app/src/main/java/net/gsantner/opoc/util/ContextUtils.java index 8e7fae98..9f3b6221 100644 --- a/app/src/main/java/net/gsantner/opoc/util/ContextUtils.java +++ b/app/src/main/java/net/gsantner/opoc/util/ContextUtils.java @@ -38,6 +38,7 @@ import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.net.Uri; import android.os.Build; +import android.os.Environment; import android.os.SystemClock; import android.support.annotation.ColorInt; import android.support.annotation.ColorRes; @@ -48,6 +49,7 @@ import android.support.annotation.StringRes; import android.support.graphics.drawable.VectorDrawableCompat; import android.support.v4.content.ContextCompat; import android.support.v4.graphics.drawable.DrawableCompat; +import android.support.v4.util.Pair; import android.text.Html; import android.text.InputFilter; import android.text.SpannableString; @@ -74,6 +76,8 @@ import java.io.IOException; import java.io.InputStreamReader; import java.lang.reflect.Method; import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; @@ -94,6 +98,9 @@ public class ContextUtils { return _context; } + public void freeContextRef() { + _context = null; + } // // Class Methods @@ -171,15 +178,23 @@ public class ContextUtils { return String.format(a ? "#%08X" : "#%06X", (a ? 0xFFFFFFFF : 0xFFFFFF) & intColor); } + public String getAndroidVersion() { + return Build.VERSION.RELEASE + " (" + Build.VERSION.SDK_INT + ")"; + } + public String getAppVersionName() { + PackageManager manager = _context.getPackageManager(); try { - PackageManager manager = _context.getPackageManager(); PackageInfo info = manager.getPackageInfo(getPackageIdManifest(), 0); return info.versionName; } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - return "?"; + try { + PackageInfo info = manager.getPackageInfo(getPackageIdReal(), 0); + return info.versionName; + } catch (PackageManager.NameNotFoundException ignored) { + } } + return "?"; } public String getAppInstallationSource() { @@ -519,12 +534,76 @@ public class ContextUtils { /** * Get the private directory for the current package (usually /data/data/package.name/) */ - public String getAppDataDir() { + @SuppressWarnings("StatementWithEmptyBody") + public File getAppDataPrivateDir() { + File filesDir; try { - return _context.getPackageManager().getPackageInfo(getPackageIdReal(), 0).applicationInfo.dataDir; + filesDir = new File(new File(_context.getPackageManager().getPackageInfo(getPackageIdReal(), 0).applicationInfo.dataDir), "files"); } catch (PackageManager.NameNotFoundException e) { - return _context.getFilesDir().getParent(); + filesDir = _context.getFilesDir(); } + if (!filesDir.exists() && filesDir.mkdirs()) ; + return filesDir; + } + + /** + * Get public (accessible) appdata folders + */ + @SuppressWarnings("StatementWithEmptyBody") + public List> getAppDataPublicDirs(boolean internalStorageFolder, boolean sdcardFolders, boolean storageNameWithoutType) { + List> dirs = new ArrayList<>(); + for (File externalFileDir : ContextCompat.getExternalFilesDirs(_context, null)) { + if (externalFileDir == null || Environment.getExternalStorageDirectory() == null) { + continue; + } + boolean isInt = externalFileDir.getAbsolutePath().startsWith(Environment.getExternalStorageDirectory().getAbsolutePath()); + boolean add = (internalStorageFolder && isInt) || (sdcardFolders && !isInt); + if (add) { + dirs.add(new Pair<>(externalFileDir, getStorageName(externalFileDir, storageNameWithoutType))); + if (!externalFileDir.exists() && externalFileDir.mkdirs()) ; + } + } + return dirs; + } + + public String getStorageName(File externalFileDir, boolean storageNameWithoutType) { + boolean isInt = externalFileDir.getAbsolutePath().startsWith(Environment.getExternalStorageDirectory().getAbsolutePath()); + + String[] split = externalFileDir.getAbsolutePath().split("/"); + if (split.length > 2) { + return isInt ? (storageNameWithoutType ? "Internal Storage" : "") : (storageNameWithoutType ? split[2] : ("SD Card (" + split[2] + ")")); + } else { + return "Storage"; + } + } + + public List> getStorages(boolean internalStorageFolder, boolean sdcardFolders) { + List> storages = new ArrayList<>(); + for (Pair pair : getAppDataPublicDirs(internalStorageFolder, sdcardFolders, true)) { + if (pair.first != null && pair.first.getAbsolutePath().lastIndexOf("/Android/data") > 0) { + try { + storages.add(new Pair<>(new File(pair.first.getCanonicalPath().replaceFirst("/Android/data.*", "")), pair.second)); + } catch (IOException ignored) { + } + } + } + return storages; + } + + public File getStorageRootFolder(File file) { + String filepath; + try { + filepath = file.getCanonicalPath(); + } catch (Exception ignored) { + return null; + } + for (Pair storage : getStorages(false, true)) { + //noinspection ConstantConditions + if (filepath.startsWith(storage.first.getAbsolutePath())) { + return storage.first; + } + } + return null; } /** @@ -854,6 +933,18 @@ public class ContextUtils { } return mimeType; } + + public Integer parseColor(String colorstr) { + if (colorstr == null || colorstr.trim().isEmpty()) { + return null; + } + try { + return Color.parseColor(colorstr); + } catch (IllegalArgumentException ignored) { + return null; + } + } + } diff --git a/app/src/main/java/net/gsantner/opoc/util/FileUtils.java b/app/src/main/java/net/gsantner/opoc/util/FileUtils.java index 642699c1..a358ebff 100644 --- a/app/src/main/java/net/gsantner/opoc/util/FileUtils.java +++ b/app/src/main/java/net/gsantner/opoc/util/FileUtils.java @@ -405,7 +405,7 @@ public class FileUtils { * Analyze given textfile and retrieve multiple information from it * Information is written back to the {@link AtomicInteger} parameters */ - public static void retrieveTextFileSummary(File file, AtomicInteger numCharacters, AtomicInteger numLines) { + public static void retrieveTextFileSummary(File file, AtomicInteger numCharacters, AtomicInteger numLines, AtomicInteger numWords) { BufferedReader br = null; try { br = new BufferedReader(new FileReader(file)); @@ -413,11 +413,15 @@ public class FileUtils { while ((line = br.readLine()) != null) { numLines.getAndIncrement(); numCharacters.getAndSet(numCharacters.get() + line.length()); + if (!line.equals("")) { + numWords.getAndSet(numWords.get() + line.split("\\s+").length); + } } } catch (Exception e) { e.printStackTrace(); numCharacters.set(-1); numLines.set(-1); + numWords.set(-1); } finally { if (br != null) { try { diff --git a/app/src/main/java/net/gsantner/opoc/util/ShareUtil.java b/app/src/main/java/net/gsantner/opoc/util/ShareUtil.java index 13c2589c..49ab80f4 100644 --- a/app/src/main/java/net/gsantner/opoc/util/ShareUtil.java +++ b/app/src/main/java/net/gsantner/opoc/util/ShareUtil.java @@ -10,6 +10,8 @@ #########################################################*/ package net.gsantner.opoc.util; +import android.Manifest; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; @@ -37,19 +39,29 @@ import android.provider.MediaStore; import android.support.annotation.DrawableRes; import android.support.annotation.Nullable; import android.support.annotation.RequiresApi; +import android.support.annotation.StringRes; +import android.support.v4.app.ActivityCompat; +import android.support.v4.content.ContextCompat; import android.support.v4.content.FileProvider; import android.support.v4.content.LocalBroadcastManager; import android.support.v4.content.pm.ShortcutInfoCompat; import android.support.v4.content.pm.ShortcutManagerCompat; import android.support.v4.graphics.drawable.IconCompat; +import android.support.v4.provider.DocumentFile; +import android.support.v4.util.Pair; +import android.support.v7.app.AlertDialog; +import android.support.v7.preference.PreferenceManager; import android.text.TextUtils; import android.util.Log; import android.view.View; import android.webkit.WebView; +import android.widget.ImageView; +import android.widget.Toast; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -65,15 +77,17 @@ import static android.app.Activity.RESULT_OK; * Also allows to parse/fetch information out of shared information. * (M)Permissions are not checked, wrap ShareUtils methods if neccessary */ -@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "SameParameterValue", "unused", "deprecation", "ConstantConditions", "ObsoleteSdkInt", "SpellCheckingInspection"}) +@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "SameParameterValue", "unused", "deprecation", "ConstantConditions", "ObsoleteSdkInt", "SpellCheckingInspection", "JavadocReference"}) public class ShareUtil { public final static String EXTRA_FILEPATH = "real_file_path_2"; public final static SimpleDateFormat SDF_RFC3339_ISH = new SimpleDateFormat("yyyy-MM-dd'T'HH-mm", Locale.getDefault()); public final static SimpleDateFormat SDF_SHORT = new SimpleDateFormat("yyMMdd-HHmm", Locale.getDefault()); public final static String MIME_TEXT_PLAIN = "text/plain"; + public final static String PREF_KEY__SAF_TREE_URI = "pref_key__saf_tree_uri"; public final static int REQUEST_CAMERA_PICTURE = 50001; public final static int REQUEST_PICK_PICTURE = 50002; + public final static int REQUEST_SAF = 50003; protected static String _lastCameraPictureFilepath; @@ -86,6 +100,10 @@ public class ShareUtil { _chooserTitle = "➥"; } + public void freeContextRef() { + _context = null; + } + public String getFileProviderAuthority() { if (TextUtils.isEmpty(_fileProviderAuthority)) { throw new RuntimeException("Error at ShareUtil.getFileProviderAuthority(): No FileProvider authority provided"); @@ -513,6 +531,15 @@ public class ShareUtil { fileStr = fileStr.substring(prefix.length()); } } + + // external/ prefix for External storage + if (fileStr.startsWith((tmps = "external/"))) { + File f = new File(Uri.decode(Environment.getExternalStorageDirectory().getAbsolutePath() + "/" + fileStr.substring(tmps.length()))); + if (f.exists()) { + return f; + } + } + // Next/OwnCloud Fileprovider for (String fp : new String[]{"org.nextcloud.files", "org.nextcloud.beta.files", "org.owncloud.files"}) { if (fileProvider.equals(fp) && fileStr.startsWith(tmps = "external_files/")) { @@ -527,6 +554,7 @@ public class ShareUtil { if (fileProvider.equals("com.mi.android.globalFileexplorer.myprovider") && fileStr.startsWith(tmps = "external_files")) { return new File(Uri.decode(Environment.getExternalStorageDirectory().getAbsolutePath() + fileStr.substring(tmps.length()))); } + // URI Encoded paths with full path after content://package/ if (fileStr.startsWith("/") || fileStr.startsWith("%2F")) { tmpf = new File(Uri.decode(fileStr)); @@ -557,7 +585,11 @@ public class ShareUtil { throw new RuntimeException("Error: ShareUtil.requestGalleryPicture needs an Activity Context."); } Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - ((Activity) _context).startActivityForResult(intent, REQUEST_PICK_PICTURE); + try { + ((Activity) _context).startActivityForResult(intent, REQUEST_PICK_PICTURE); + } catch (Exception ex) { + Toast.makeText(_context, "No gallery app installed!", Toast.LENGTH_SHORT).show(); + } } /** @@ -565,7 +597,7 @@ public class ShareUtil { * Result ({@link String}) will be available from {@link Activity#onActivityResult(int, int, Intent)}. * It has set resultCode to {@link Activity#RESULT_OK} with same requestCode, if successfully * The requested image savepath has to be stored at caller side (not contained in intent), - * it can be retrieved using {@link #extractResultFromActivityResult(int, int, Intent)}, + * it can be retrieved using {@link #extractResultFromActivityResult(int, int, Intent, Activity...)} * returns null if an error happened. * * @param target Path to file to write to, if folder the filename gets app_name + millis + random filename. If null DCIM folder is used. @@ -621,7 +653,9 @@ public class ShareUtil { * Forward all arguments from activity. Only requestCodes from {@link ShareUtil} get analyzed. * Also may forward results via local broadcast */ - public Object extractResultFromActivityResult(int requestCode, int resultCode, Intent data) { + @SuppressLint("ApplySharedPref") + public Object extractResultFromActivityResult(int requestCode, int resultCode, Intent data, Activity... activityOrNull) { + Activity activity = greedyGetActivity(activityOrNull); switch (requestCode) { case REQUEST_CAMERA_PICTURE: { String picturePath = (resultCode == RESULT_OK) ? _lastCameraPictureFilepath : null; @@ -676,6 +710,18 @@ public class ShareUtil { } break; } + + case REQUEST_SAF: { + if (resultCode == RESULT_OK && data != null && data.getData() != null) { + Uri treeUri = data.getData(); + PreferenceManager.getDefaultSharedPreferences(_context).edit().putString(PREF_KEY__SAF_TREE_URI, treeUri.toString()).commit(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + activity.getContentResolver().takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + return treeUri; + } + break; + } } return null; } @@ -822,4 +868,264 @@ public class ShareUtil { customTabIntent.setPackage(pkg); } } + + /*** + * Request storage access. The user needs to press "Select storage" at the correct storage. + * @param activity The activity which will receive the result from startActivityForResult + */ + public void requestStorageAccessFramework(Activity... activity) { + Activity a = greedyGetActivity(activity); + if (a != null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + ); + a.startActivityForResult(intent, REQUEST_SAF); + } + } + + /** + * Get storage access framework tree uri. The user must have granted access via {@link #requestStorageAccessFramework(Activity...)} + * + * @return Uri or null if not granted yet + */ + public Uri getStorageAccessFrameworkTreeUri() { + String treeStr = PreferenceManager.getDefaultSharedPreferences(_context).getString(PREF_KEY__SAF_TREE_URI, null); + if (!TextUtils.isEmpty(treeStr)) { + try { + return Uri.parse(treeStr); + } catch (Exception ignored) { + } + } + return null; + } + + /** + * Get mounted storage folder root (by tree uri). The user must have granted access via {@link #requestStorageAccessFramework(Activity...)} + * + * @return File or null if SD not mounted + */ + public File getStorageAccessFolder() { + Uri safUri = getStorageAccessFrameworkTreeUri(); + if (safUri != null) { + String safUriStr = safUri.toString(); + ContextUtils cu = new ContextUtils(_context); + for (Pair storage : cu.getStorages(false, true)) { + @SuppressWarnings("ConstantConditions") String storageFolderName = storage.first.getName(); + if (safUriStr.contains(storageFolderName)) { + return storage.first; + } + } + cu.freeContextRef(); + } + return null; + } + + /** + * Check whether or not a file is under a storage access folder (external storage / SD) + * + * @param file The file object (file/folder) + * @return Wether or not the file is under storage access folder + */ + public boolean isUnderStorageAccessFolder(File file) { + if (file != null) { + ContextUtils cu = new ContextUtils(_context); + for (Pair storage : cu.getStorages(false, true)) { + if (file.getAbsolutePath().startsWith(storage.first.getAbsolutePath())) { + cu.freeContextRef(); + return true; + } + } + cu.freeContextRef(); + } + return false; + } + + /** + * Greedy extract Activity from parameter or convert context if it's a activity + */ + private Activity greedyGetActivity(Activity... activity) { + if (activity != null && activity.length != 0 && activity[0] != null) { + return activity[0]; + } + if (_context instanceof Activity) { + return (Activity) _context; + } + return null; + } + + /** + * Check whether or not a file can be written. + * Requires storage access framework permission for external storage (SD) + * + * @param file The file object (file/folder) + * @param isDir Wether or not the given file parameter is a directory + * @return Wether or not the file can be written + */ + public boolean canWriteFile(File file, boolean isDir) { + if (file == null) { + return false; + } else if (file.getAbsolutePath().startsWith(Environment.getExternalStorageDirectory().getAbsolutePath())) { + boolean s1 = isDir && file.getParentFile().canWrite(); + return !isDir && file.getParentFile() != null ? file.getParentFile().canWrite() : file.canWrite(); + } else { + DocumentFile dof = getDocumentFile(file, isDir); + return dof != null && dof.canWrite(); + } + } + + /** + * Get a {@link DocumentFile} object out of a normal java {@link File}. + * When used on a external storage (SD), use {@link #requestStorageAccessFramework(Activity...)} + * first to get access. Otherwise this will fail. + * + * @param file The file/folder to convert + * @param isDir Wether or not file is a directory. For non-existing (to be created) files this info is not known hence required. + * @return A {@link DocumentFile} object or null if file cannot be converted + */ + public DocumentFile getDocumentFile(File file, boolean isDir) { + // On older versions use fromFile + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + return DocumentFile.fromFile(file); + } + + // Get ContextUtils to find storageRootFolder + ContextUtils cu = new ContextUtils(_context); + File baseFolderFile = cu.getStorageRootFolder(file); + cu.freeContextRef(); + + String baseFolder = baseFolderFile == null ? null : baseFolderFile.getAbsolutePath(); + boolean originalDirectory = false; + if (baseFolder == null) { + return null; + } + + String relPath = null; + try { + String fullPath = file.getCanonicalPath(); + if (!baseFolder.equals(fullPath)) { + relPath = fullPath.substring(baseFolder.length() + 1); + } else { + originalDirectory = true; + } + } catch (IOException e) { + return null; + } catch (Exception ignored) { + originalDirectory = true; + } + Uri treeUri; + if ((treeUri = getStorageAccessFrameworkTreeUri()) == null) { + return null; + } + DocumentFile dof = DocumentFile.fromTreeUri(_context, treeUri); + if (originalDirectory) { + return dof; + } + String[] parts = relPath.split("\\/"); + for (int i = 0; i < parts.length; i++) { + DocumentFile nextDof = dof.findFile(parts[i]); + if (nextDof == null) { + nextDof = ((i < parts.length - 1) || isDir) ? dof.createDirectory(parts[i]) : dof.createFile("image", parts[i]); + } + dof = nextDof; + } + return dof; + } + + public void showMountSdDialog(@StringRes int title, @StringRes int description, @DrawableRes int mountDescriptionGraphic, Activity... activityOrNull) { + Activity activity = greedyGetActivity(activityOrNull); + if (activity == null) { + return; + } + + // Image viewer + ImageView imv = new ImageView(activity); + imv.setImageResource(mountDescriptionGraphic); + imv.setAdjustViewBounds(true); + + AlertDialog.Builder dialog = new AlertDialog.Builder(activity); + dialog.setView(imv); + dialog.setTitle(title); + dialog.setMessage(_context.getString(description) + "\n\n"); + dialog.setNegativeButton(android.R.string.cancel, null); + dialog.setPositiveButton(android.R.string.yes, (dialogInterface, i) -> requestStorageAccessFramework(activity)); + AlertDialog dialogi = dialog.create(); + dialogi.show(); + } + + public void writeFile(File file, boolean isDirectory, Callback.a2 writeFileCallback) { + try { + FileOutputStream fileOutputStream = null; + ParcelFileDescriptor pfd = null; + if (file.canWrite()) { + if (isDirectory) { + file.mkdirs(); + } else { + fileOutputStream = new FileOutputStream(file); + } + } else { + DocumentFile dof = getDocumentFile(file, isDirectory); + if (dof != null && dof.getUri() != null && dof.canWrite()) { + if (isDirectory) { + // Nothing to do + } else { + pfd = _context.getContentResolver().openFileDescriptor(dof.getUri(), "w"); + fileOutputStream = new FileOutputStream(pfd.getFileDescriptor()); + } + } + } + if (writeFileCallback != null) { + writeFileCallback.callback(fileOutputStream != null || (isDirectory && file.exists()), fileOutputStream); + } + if (fileOutputStream != null) { + fileOutputStream.close(); + } + if (pfd != null) { + pfd.close(); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * Call telephone number. + * Non direct call, opens up the dialer and pre-sets the telephone number. User needs to press manually. + * Direct call requires M permission granted, also add permissions to manifest: + * + * + * @param telNo The telephone number to call + * @param directCall Direct call number if possible + */ + @SuppressWarnings("SimplifiableConditionalExpression") + public void callTelephoneNumber(String telNo, boolean... directCall) { + Activity activity = greedyGetActivity(); + if (activity == null) { + throw new RuntimeException("Error: ShareUtil::callTelephoneNumber needs to be contstructed with activity context"); + } + boolean ldirectCall = (directCall != null && directCall.length > 0) ? directCall[0] : true; + + + if (android.os.Build.VERSION.SDK_INT >= 23 && ldirectCall && activity != null) { + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.CALL_PHONE}, 4001); + ldirectCall = false; + } else { + try { + Intent callIntent = new Intent(Intent.ACTION_CALL); + callIntent.setData(Uri.parse("tel:" + telNo)); + activity.startActivity(callIntent); + } catch (Exception ignored) { + ldirectCall = false; + } + } + } + // Show dialer up with telephone number pre-inserted + if (!ldirectCall) { + Intent intent = new Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", telNo, null)); + activity.startActivity(intent); + } + } } diff --git a/build.gradle b/build.gradle index 25e4f2b8..24a36828 100644 --- a/build.gradle +++ b/build.gradle @@ -13,8 +13,8 @@ import java.text.SimpleDateFormat buildscript { ext { - version_gradle_tools = "3.2.1" - version_plugin_kotlin = "1.3.11" + version_gradle_tools = "3.4.2" + version_plugin_kotlin = "1.3.41" enable_plugin_kotlin = false version_compileSdk = 28 @@ -79,7 +79,7 @@ static String findUsedAndroidLocales() { Set langs = new HashSet<>() new File('.').eachFileRecurse(groovy.io.FileType.DIRECTORIES) { final foldername = it.name - if (foldername.startsWith('values-') && !it.canonicalPath.contains("build" + File.separator + "intermediates")) { + if (foldername.startsWith('values-') && !it.canonicalPath.contains("build" + File.separator + "intermediates") && !it.canonicalPath.contains("gradle" + File.separator + "daemon")) { new File(it.toString()).eachFileRecurse(groovy.io.FileType.FILES) { if (it.name.toLowerCase().endsWith(".xml") && it.getCanonicalFile().getText('UTF-8').contains("