From 402cbcc3c225e04b7bcd78599d55078df493121c Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Tue, 29 Jun 2021 10:56:24 +0200 Subject: [PATCH] implement combined EXTRA_STREAM/EXTRA_TEXT Intents for createChooser() An app can create an `ACTION_SEND Intent` to share a file and/or text to another app. This `Intent` can provide an `InputStream` to get the actual file via `EXTRA_STREAM`. This `Intent` can also include `EXTRA_TEXT` to describe what the shared file is. Apps like K-9Mail, Gmail, Signal, etc. correctly handle this case and include both the file itself and the related text in the draft message. This is used in F-Droid to share apps. The text is the name/description of the app and the URL that points to the app's page on f-droid.org. The `EXTRA_STREAM` is the actual APK if available. Having all together means that the user can choose to share a message or the actual APK, depending on the receiving app. Unfortunately, not all apps handle this well. WhatsApp and Element only attach the file and ignore the text. https://github.com/vector-im/element-android/issues/3637 --- .../nearby/PublicSourceDirProviderTest.java | 29 ++++++ .../nearby/PublicSourceDirProvider.java | 35 ++++++- .../fdroid/views/AppDetailsActivity.java | 93 +++++++++++-------- 3 files changed, 115 insertions(+), 42 deletions(-) diff --git a/app/src/androidTest/java/org/fdroid/fdroid/nearby/PublicSourceDirProviderTest.java b/app/src/androidTest/java/org/fdroid/fdroid/nearby/PublicSourceDirProviderTest.java index df4d75d9a..507feb1e6 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/nearby/PublicSourceDirProviderTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/nearby/PublicSourceDirProviderTest.java @@ -3,7 +3,9 @@ package org.fdroid.fdroid.nearby; import android.content.Context; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; +import android.database.Cursor; import android.net.Uri; +import android.provider.MediaStore; import org.apache.commons.io.FileUtils; import org.junit.Before; @@ -19,6 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @RunWith(AndroidJUnit4.class) @@ -65,4 +68,30 @@ public class PublicSourceDirProviderTest { } } } + + /** + * Test whether querying the custom {@link android.content.ContentProvider} + * for installed APKs returns the right kind of data. + */ + @Test + public void testQuery() throws IOException { + PackageManager pm = context.getPackageManager(); + List packageInfoList = pm.getInstalledPackages(0); + for (PackageInfo packageInfo : packageInfoList) { + File apk = new File(packageInfo.applicationInfo.publicSourceDir); + if (apk.getCanonicalPath().startsWith("/system")) { + continue; + } + Uri uri = PublicSourceDirProvider.getUri(context, packageInfo.packageName); + Cursor cursor = context.getContentResolver().query(uri, null, null, null, null); + assertNotNull(cursor); + + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + assertNotNull(cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME))); + cursor.moveToNext(); + } + cursor.close(); + } + } } diff --git a/app/src/main/java/org/fdroid/fdroid/nearby/PublicSourceDirProvider.java b/app/src/main/java/org/fdroid/fdroid/nearby/PublicSourceDirProvider.java index f9bf31bba..60d824cfc 100644 --- a/app/src/main/java/org/fdroid/fdroid/nearby/PublicSourceDirProvider.java +++ b/app/src/main/java/org/fdroid/fdroid/nearby/PublicSourceDirProvider.java @@ -3,11 +3,14 @@ package org.fdroid.fdroid.nearby; import android.content.ContentProvider; import android.content.ContentValues; import android.content.Context; +import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.database.Cursor; +import android.database.MatrixCursor; import android.net.Uri; import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; import java.io.File; import java.io.FileNotFoundException; @@ -43,11 +46,39 @@ public class PublicSourceDirProvider extends ContentProvider { context.getPackageName(), TAG, packageName)); } + public static Intent getApkShareIntent(Context context, String packageName) { + Intent intent = new Intent(Intent.ACTION_SEND); + Uri apkUri = getUri(context, packageName); + intent.setType(SHARE_APK_MIME_TYPE); + intent.putExtra(Intent.EXTRA_STREAM, apkUri); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + return intent; + } + @Nullable @Override public Cursor query(@NonNull Uri uri, @Nullable String[] projection, - @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) { - throw new IllegalStateException("unimplemented"); + @Nullable String selection, @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + MatrixCursor metadataCursor = new MatrixCursor(new String[]{ + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.MIME_TYPE, + MediaStore.MediaColumns.DATA, + MediaStore.MediaColumns.SIZE, + }); + try { + ApplicationInfo applicationInfo = getApplicationInfo(uri); + File f = new File(applicationInfo.publicSourceDir); + metadataCursor.addRow(new Object[]{ + pm.getApplicationLabel(applicationInfo).toString().replace(" ", "") + ".apk", + SHARE_APK_MIME_TYPE, + Uri.parse("file://" + f.getCanonicalPath()), + f.length(), + }); + } catch (PackageManager.NameNotFoundException | IOException e) { + e.printStackTrace(); + } + return metadataCursor; } @Nullable diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java index 6066e6eb8..cf143f124 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java @@ -30,8 +30,6 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; import android.database.ContentObserver; import android.net.Uri; import android.os.Build; @@ -47,7 +45,6 @@ import android.widget.Toast; import com.bumptech.glide.request.RequestOptions; import com.google.android.material.appbar.MaterialToolbar; -import org.acra.ACRA; import org.fdroid.fdroid.AppUpdateStatusManager; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.NfcHelper; @@ -59,7 +56,6 @@ import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.AppPrefsProvider; import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.data.Schema; -import org.fdroid.fdroid.installer.ApkFileProvider; import org.fdroid.fdroid.installer.InstallManagerService; import org.fdroid.fdroid.installer.Installer; import org.fdroid.fdroid.installer.InstallerFactory; @@ -67,7 +63,6 @@ import org.fdroid.fdroid.installer.InstallerService; import org.fdroid.fdroid.nearby.PublicSourceDirProvider; import org.fdroid.fdroid.views.apps.FeatureImage; -import java.io.IOException; import java.util.Iterator; import androidx.annotation.Nullable; @@ -262,19 +257,62 @@ public class AppDetailsActivity extends AppCompatActivity return true; } + /** + * An app can create an {@link Intent#ACTION_SEND} to share a file + * and/or text to another app. This {@link Intent} can provide an + * {@link java.io.InputStream} to get the actual file via + * {@link Intent#EXTRA_STREAM}. This {@link Intent} can also include + * {@link Intent#EXTRA_TEXT} to describe what the shared file is. Apps + * like K-9Mail, Gmail, Signal, etc. correctly handle this case and + * include both the file itself and the related text in the draft message. + *

