diff options
author | AlexF <alexfongg@gmail.com> | 2015-12-16 22:53:08 +0800 |
---|---|---|
committer | Miklos Vajna <vmiklos@collabora.co.uk> | 2016-02-02 19:25:41 +0000 |
commit | 1dfb68debb01dcc0aa5902f41bc17c7a97e47b26 (patch) | |
tree | 6e1237eaeb08179b4cfb29c54d4c8eb71341d736 /android | |
parent | bb01d7bc50e59eb30c0826a000ede52b93074f75 (diff) |
tdf#88389 - android document browser: external storage access
Background:
External SD cards are only partially supported by the Android system,
with a great deal of fragmentation on implementation across manufacturers
and android versions. There is no official support for OTG devices.
This commit adds:
1) External SD card support
2) OTG device support
Caveats:
1) Not tested on Android 6. Emulator crashes when opening
files on Android 6, using an unmodified build of the master branch.
2) OTG support currently works only if there is write access
to the OTG directory. The user must be aware of exact OTG directory
path or be able to navigate to it as well.
3) External SD card provider currently lacks file filtering.
Approach:
-----
Added new document providers.
External SD cards:
There are 2 different document providers external sd cards,
one for Android 4.4 and above, and the other for older versions.
1) New
Android 4.4 and above require usage of the DocumentFile wrapper class
to access files in external storage. Actual file paths are no longer
obtainable. As such, the underlying file will be cloned in a cache,
allowing us to get an actual file path and use LOK.
Some differences exist between 4.4 & 5+. The document provider handles
each case separately.
2) Legacy
Android 4.3 and below do not support the DocumentFile wrapper.
File object can be used in these versions, allowing actual file paths
to be obtained. The document provider guesses the root directory of
the SD card. If the guessing fails, the user is to navigate to
this directory himself.
OTG:
The OTG document provider resembles the legacy external SD card
document provider, requiring the user to locate the directory himself.
The document provider does not guess the root directory of the OTG
device as the location varies with manufacturer implementation.
-----
Supplementary Notes:
Attempting to use the internal app cache as the file cache like in
the ownCloud document provider did not work. Using the external app
cache works fine though. It could be because initializing LOK wipes
the internal app cache.
Would be good to test the ownCloud document provider to confirm if it
works.
Change-Id: Ie727cca265107bc49ca7e7b57130470f7fc52e06
Reviewed-on: https://gerrit.libreoffice.org/20738
Reviewed-by: Tomaž Vajngerl <quikee@gmail.com>
Tested-by: Miklos Vajna <vmiklos@collabora.co.uk>
Diffstat (limited to 'android')
17 files changed, 1125 insertions, 6 deletions
diff --git a/android/source/AndroidManifest.xml b/android/source/AndroidManifest.xml index d49771ab9d3f..25e824074ac9 100644 --- a/android/source/AndroidManifest.xml +++ b/android/source/AndroidManifest.xml @@ -113,6 +113,16 @@ </intent-filter> </activity> + <activity android:name=".storage.external.BrowserSelectorActivity" + android:theme="@style/LibreOfficeTheme"> + </activity> + + <activity android:name=".storage.external.DirectoryBrowserActivity" + android:label="@string/directory_browser_label" + android:theme="@style/LibreOfficeTheme" + android:windowSoftInputMode="stateHidden"> + </activity> + </application> </manifest> diff --git a/android/source/res/drawable/ic_menu_back.png b/android/source/res/drawable/ic_menu_back.png Binary files differnew file mode 100644 index 000000000000..d3191caffd13 --- /dev/null +++ b/android/source/res/drawable/ic_menu_back.png diff --git a/android/source/res/layout/activity_directory_browser.xml b/android/source/res/layout/activity_directory_browser.xml new file mode 100644 index 000000000000..b03c6bbb1224 --- /dev/null +++ b/android/source/res/layout/activity_directory_browser.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/fragment_container" + android:layout_width="match_parent" android:layout_height="match_parent"> + +</FrameLayout>
\ No newline at end of file diff --git a/android/source/res/layout/fragment_directory_browser.xml b/android/source/res/layout/fragment_directory_browser.xml new file mode 100644 index 000000000000..fcf7fc6c9b47 --- /dev/null +++ b/android/source/res/layout/fragment_directory_browser.xml @@ -0,0 +1,71 @@ +<?xml version="1.0" encoding="utf-8"?> +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:orientation="vertical" android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="#FFFFFF"> + + <LinearLayout + android:id="@+id/browser_header" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + + <ImageView + android:id="@+id/up_image" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:src="@drawable/ic_menu_back" + android:scaleType="fitCenter" + android:adjustViewBounds="true" + android:contentDescription="@string/up_description"/> + + <EditText + android:id="@+id/directory_header" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:singleLine="true" + android:scrollHorizontally="true" + android:textAppearance="?android:attr/textAppearanceMedium" + android:inputType="text"/> + + <Button + android:id="@+id/directory_search_button" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:text="@string/search_label"/> + + </LinearLayout> + + <LinearLayout + android:id="@+id/browser_footer" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_alignParentBottom="true"> + + <Button + android:id="@+id/cancel_button" + android:layout_height="wrap_content" + android:layout_width="0dp" + android:layout_weight="1" + android:text="@string/cancel_label"/> + + <Button + android:id="@+id/confirm_button" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="@string/confirm_label"/> + </LinearLayout> + + <ListView + android:id="@+id/directory_list" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@id/browser_header" + android:layout_above="@id/browser_footer"> + </ListView> + + +</RelativeLayout>
\ No newline at end of file diff --git a/android/source/res/values/strings.xml b/android/source/res/values/strings.xml index 403f5b900c3f..c4d1bae404c5 100644 --- a/android/source/res/values/strings.xml +++ b/android/source/res/values/strings.xml @@ -51,12 +51,18 @@ <string name="close_document_locations">Close document locations</string> <string name="local_documents">Local documents</string> <string name="local_file_system">Local file system</string> + <string name="external_sd_file_system">External SD</string> + <string name="otg_file_system">OTG device (experimental)</string> <string name="owncloud">ownCloud</string> <string name="owncloud_wrong_connection">Cannot connect to ownCloud server. Check your configuration.</string> <string name="owncloud_unauthorized">Cannot log into ownCloud server. Check your configuration.</string> <string name="owncloud_unspecified_error">Unspecified error connecting to ownCloud server. Check your configuration and/or try later.</string> + <string name="ext_document_provider_error">Invalid root file. Check your configuration.</string> + <string name="legacy_extsd_missing_error">Invalid root file. Check your external sd card and/or configuration.</string> + <string name="otg_missing_error">Invalid root file. Check your OTG device and/or configuration.</string> + <!-- Edit action names --> <string name="action_bold">Bold</string> <string name="action_underline">Underline</string> @@ -75,6 +81,10 @@ <!-- Document provider settings --> <string name="storage_provider_settings">Storage provider settings</string> <string name="owncloud_settings">ownCloud settings</string> + <string name="physical_storage_settings">Physical storage settings</string> + <string name="external_sd_path">External SD path</string> + <string name="otg_device_path">OTG device path</string> + <string name="otg_warning">Experimental Feature: Use only if OTG device is writable.</string> <string name="server_url">Server URL</string> <string name="server_url_and_port">URL and port of the ownCloud server.</string> <string name="user_name">User name</string> @@ -82,5 +92,13 @@ <string name="action_undo">Undo</string> <string name="action_redo">Redo</string> + <!-- Directory browser strings --> + <string name="up_description">To parent directory</string> + <string name="confirm_label">Confirm</string> + <string name="cancel_label">Cancel</string> + <string name="search_label">Go</string> + <string name="directory_browser_label">Choose Directory</string> + <string name="bad_directory">Invalid directory path</string> + </resources> diff --git a/android/source/res/xml/documentprovider_preferences.xml b/android/source/res/xml/documentprovider_preferences.xml index a359d14c4460..4a66c6377847 100644 --- a/android/source/res/xml/documentprovider_preferences.xml +++ b/android/source/res/xml/documentprovider_preferences.xml @@ -23,4 +23,17 @@ android:title="@string/password" android:defaultValue="" /> </PreferenceCategory> + <PreferenceCategory + android:title="@string/physical_storage_settings"> + <PreferenceScreen + android:title="@string/external_sd_path" + android:key="pref_extsd_path_uri"> + </PreferenceScreen> + <PreferenceScreen + android:title="@string/otg_device_path" + android:key="pref_otg_path_uri" + android:summary="@string/otg_warning"> + </PreferenceScreen> + </PreferenceCategory> + </PreferenceScreen> diff --git a/android/source/src/java/org/libreoffice/storage/DocumentProviderFactory.java b/android/source/src/java/org/libreoffice/storage/DocumentProviderFactory.java index b8c05341d18a..f73a2b0d543a 100644 --- a/android/source/src/java/org/libreoffice/storage/DocumentProviderFactory.java +++ b/android/source/src/java/org/libreoffice/storage/DocumentProviderFactory.java @@ -12,12 +12,16 @@ package org.libreoffice.storage; import java.util.HashSet; import java.util.Set; +import org.libreoffice.storage.external.ExtsdDocumentsProvider; +import org.libreoffice.storage.external.LegacyExtSDDocumentsProvider; +import org.libreoffice.storage.external.OTGDocumentsProvider; import org.libreoffice.storage.local.LocalDocumentsDirectoryProvider; import org.libreoffice.storage.local.LocalDocumentsProvider; import org.libreoffice.storage.owncloud.OwnCloudProvider; import android.content.Context; import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.os.Build; /** * Keeps the instances of the available IDocumentProviders in the system. @@ -29,6 +33,8 @@ import android.content.SharedPreferences.OnSharedPreferenceChangeListener; * DocumentProviderFactory.getInstance(). */ public final class DocumentProviderFactory { + public static int EXTSD_PROVIDER_INDEX = 2; + public static int OTG_PROVIDER_INDEX = 3; /** * Private factory instance for the Singleton pattern. @@ -56,10 +62,19 @@ public final class DocumentProviderFactory { instance = new DocumentProviderFactory(); // initialize document providers list - instance.providers = new IDocumentProvider[3]; + instance.providers = new IDocumentProvider[5]; instance.providers[0] = new LocalDocumentsDirectoryProvider(0); instance.providers[1] = new LocalDocumentsProvider(1); - instance.providers[2] = new OwnCloudProvider(2, context); + instance.providers[OTG_PROVIDER_INDEX] = new OTGDocumentsProvider(OTG_PROVIDER_INDEX, context); + instance.providers[4] = new OwnCloudProvider(4, context); + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + instance.providers[EXTSD_PROVIDER_INDEX] + = new ExtsdDocumentsProvider(EXTSD_PROVIDER_INDEX, context); + } else { + instance.providers[EXTSD_PROVIDER_INDEX] + = new LegacyExtSDDocumentsProvider(EXTSD_PROVIDER_INDEX, context); + } // initialize document provider names list instance.providerNames = new String[instance.providers.length]; diff --git a/android/source/src/java/org/libreoffice/storage/DocumentProviderSettingsActivity.java b/android/source/src/java/org/libreoffice/storage/DocumentProviderSettingsActivity.java index e98534a44756..7b2160a3f509 100644 --- a/android/source/src/java/org/libreoffice/storage/DocumentProviderSettingsActivity.java +++ b/android/source/src/java/org/libreoffice/storage/DocumentProviderSettingsActivity.java @@ -12,25 +12,30 @@ package org.libreoffice.storage; import java.util.Set; import org.libreoffice.R; +import org.libreoffice.storage.external.BrowserSelectorActivity; import android.app.Activity; +import android.content.Intent; import android.content.SharedPreferences.OnSharedPreferenceChangeListener; import android.os.Bundle; +import android.preference.Preference; import android.preference.PreferenceFragment; import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; public class DocumentProviderSettingsActivity extends Activity { public static final String KEY_PREF_OWNCLOUD_SERVER = "pref_server_url"; public static final String KEY_PREF_OWNCLOUD_USER_NAME = "pref_user_name"; public static final String KEY_PREF_OWNCLOUD_PASSWORD = "pref_password"; + public static final String KEY_PREF_EXTERNAL_SD_PATH_URI = "pref_extsd_path_uri"; + public static final String KEY_PREF_OTG_PATH_URI = "pref_otg_path_uri"; private Set<OnSharedPreferenceChangeListener> listeners; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - // Display the fragment as the main content. getFragmentManager().beginTransaction() .replace(android.R.id.content, new SettingsFragment()).commit(); @@ -39,7 +44,6 @@ public class DocumentProviderSettingsActivity extends Activity { @Override protected void onResume() { super.onResume(); - listeners = DocumentProviderFactory.getInstance().getChangeListeners(); for (OnSharedPreferenceChangeListener listener : listeners) { PreferenceManager.getDefaultSharedPreferences(this) @@ -50,7 +54,6 @@ public class DocumentProviderSettingsActivity extends Activity { @Override protected void onPause() { super.onPause(); - for (OnSharedPreferenceChangeListener listener : listeners) { PreferenceManager.getDefaultSharedPreferences(this) .unregisterOnSharedPreferenceChangeListener(listener); @@ -61,9 +64,39 @@ public class DocumentProviderSettingsActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - // Load the preferences from an XML resource addPreferencesFromResource(R.xml.documentprovider_preferences); + + PreferenceScreen extSDPreference = + (PreferenceScreen)findPreference(KEY_PREF_EXTERNAL_SD_PATH_URI); + PreferenceScreen otgPreference = + (PreferenceScreen)findPreference(KEY_PREF_OTG_PATH_URI); + + extSDPreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + startBrowserSelectorActivity(KEY_PREF_EXTERNAL_SD_PATH_URI, + BrowserSelectorActivity.MODE_EXT_SD); + return true; + } + }); + otgPreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + startBrowserSelectorActivity(KEY_PREF_OTG_PATH_URI, + BrowserSelectorActivity.MODE_OTG); + return true; + } + }); + } + + private void startBrowserSelectorActivity(String prefKey, String mode) { + Intent i = new Intent(getActivity(), BrowserSelectorActivity.class); + i.putExtra(BrowserSelectorActivity.PREFERENCE_KEY_EXTRA, prefKey); + i.putExtra(BrowserSelectorActivity.MODE_EXTRA, mode); + startActivity(i); + } + } } diff --git a/android/source/src/java/org/libreoffice/storage/IOUtils.java b/android/source/src/java/org/libreoffice/storage/IOUtils.java new file mode 100644 index 000000000000..0cb7b2e0ecec --- /dev/null +++ b/android/source/src/java/org/libreoffice/storage/IOUtils.java @@ -0,0 +1,59 @@ +package org.libreoffice.storage; + +import android.content.Context; +import android.util.Log; + +import org.libreoffice.R; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * File IO related methods. + */ +public class IOUtils { + private static final int BUFFER_SIZE = 1024 * 8; + private static final String LOGTAG = IOUtils.class.getSimpleName(); + + public static File getFileFromURIString(String URIpath) throws IllegalArgumentException{ + try{ + return new File(new URI(URIpath)); + } catch (URISyntaxException e) { + //should not happen as all URIs are system generated + Log.wtf(LOGTAG, e.getReason()); + return null; + } + } + + public static boolean isInvalidFile(File f) { + return f == null || !f.exists() || f.getTotalSpace() == 0 + || !f.canRead() || !f.canWrite(); + } + + public static int copy(InputStream input, OutputStream output) throws Exception { + byte[] buffer = new byte[BUFFER_SIZE]; + + BufferedInputStream in = new BufferedInputStream(input, BUFFER_SIZE); + BufferedOutputStream out = new BufferedOutputStream(output, BUFFER_SIZE); + + int count = 0, n = 0; + try { + while ((n = in.read(buffer, 0, BUFFER_SIZE)) != -1) { + out.write(buffer, 0, n); + count += n; + } + out.flush(); + } finally { + if (out != null) out.close(); + if (in != null) in.close(); + } + + return count; + } + +} diff --git a/android/source/src/java/org/libreoffice/storage/external/BrowserSelectorActivity.java b/android/source/src/java/org/libreoffice/storage/external/BrowserSelectorActivity.java new file mode 100644 index 000000000000..fe12804e620c --- /dev/null +++ b/android/source/src/java/org/libreoffice/storage/external/BrowserSelectorActivity.java @@ -0,0 +1,152 @@ +package org.libreoffice.storage.external; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.UriPermission; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.support.v7.app.AppCompatActivity; +import android.util.Log; + +import org.libreoffice.storage.DocumentProviderFactory; + +import java.util.Set; + +/** + * Activity to select which directory browser to use. + * Android 5+ will use the DocumentTree intent to locate a browser. + * Android 4+ & OTG will use the internal directory browser. + */ +public class BrowserSelectorActivity extends AppCompatActivity { + public static final String PREFERENCE_KEY_EXTRA = "org.libreoffice.pref_key_extra"; + public static final String MODE_EXTRA = "org.libreoffice.mode_extra"; + public static final String MODE_OTG = "OTG"; + public static final String MODE_EXT_SD = "EXT_SD"; + + private static final String LOGTAG = BrowserSelectorActivity.class.getSimpleName(); + private static final int REQUEST_DOCUMENT_TREE = 1; + private static final int REQUEST_INTERNAL_BROWSER = 2; + private Set<SharedPreferences.OnSharedPreferenceChangeListener> listeners; + private String preferenceKey; + private SharedPreferences preferences; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + preferenceKey = getIntent().getStringExtra(PREFERENCE_KEY_EXTRA); + preferences = PreferenceManager.getDefaultSharedPreferences(this); + String mode = getIntent().getStringExtra(MODE_EXTRA); + + if(mode.equals(MODE_EXT_SD)) { + findSDCard(); + } else if (mode.equals(MODE_OTG)) { + findOTGDevice(); + } + } + + private void findOTGDevice() { + useInternalBrowser(DocumentProviderFactory.OTG_PROVIDER_INDEX); + } + + private void findSDCard() { + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + useDocumentTreeBrowser(); + } else { + useInternalBrowser(DocumentProviderFactory.EXTSD_PROVIDER_INDEX); + } + } + + private void useInternalBrowser(int providerIndex) { + IExternalDocumentProvider provider = + (IExternalDocumentProvider) DocumentProviderFactory.getInstance() + .getProvider(providerIndex); + String previousDirectoryPath = preferences.getString(preferenceKey, provider.guessRootURI()); + Intent i = new Intent(this, DirectoryBrowserActivity.class); + i.putExtra(DirectoryBrowserActivity.DIRECTORY_PATH_EXTRA, previousDirectoryPath); + startActivityForResult(i, REQUEST_INTERNAL_BROWSER); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private void useDocumentTreeBrowser() { + Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + startActivityForResult(i, REQUEST_DOCUMENT_TREE); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + //listeners are registered here as onActivityResult is called before onResume + super.onActivityResult(requestCode, resultCode, data); + + registerListeners(); + if(resultCode == RESULT_OK) { + switch(requestCode) { + case REQUEST_DOCUMENT_TREE: + Uri treeUri = data.getData(); + preferences.edit() + .putString(preferenceKey, treeUri.toString()) + .apply(); + + updatePersistedUriPermission(treeUri); + getContentResolver().takePersistableUriPermission(treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + break; + + case REQUEST_INTERNAL_BROWSER: + Uri fileUri = data.getData(); + preferences.edit() + .putString(preferenceKey, fileUri.toString()) + .apply(); + break; + default: + } + } + unregisterListeners(); + Log.d(LOGTAG, "Preference saved: " + + preferences.getString(preferenceKey, "Directory not saved.")); + finish(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private void updatePersistedUriPermission(Uri treeUri) { + freePreviousUriPermissions(); + + //TODO: Use non-emulator Android 5+ device to check if needed + /*this.grantUriPermission(this.getPackageName(), + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION); */ + + getContentResolver().takePersistableUriPermission(treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private void freePreviousUriPermissions() { + ContentResolver cr = getContentResolver(); + for (UriPermission uriPermission : cr.getPersistedUriPermissions()) { + cr.releasePersistableUriPermission(uriPermission.getUri(), 0); + } + } + + private void registerListeners() { + listeners = DocumentProviderFactory.getInstance().getChangeListeners(); + for (SharedPreferences.OnSharedPreferenceChangeListener listener : listeners) { + PreferenceManager.getDefaultSharedPreferences(this) + .registerOnSharedPreferenceChangeListener(listener); + } + } + + private void unregisterListeners() { + for (SharedPreferences.OnSharedPreferenceChangeListener listener : listeners) { + PreferenceManager.getDefaultSharedPreferences(this) + .unregisterOnSharedPreferenceChangeListener(listener); + } + } +} diff --git a/android/source/src/java/org/libreoffice/storage/external/DirectoryBrowserActivity.java b/android/source/src/java/org/libreoffice/storage/external/DirectoryBrowserActivity.java new file mode 100644 index 000000000000..224526adb17b --- /dev/null +++ b/android/source/src/java/org/libreoffice/storage/external/DirectoryBrowserActivity.java @@ -0,0 +1,42 @@ +package org.libreoffice.storage.external; + + +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.app.AppCompatActivity; + +import org.libreoffice.R; + +/** + * Container for DirectoryBrowserFragment + */ +public class DirectoryBrowserActivity extends AppCompatActivity { + public static final String DIRECTORY_PATH_EXTRA = "org.libreoffie.directory_path_extra"; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Intent data = getIntent(); + String initialPath = data.getStringExtra(DIRECTORY_PATH_EXTRA); + + setContentView(R.layout.activity_directory_browser); + FragmentManager fm = getFragmentManager(); + Fragment fragment = DirectoryBrowserFragment.newInstance(initialPath); + fm.beginTransaction() + .add(R.id.fragment_container, fragment) + .commit(); + } + + @Override + public void onBackPressed() { + FragmentManager fm = getFragmentManager(); + if(fm.getBackStackEntryCount() > 0) { + fm.popBackStack(); + } else { + super.onBackPressed(); + } + } +} diff --git a/android/source/src/java/org/libreoffice/storage/external/DirectoryBrowserFragment.java b/android/source/src/java/org/libreoffice/storage/external/DirectoryBrowserFragment.java new file mode 100644 index 000000000000..27cbfbce14be --- /dev/null +++ b/android/source/src/java/org/libreoffice/storage/external/DirectoryBrowserFragment.java @@ -0,0 +1,199 @@ +package org.libreoffice.storage.external; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.support.annotation.Nullable; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import org.libreoffice.R; +import org.libreoffice.storage.IOUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.Comparator; + +/** + * A simple directory browser. + */ +public class DirectoryBrowserFragment extends Fragment { + private static final String LOGTAG = DirectoryBrowserFragment.class.getSimpleName(); + private static final String INITIAL_PATH_URI_KEY = "initial_path"; + private File currentDirectory; + private FileArrayAdapter directoryAdapter; + + public static DirectoryBrowserFragment newInstance(String initialPathURI) { + Bundle args = new Bundle(); + args.putString(INITIAL_PATH_URI_KEY, initialPathURI); + DirectoryBrowserFragment fragment = new DirectoryBrowserFragment(); + fragment.setArguments(args); + Log.d(LOGTAG, "Saved path: " + initialPathURI); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + String initialPathURI = getArguments().getString(INITIAL_PATH_URI_KEY); + setupCurrentDirectory(initialPathURI); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.fragment_directory_browser, container, false); + + final EditText directoryHeader = (EditText)v.findViewById(R.id.directory_header); + Button directorySearchButton = (Button)v.findViewById(R.id.directory_search_button); + Button positiveButton = (Button)v.findViewById(R.id.confirm_button); + Button negativeButton = (Button)v.findViewById(R.id.cancel_button); + ImageView upImage = (ImageView)v.findViewById(R.id.up_image); + ListView directoryListView = (ListView) v.findViewById(R.id.directory_list); + + directoryHeader.setText(currentDirectory.getPath()); + directorySearchButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String currentPath = currentDirectory.getAbsolutePath(); + String enteredPath = directoryHeader.getText().toString(); + File testDirectory = new File(enteredPath); + if(enteredPath.equals(currentPath)) ; + else if (isInvalidFileDirectory(testDirectory)) { + Toast.makeText(getActivity(), R.string.bad_directory, Toast.LENGTH_SHORT) + .show(); + } + else { + changeDirectory(testDirectory); + } + } + }); + + positiveButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Intent data = new Intent(); + data.setData(Uri.fromFile(currentDirectory)); + getActivity().setResult(Activity.RESULT_OK, data); + getActivity().finish(); + } + }); + + negativeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + getActivity().setResult(Activity.RESULT_CANCELED, null); + getActivity().finish(); + } + }); + + upImage.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + changeDirectory(currentDirectory.getParentFile()); + } + }); + + directoryAdapter = new FileArrayAdapter(getActivity(), new ArrayList<File>()); + directoryAdapter.populateFileList(currentDirectory); + directoryListView.setAdapter(directoryAdapter); + directoryListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + changeDirectory(directoryAdapter.getItem(position)); + } + }); + + return v; + } + + private void changeDirectory(File destination) { + if(destination == null) { + Toast.makeText(getActivity(), "Unable to go further.", Toast.LENGTH_SHORT) + .show(); + } else { + Fragment fragment = DirectoryBrowserFragment.newInstance(destination.toURI().toString()); + getActivity().getFragmentManager().beginTransaction() + .replace(R.id.fragment_container, fragment) + .addToBackStack(null) + .commit(); + } + } + + private void setupCurrentDirectory(String initialPathURI) { + File initialDirectory = null; + if(initialPathURI != null && !initialPathURI.isEmpty()) { + initialDirectory = IOUtils.getFileFromURIString(initialPathURI); + } + + if(isInvalidFileDirectory(initialDirectory)) { + initialDirectory = Environment.getExternalStorageDirectory(); + } + currentDirectory = initialDirectory; + } + + private boolean isInvalidFileDirectory(File f) { + return f == null || !f.exists() || !f.isDirectory() ||!f.canRead(); + } + + private class FileArrayAdapter extends ArrayAdapter<File> { + private Comparator<File> caseInsensitiveNaturalOrderComparator; + + public FileArrayAdapter(Context context, ArrayList<File> files) { + super(context, 0, files); + caseInsensitiveNaturalOrderComparator = new AlphabeticalFileComparator(); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = getActivity().getLayoutInflater() + .inflate(android.R.layout.simple_list_item_1, null); + } + + File f = this.getItem(position); + TextView tv = (TextView) convertView.findViewById(android.R.id.text1); + tv.setText(f.getName()); + + return convertView; + } + + public void sortAlphabetically() { + this.sort(caseInsensitiveNaturalOrderComparator); + } + + public void populateFileList(File directory) { + for(File f : directory.listFiles()){ + if(f.isDirectory()){ + directoryAdapter.add(f); + } + } + directoryAdapter.sortAlphabetically(); + } + } + + private class AlphabeticalFileComparator implements Comparator<File> { + @Override + public int compare(File lhs, File rhs) { + String lhsName = lhs.getName(); + String rhsName = rhs.getName(); + + return lhsName.compareToIgnoreCase(rhsName); + } + } +} diff --git a/android/source/src/java/org/libreoffice/storage/external/ExternalFile.java b/android/source/src/java/org/libreoffice/storage/external/ExternalFile.java new file mode 100644 index 000000000000..638111068ddd --- /dev/null +++ b/android/source/src/java/org/libreoffice/storage/external/ExternalFile.java @@ -0,0 +1,149 @@ +package org.libreoffice.storage.external; + +import android.content.Context; +import android.support.v4.provider.DocumentFile; +import android.util.Log; + +import org.libreoffice.storage.IFile; +import org.libreoffice.storage.IOUtils; + +import java.io.File; +import java.io.FileFilter; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * Implementation of IFile for the external file system, for Android 4.4+ + * + * Uses the DocumentFile class. + * + * The DocumentFile class obfuscates the path of the files it wraps, + * preventing usage of LOK's documentLoad method. A copy of the DocumentFile's contents + * will be created in the cache when files are opened, allowing use of documentLoad. + */ +public class ExternalFile implements IFile{ + private final static String LOGTAG = "ExternalFile"; + + private ExtsdDocumentsProvider provider; + private DocumentFile docFile; + private File duplicateFile; + private Context context; + + public ExternalFile(ExtsdDocumentsProvider provider, DocumentFile docFile, Context context) { + this.provider = provider; + this.context = context; + this.docFile = docFile; + } + + @Override + public URI getUri() { + try{ + return new URI(docFile.toString()); + } catch (URISyntaxException e) { + Log.e(LOGTAG, e.getMessage(), e.getCause()); + return null; + } + } + + @Override + public String getName() { + return docFile.getName(); + } + + @Override + public boolean isDirectory() { + return docFile.isDirectory(); + } + + @Override + public long getSize() { + return docFile.length(); + } + + @Override + public Date getLastModified() { + return new Date(docFile.lastModified()); + } + + @Override + public List<IFile> listFiles() { + List<IFile> children = new ArrayList<IFile>(); + for (DocumentFile child : docFile.listFiles()) { + children.add(new ExternalFile(provider, child, context)); + } + return children; + } + + @Override + public List<IFile> listFiles(FileFilter filter) { + // TODO: no filtering yet + return listFiles(); + } + + @Override + public IFile getParent() { + // this is the root node + if(docFile.getParentFile() == null) return null; + + return new ExternalFile(provider, docFile.getParentFile(), context); + } + + @Override + public File getDocument() { + if(isDirectory()) { + return null; + } else { + duplicateFile = duplicateInCache(); + return duplicateFile; + } + } + + private File duplicateInCache() { + try{ + InputStream istream = context.getContentResolver(). + openInputStream(docFile.getUri()); + + File storageFolder = provider.getCacheDir(); + File fileCopy = new File(storageFolder, docFile.getName()); + OutputStream ostream = new FileOutputStream(fileCopy); + + IOUtils.copy(istream, ostream); + return fileCopy; + } catch (Exception e) { + Log.e(LOGTAG, e.getMessage(), e.getCause()); + return null; + } + } + + @Override + public void saveDocument(File file) { + try{ + OutputStream ostream = context.getContentResolver(). + openOutputStream(docFile.getUri()); + InputStream istream = new FileInputStream(file); + + IOUtils.copy(istream, ostream); + + } catch (Exception e) { + Log.e(LOGTAG, e.getMessage(), e.getCause()); + } + } + + @Override + public boolean equals(Object object) { + if (this == object) + return true; + if (!(object instanceof ExternalFile)) + return false; + ExternalFile file = (ExternalFile) object; + return file.getUri().equals(getUri()); + } + +} diff --git a/android/source/src/java/org/libreoffice/storage/external/ExtsdDocumentsProvider.java b/android/source/src/java/org/libreoffice/storage/external/ExtsdDocumentsProvider.java new file mode 100644 index 000000000000..09e993bd1eb2 --- /dev/null +++ b/android/source/src/java/org/libreoffice/storage/external/ExtsdDocumentsProvider.java @@ -0,0 +1,152 @@ +package org.libreoffice.storage.external; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.preference.PreferenceManager; +import android.support.v4.provider.DocumentFile; + +import org.libreoffice.R; +import org.libreoffice.storage.DocumentProviderSettingsActivity; +import org.libreoffice.storage.IFile; + +import java.io.File; +import java.net.URI; + +/** + * Implementation of IDocumentProvider for the external file system, for android 4.4+ + * + * The DocumentFile class is required when accessing files in external storage + * for Android 4.4+. The ExternalFile class is used to handle this. + * + * Android 4.4 & 5+ use different types of root directory paths, + * 5 using a DirectoryTree Uri and 4.4 using a normal File path. + * As such, different methods are required to obtain the rootDirectory IFile. + * 4.4 has to guess the location of the rootDirectory as well. + */ +public class ExtsdDocumentsProvider implements IExternalDocumentProvider, + OnSharedPreferenceChangeListener{ + private static final String LOGTAG = ExtsdDocumentsProvider.class.getSimpleName(); + + private int id; + private File cacheDir; + private Context context; + private String rootPathURI; + + public ExtsdDocumentsProvider(int id, Context context) { + this.id = id; + this.context = context; + setupRootPathUri(); + setupCache(); + } + + private void setupRootPathUri() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + String rootURIGuess = guessRootURI(); + rootPathURI = preferences.getString( + DocumentProviderSettingsActivity.KEY_PREF_EXTERNAL_SD_PATH_URI, rootURIGuess); + } + + //Android 4.4 specific + @TargetApi(Build.VERSION_CODES.KITKAT) + public String guessRootURI() { + File[] options = context.getExternalFilesDirs(null); + File internalSD = Environment.getExternalStorageDirectory(); + String internalSDPath = internalSD.getAbsolutePath(); + + for (File option: options) { + String optionPath = option.getAbsolutePath(); + if(!optionPath.contains(internalSDPath)) + return option.toURI().toString(); + } + + return ""; + } + + private void setupCache() { + // TODO: probably we should do smarter cache management + cacheDir = new File(context.getExternalCacheDir(), "externalFiles"); + if (cacheDir.exists()) { + deleteRecursive(cacheDir); + } + cacheDir.mkdirs(); + } + + private static void deleteRecursive(File file) { + if (file.isDirectory()) { + for (File child : file.listFiles()) + deleteRecursive(child); + } + file.delete(); + } + + public File getCacheDir() { + return cacheDir; + } + + @Override + public IFile getRootDirectory() { + if(android.os.Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + return android4RootDirectory(); + } else { + return android5RootDirectory(); + } + } + + private ExternalFile android4RootDirectory() { + try{ + File f = new File(new URI(rootPathURI)); + return new ExternalFile(this, DocumentFile.fromFile(f), context); + } catch (Exception e) { + //invalid rootPathURI + throw buildRuntimeExceptionForInvalidFileURI(); + } + } + + private ExternalFile android5RootDirectory() { + try { + return new ExternalFile(this, + DocumentFile.fromTreeUri(context, Uri.parse(rootPathURI)), + context); + } catch (Exception e) { + //invalid rootPathURI + throw buildRuntimeExceptionForInvalidFileURI(); + } + } + + private RuntimeException buildRuntimeExceptionForInvalidFileURI() { + return new RuntimeException(context.getString(R.string.ext_document_provider_error)); + } + + @Override + public IFile createFromUri(URI javaURI) { + //TODO: refactor when new DocumentFile API exist + //uri must be of a DocumentFile file, not directory. + Uri androidUri = Uri.parse(javaURI.toString()); + return new ExternalFile(this, + DocumentFile.fromSingleUri(context, androidUri), + context); + } + + @Override + public int getNameResource() { + return R.string.external_sd_file_system; + } + + @Override + public int getId() { + return id; + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences preferences, String key) { + if (key.equals(DocumentProviderSettingsActivity.KEY_PREF_EXTERNAL_SD_PATH_URI)) { + rootPathURI = preferences.getString(key, ""); + } + } + +} diff --git a/android/source/src/java/org/libreoffice/storage/external/IExternalDocumentProvider.java b/android/source/src/java/org/libreoffice/storage/external/IExternalDocumentProvider.java new file mode 100644 index 000000000000..34bbcbc4bad6 --- /dev/null +++ b/android/source/src/java/org/libreoffice/storage/external/IExternalDocumentProvider.java @@ -0,0 +1,19 @@ +package org.libreoffice.storage.external; + +import org.libreoffice.storage.IDocumentProvider; + + +/** + * Interface for external document providers. + */ +public interface IExternalDocumentProvider extends IDocumentProvider { + + /** + * Used to obtain the default directory to display when + * browsing using the internal DirectoryBrowser. + * + * @return a guess of the root file's URI. + */ + String guessRootURI(); + +} diff --git a/android/source/src/java/org/libreoffice/storage/external/LegacyExtSDDocumentsProvider.java b/android/source/src/java/org/libreoffice/storage/external/LegacyExtSDDocumentsProvider.java new file mode 100644 index 000000000000..ae5ddde40141 --- /dev/null +++ b/android/source/src/java/org/libreoffice/storage/external/LegacyExtSDDocumentsProvider.java @@ -0,0 +1,97 @@ +package org.libreoffice.storage.external; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.util.Log; + +import org.libreoffice.R; +import org.libreoffice.storage.DocumentProviderSettingsActivity; +import org.libreoffice.storage.IFile; +import org.libreoffice.storage.IOUtils; +import org.libreoffice.storage.local.LocalFile; + +import java.io.File; +import java.net.URI; + +/** + * Legacy document provider for External SD cards, for Android 4.3 and below. + * + * Uses the the LocalFile class. + */ +public class LegacyExtSDDocumentsProvider implements IExternalDocumentProvider, + SharedPreferences.OnSharedPreferenceChangeListener{ + private static final String LOGTAG = LegacyExtSDDocumentsProvider.class.getSimpleName(); + + private int id; + private Context context; + private String rootPathURI; + + public LegacyExtSDDocumentsProvider(int id, Context context) { + this.id = id; + this.context = context; + setupRootPathUri(); + } + + private void setupRootPathUri() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + String rootURIGuess = guessRootURI(); + + rootPathURI = preferences.getString( + DocumentProviderSettingsActivity.KEY_PREF_EXTERNAL_SD_PATH_URI, rootURIGuess); + } + + public String guessRootURI() { + //hacky method of obtaining extsdcard root + final String value = System.getenv("SECONDARY_STORAGE"); + Log.d(LOGTAG, "guesses: " + value); + if (!TextUtils.isEmpty(value)) { + final String[] paths = value.split(":"); + for (String path : paths) { + File file = new File(path); + if(path.contains("ext") && file.isDirectory()) { + return file.toURI().toString(); + } + } + } + return ""; + } + + @Override + public IFile getRootDirectory() { + if(rootPathURI.equals("")) { + throw new RuntimeException(context.getString(R.string.ext_document_provider_error)); + } + + File f = IOUtils.getFileFromURIString(rootPathURI); + if(IOUtils.isInvalidFile(f)) { + //missing device + throw new RuntimeException(context.getString(R.string.legacy_extsd_missing_error)); + } + return new LocalFile(f); + } + + @Override + public IFile createFromUri(URI uri) { + return new LocalFile(uri); + } + + @Override + public int getNameResource() { + return R.string.external_sd_file_system; + } + + @Override + public int getId() { + return id; + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences preferences, String key) { + if (key.equals(DocumentProviderSettingsActivity.KEY_PREF_EXTERNAL_SD_PATH_URI)) { + rootPathURI = preferences.getString(key, ""); + } + } + +} diff --git a/android/source/src/java/org/libreoffice/storage/external/OTGDocumentsProvider.java b/android/source/src/java/org/libreoffice/storage/external/OTGDocumentsProvider.java new file mode 100644 index 000000000000..37e9be7d1a32 --- /dev/null +++ b/android/source/src/java/org/libreoffice/storage/external/OTGDocumentsProvider.java @@ -0,0 +1,84 @@ +package org.libreoffice.storage.external; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.util.Log; + +import org.libreoffice.R; +import org.libreoffice.storage.DocumentProviderSettingsActivity; +import org.libreoffice.storage.IFile; +import org.libreoffice.storage.IOUtils; +import org.libreoffice.storage.local.LocalFile; + +import java.io.File; +import java.net.URI; + +/** + * TODO: OTG currently uses LocalFile. Change to an IFile that handles abrupt OTG unmounting + */ +public class OTGDocumentsProvider implements IExternalDocumentProvider, + SharedPreferences.OnSharedPreferenceChangeListener { + + private static final String LOGTAG = OTGDocumentsProvider.class.getSimpleName(); + + private Context context; + private String rootPathURI; + private int id; + + public OTGDocumentsProvider(int id, Context context) { + this.context = context; + this.id = id; + setupRootPath(); + } + + private void setupRootPath() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + rootPathURI = preferences.getString( + DocumentProviderSettingsActivity.KEY_PREF_OTG_PATH_URI, ""); + } + + @Override + public IFile createFromUri(URI uri) { + return new LocalFile(uri); + } + + @Override + public int getNameResource() { + return R.string.otg_file_system; + } + + @Override + public int getId() { + return id; + } + + @Override + public IFile getRootDirectory() { + + if(rootPathURI.equals("")) { + throw new RuntimeException(context.getString(R.string.ext_document_provider_error)); + } + + File f = IOUtils.getFileFromURIString(rootPathURI); + if(IOUtils.isInvalidFile(f)) { + //missing device + throw new RuntimeException(context.getString(R.string.otg_missing_error, context)); + } + + return new LocalFile(f); + } + + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key.equals(DocumentProviderSettingsActivity.KEY_PREF_OTG_PATH_URI)) { + rootPathURI = sharedPreferences.getString(key, ""); + } + } + + @Override + public String guessRootURI() { + return ""; + } +} |