diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 7f192c1f5..9e29de023 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -173,14 +173,9 @@ limitations under the License.
android:resource="@xml/track_widget_info" />
-
diff --git a/src/main/java/de/dennisguse/opentracks/android/ContentResolverWrapper.java b/src/main/java/de/dennisguse/opentracks/android/ContentResolverWrapper.java
new file mode 100644
index 000000000..cdfe66ac6
--- /dev/null
+++ b/src/main/java/de/dennisguse/opentracks/android/ContentResolverWrapper.java
@@ -0,0 +1,40 @@
+package de.dennisguse.opentracks.android;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class ContentResolverWrapper implements IContentResolver {
+
+ private ContentResolver contentResolver;
+
+ public ContentResolverWrapper(ContentResolver contentResolver) {
+ this.contentResolver = contentResolver;
+ }
+
+ @Nullable
+ public Uri insert(@NonNull Uri url, @Nullable ContentValues values) {
+ return contentResolver.insert(url, values);
+ }
+
+ public int bulkInsert(@NonNull Uri url, @NonNull ContentValues[] values) {
+ return contentResolver.bulkInsert(url, values);
+ }
+
+ @Nullable
+ public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
+ return contentResolver.query(uri, projection, selection, selectionArgs, sortOrder);
+ }
+
+ public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String where, @Nullable String[] selectionArgs) {
+ return contentResolver.update(uri, values, where, selectionArgs);
+ }
+
+ public int delete(@NonNull Uri url, @Nullable String where, @Nullable String[] selectionArgs) {
+ return contentResolver.delete(url, where, selectionArgs);
+ }
+}
diff --git a/src/main/java/de/dennisguse/opentracks/android/IContentResolver.java b/src/main/java/de/dennisguse/opentracks/android/IContentResolver.java
new file mode 100644
index 000000000..a04617f3e
--- /dev/null
+++ b/src/main/java/de/dennisguse/opentracks/android/IContentResolver.java
@@ -0,0 +1,27 @@
+package de.dennisguse.opentracks.android;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresPermission;
+
+/**
+ * Defines the interface actually shared by {@link android.content.ContentProvider} and {@link android.content.ContentResolver}.
+ * So, both can be used interchangeably.
+ */
+public interface IContentResolver {
+ @Nullable
+ Uri insert(@RequiresPermission.Write @NonNull Uri url, @Nullable ContentValues values);
+
+ int bulkInsert(@RequiresPermission.Write @NonNull Uri url, @NonNull ContentValues[] values);
+
+ @Nullable
+ Cursor query(@RequiresPermission.Read @NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder);
+
+ int update(@RequiresPermission.Write @NonNull Uri uri, @Nullable ContentValues values, @Nullable String where, @Nullable String[] selectionArgs);
+
+ int delete(@RequiresPermission.Write @NonNull Uri url, @Nullable String where, @Nullable String[] selectionArgs);
+}
\ No newline at end of file
diff --git a/src/main/java/de/dennisguse/opentracks/content/ContentProviderUtilsImpl.java b/src/main/java/de/dennisguse/opentracks/content/ContentProviderUtilsImpl.java
index 31d69b8e7..f119e7c2d 100644
--- a/src/main/java/de/dennisguse/opentracks/content/ContentProviderUtilsImpl.java
+++ b/src/main/java/de/dennisguse/opentracks/content/ContentProviderUtilsImpl.java
@@ -24,18 +24,21 @@ import android.location.Location;
import android.net.Uri;
import android.util.Log;
-import de.dennisguse.opentracks.content.Waypoint.WaypointType;
-import de.dennisguse.opentracks.content.sensor.SensorDataSet;
-import de.dennisguse.opentracks.stats.TripStatistics;
-import de.dennisguse.opentracks.util.FileUtils;
-
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
+import de.dennisguse.opentracks.android.ContentResolverWrapper;
+import de.dennisguse.opentracks.android.IContentResolver;
+import de.dennisguse.opentracks.content.Waypoint.WaypointType;
+import de.dennisguse.opentracks.content.sensor.SensorDataSet;
+import de.dennisguse.opentracks.stats.TripStatistics;
+import de.dennisguse.opentracks.util.FileUtils;
+
/**
* {@link ContentProviderUtils} implementation.
+ * Allows to use {@link ContentResolver} and {@link android.content.ContentProvider} interchangeably via {@link IContentResolver}.
*
* @author Leif Hendrik Wilden
*/
@@ -45,10 +48,14 @@ public class ContentProviderUtilsImpl implements ContentProviderUtils {
private static final int MAX_LATITUDE = 90000000;
- private final ContentResolver contentResolver;
+ private final IContentResolver contentResolver;
private int defaultCursorBatchSize = 2000;
public ContentProviderUtilsImpl(ContentResolver contentResolver) {
+ this.contentResolver = new ContentResolverWrapper(contentResolver);
+ }
+
+ public ContentProviderUtilsImpl(IContentResolver contentResolver) {
this.contentResolver = contentResolver;
}
@@ -716,8 +723,7 @@ public class ContentProviderUtilsImpl implements ContentProviderUtils {
if (maxWaypoints >= 0) {
sortOrder += " LIMIT " + maxWaypoints;
}
- return contentResolver.query(
- WaypointsColumns.CONTENT_URI, projection, selection, selectionArgs, sortOrder);
+ return contentResolver.query(WaypointsColumns.CONTENT_URI, projection, selection, selectionArgs, sortOrder);
}
@Override
diff --git a/src/main/java/de/dennisguse/opentracks/content/CustomContentProvider.java b/src/main/java/de/dennisguse/opentracks/content/CustomContentProvider.java
index 792927554..f599c3cc0 100644
--- a/src/main/java/de/dennisguse/opentracks/content/CustomContentProvider.java
+++ b/src/main/java/de/dennisguse/opentracks/content/CustomContentProvider.java
@@ -40,7 +40,7 @@ import androidx.annotation.VisibleForTesting;
*
* @author Leif Hendrik Wilden
*/
-public class CustomContentProvider extends ContentProvider {
+public abstract class CustomContentProvider extends ContentProvider {
@VisibleForTesting
static final int DATABASE_VERSION = 23;
diff --git a/src/main/java/de/dennisguse/opentracks/content/ShareContentProvider.java b/src/main/java/de/dennisguse/opentracks/content/ShareContentProvider.java
index 701644896..a931dcb33 100644
--- a/src/main/java/de/dennisguse/opentracks/content/ShareContentProvider.java
+++ b/src/main/java/de/dennisguse/opentracks/content/ShareContentProvider.java
@@ -1,9 +1,7 @@
package de.dennisguse.opentracks.content;
-import android.content.ContentProvider;
-import android.content.ContentValues;
import android.content.Context;
-import android.content.Intent;
+import android.content.UriMatcher;
import android.content.pm.ProviderInfo;
import android.database.Cursor;
import android.database.MatrixCursor;
@@ -22,24 +20,33 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Arrays;
-import de.dennisguse.opentracks.BuildConfig;
+import de.dennisguse.opentracks.android.IContentResolver;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.io.file.exporter.FileTrackExporter;
import de.dennisguse.opentracks.io.file.exporter.TrackWriter;
/**
- * A content provider that mimics the behavior of {@link androidx.core.content.FileProvider}, which shares virtual (non-existing) files.
- * The actual content is generated by accessing {@link CustomContentProvider} on request.
+ * A content provider that mimics the behavior of {@link androidx.core.content.FileProvider}, which shares virtual (non-existing) KML-files.
+ * The actual content of the virtual files is generated by using the functionality defined in {@link CustomContentProvider}.
+ *
+ * Moreover, it manages access to OpenTrack's database via {@link CustomContentProvider}.
+ *
+ * Explanation:
+ * Although a request is handled by a {@link android.content.ContentProvider} (with temporarily granted permission), Android's security infrastructure prevents forwarding queries to non-exported {@link android.content.ContentProvider}.
+ * Thus, if {@link ShareContentProvider} and {@link CustomContentProvider} would be two different instances, the data would not be accessible to external apps.
+ * While handling a request {@link ShareContentProvider} could `grantPermissions()` to the calling app for {@link CustomContentProvider}'s URI.
+ * However, while handling the request this would allow the calling app to actually contact {@link CustomContentProvider} directly and get access to stored data that should remain private.
+ *
*/
-public class ShareContentProvider extends ContentProvider {
-
- public static final String TAG = ShareContentProvider.class.getCanonicalName();
-
- public static final String SHAREPROVIDER = BuildConfig.APPLICATION_ID;
+public class ShareContentProvider extends CustomContentProvider implements IContentResolver {
private static final String[] COLUMNS = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
+ public static final String TAG = ShareContentProvider.class.getCanonicalName();
+
public static String MIME = "application/kml+xml";
+ private static final int URI_KML = 1;
+ private final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
public static Uri createURI(long[] trackIds) {
if (trackIds.length == 0) {
@@ -52,7 +59,31 @@ public class ShareContentProvider extends ContentProvider {
}
builder.deleteCharAt(builder.lastIndexOf(","));
- return Uri.parse("content://" + SHAREPROVIDER + "/" + builder + ".kml");
+ return Uri.parse("content://" + ContentProviderUtils.AUTHORITY + "/" + TracksColumns.TABLE_NAME + "/kml/" + builder + ".kml");
+ }
+
+ @Override
+ public boolean onCreate() {
+ uriMatcher.addURI(ContentProviderUtils.AUTHORITY, TracksColumns.TABLE_NAME + "/kml/*", URI_KML);
+ return super.onCreate();
+ }
+
+ /**
+ * Do not allow to be exported via AndroidManifest.
+ * Check that caller has permissions to access {@link CustomContentProvider}.
+ */
+ @Override
+ public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) {
+ super.attachInfo(context, info);
+
+ // Sanity check our security
+ if (info.exported) {
+ throw new UnsupportedOperationException("Provider must not be exported");
+ }
+
+ if (!info.grantUriPermissions) {
+ throw new SecurityException("Provider must grant uri permissions");
+ }
}
private static long[] parseURI(Uri uri) {
@@ -69,21 +100,12 @@ public class ShareContentProvider extends ContentProvider {
return trackIds;
}
- @Override
- public boolean onCreate() {
- return true;
- }
-
- @Override
- public void attachInfo(Context context, ProviderInfo info) {
- super.attachInfo(context, info);
- if (!info.grantUriPermissions) {
- throw new SecurityException("Provider must grant uri permissions");
- }
- }
-
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
+ if (uriMatcher.match(uri) != URI_KML) {
+ return super.query(uri, projection, selection, selectionArgs, sortOrder);
+ }
+
// ContentProvider has already checked granted permissions
if (projection == null) {
projection = COLUMNS;
@@ -113,16 +135,16 @@ public class ShareContentProvider extends ContentProvider {
@Nullable
@Override
public String getType(@NonNull Uri uri) {
- return MIME;
+ if (uriMatcher.match(uri) == URI_KML) {
+ return MIME;
+ }
+ return super.getType(uri);
}
@Nullable
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
- ContentProviderUtils contentProviderUtils = ContentProviderUtils.Factory.get(getContext());
-
- final Uri customContentProviderURI = Uri.parse("content://" + ContentProviderUtils.AUTHORITY + "/tracks");
- getContext().grantUriPermission(getCallingPackage(), customContentProviderURI, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ ContentProviderUtils contentProviderUtils = new ContentProviderUtilsImpl(this);
long[] trackIds = parseURI(uri);
final Track[] tracks = new Track[trackIds.length];
@@ -141,35 +163,10 @@ public class ShareContentProvider extends ContentProvider {
} catch (IOException e) {
Log.w(TAG, "Oops closing " + e);
Toast.makeText(getContext(), "", Toast.LENGTH_SHORT).show();
- } finally {
-
- getContext().revokeUriPermission(customContentProviderURI, Intent.FLAG_GRANT_READ_URI_PERMISSION);
}
}
};
return openPipeHelper(uri, getType(uri), null, null, pipeDataWriter);
}
-
- @Nullable
- @Override
- public String[] getStreamTypes(@NonNull Uri uri, @NonNull String mimeTypeFilter) {
- throw new UnsupportedOperationException();
- }
-
- @Nullable
- @Override
- public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
- throw new UnsupportedOperationException();
- }
}