+ * This is used in F-Droid to share apps. The text is the + * name/description of the app and the URL that points to the app's page + * on f-droid.org. The {@link Intent#EXTRA_STREAM} is the actual APK if available. + * Having all together means that the user can choose to share a message + * or the actual APK, depending on the receiving app. + *

+ * Unfortunately, not all apps handle this well. WhatsApp and Element + * only attach the file and ignore the text. + * + * @see + */ @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.action_share) { - Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(Intent.EXTRA_SUBJECT, app.name); - shareIntent.putExtra(Intent.EXTRA_TEXT, app.name + " (" + app.summary - + ") - https://f-droid.org/packages/" + app.packageName); + String extraText = String.format("%s (%s)\nhttps://f-droid.org/packages/%s/", + app.name, app.summary, app.packageName); - // TODO: allow user to share APK if app is installed - boolean allowShareApk = app.isInstalled(getApplicationContext()) && bluetoothAdapter != null; + Intent uriIntent = new Intent(Intent.ACTION_SEND); + uriIntent.setData(Uri.parse(String.format("https://f-droid.org/packages/%s/", app.packageName))); + uriIntent.putExtra(Intent.EXTRA_TITLE, app.name); - startActivity(Intent.createChooser(shareIntent, getString(R.string.menu_share))); + Intent textIntent = new Intent(Intent.ACTION_SEND); + textIntent.setType("text/plain"); + textIntent.putExtra(Intent.EXTRA_SUBJECT, app.name); + textIntent.putExtra(Intent.EXTRA_TITLE, app.name); + textIntent.putExtra(Intent.EXTRA_TEXT, extraText); + + if (app.isInstalled(getApplicationContext())) { + // allow user to share APK if app is installed + Intent streamIntent = PublicSourceDirProvider.getApkShareIntent(this, app.packageName); + streamIntent.putExtra(Intent.EXTRA_SUBJECT, "Shared from F-Droid: " + app.name + ".apk"); + streamIntent.putExtra(Intent.EXTRA_TITLE, app.name + ".apk"); + streamIntent.putExtra(Intent.EXTRA_TEXT, extraText); + + Intent chooserIntent = Intent.createChooser(streamIntent, getString(R.string.menu_share)); + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{ + textIntent, + uriIntent, + }); + startActivity(chooserIntent); + } else { + Intent chooserIntent = Intent.createChooser(textIntent, getString(R.string.menu_share)); + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{ + uriIntent, + }); + startActivity(chooserIntent); + } return true; } else if (item.getItemId() == R.id.action_ignore_all) { app.getPrefs(this).ignoreAllUpdates ^= true; @@ -297,6 +335,7 @@ public class AppDetailsActivity extends AppCompatActivity return super.onOptionsItemSelected(item); } + /* private void shareApkBluetooth() { // If Bluetooth has not been enabled/turned on, then // enabling device discoverability will automatically enable Bluetooth @@ -305,33 +344,7 @@ public class AppDetailsActivity extends AppCompatActivity startActivityForResult(discoverBt, REQUEST_ENABLE_BLUETOOTH); // if this is successful, the Bluetooth transfer is started } - - @Nullable - private Intent getApkShareIntent() { - try { - PackageManager pm = getPackageManager(); - PackageInfo packageInfo = pm.getPackageInfo(app.packageName, PackageManager.GET_META_DATA); - - Intent shareApkIntent = new Intent(Intent.ACTION_SEND); - // The APK type ("application/vnd.android.package-archive") is blocked by stock Android, so use zip - shareApkIntent.setType(PublicSourceDirProvider.SHARE_APK_MIME_TYPE); - shareApkIntent.putExtra(Intent.EXTRA_STREAM, ApkFileProvider.getSafeUri(this, packageInfo)); - - // App might have been uninstalled while the menu was open - if (app.isInstalled(getApplicationContext())) { - return shareApkIntent; - } else { - Toast.makeText(this, R.string.app_not_installed, Toast.LENGTH_SHORT).show(); - } - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "Could not get application info to share", e); - } catch (IOException e) { - Exception toLog = new RuntimeException("Error preparing file to share", e); - ACRA.getErrorReporter().handleException(toLog, false); - } - Toast.makeText(this, R.string.share_apk_error, Toast.LENGTH_SHORT).show(); - return null; - } + */ @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) {