Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<permission
android:description="@string/permissionReadCardsDescription"
android:icon="@drawable/ic_launcher_foreground"
android:label="@string/permissionReadCardsLabel"
android:name="me.hackerchick.catima.READ_CARDS"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the docs you write:

The authority for this content provider: `<package_name>.contentprovider.cards`

There are 3 release channels, with 2 possible package names:

| Release Channel | Package Name                |
|-----------------|-----------------------------|
| Google Play     | me.hackerchick.catima       |
| F-Droid         | me.hackerchick.catima       |
| Debug Build     | me.hackerchick.catima.debug |

But it seems to me like this will not use me.hackerchick.catima.debug.READ_CARDS in the debug channel?

I don't really mind apps not having to request both, but IIRC this could cause installation conflicts from the regular and debug builds, not allowing both at the same time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but IIRC this could cause installation conflicts from the regular and debug builds, not allowing both at the same time?

That only happens if there's a conflict between content provider authorities. In the AndroidManifest I set the authority to ${applicationId}.contentprovider.cards, so both channels can be installed in parallel.

I did forget about the permission. Just tested it, and granting it once on either channel grants it on both apps. Do you think this is OK?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds fine to me, yeah

android:protectionLevel="dangerous" />

<uses-sdk tools:overrideLibrary="com.google.zxing.client.android" />

<uses-permission android:name="android.permission.CAMERA" />
Expand Down Expand Up @@ -155,6 +162,12 @@
android:name=".UCropWrapper"
android:theme="@style/AppTheme.NoActionBar" />

<provider
android:name=".contentprovider.CardsContentProvider"
android:authorities="${applicationId}.contentprovider.cards"
android:exported="true"
android:readPermission="me.hackerchick.catima.READ_CARDS"/>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package protect.card_locker.contentprovider;

import static protect.card_locker.DBHelper.LoyaltyCardDbIds;

import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.os.Build;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

import protect.card_locker.BuildConfig;
import protect.card_locker.DBHelper;
import protect.card_locker.preferences.Settings;

public class CardsContentProvider extends ContentProvider {
private static final String TAG = "Catima";

public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".contentprovider.cards";

public static class Version {
public static final String MAJOR_COLUMN = "major";
public static final String MINOR_COLUMN = "minor";
public static final int MAJOR = 1;
public static final int MINOR = 0;
}

private static final int URI_VERSION = 0;
private static final int URI_CARDS = 1;
private static final int URI_GROUPS = 2;
private static final int URI_CARD_GROUPS = 3;

private static final String[] CARDS_DEFAULT_PROJECTION = new String[]{
LoyaltyCardDbIds.ID,
LoyaltyCardDbIds.STORE,
LoyaltyCardDbIds.VALID_FROM,
LoyaltyCardDbIds.EXPIRY,
LoyaltyCardDbIds.BALANCE,
LoyaltyCardDbIds.BALANCE_TYPE,
LoyaltyCardDbIds.NOTE,
LoyaltyCardDbIds.HEADER_COLOR,
LoyaltyCardDbIds.CARD_ID,
LoyaltyCardDbIds.BARCODE_ID,
LoyaltyCardDbIds.BARCODE_TYPE,
LoyaltyCardDbIds.STAR_STATUS,
LoyaltyCardDbIds.LAST_USED,
LoyaltyCardDbIds.ARCHIVE_STATUS,
};

private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH) {{
addURI(AUTHORITY, "version", URI_VERSION);
addURI(AUTHORITY, "cards", URI_CARDS);
addURI(AUTHORITY, "groups", URI_GROUPS);
addURI(AUTHORITY, "card_groups", URI_CARD_GROUPS);
}};

@Override
public boolean onCreate() {
return true;
}

@Nullable
@Override
public Cursor query(@NonNull final Uri uri,
@Nullable final String[] projection,
@Nullable final String selection,
@Nullable final String[] selectionArgs,
@Nullable final String sortOrder) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// Disable the content provider on SDK < 23 since it grants dangerous
// permissions at install-time
Log.w(TAG, "Content provider read is only available for SDK >= 23");
return null;
}

final Settings settings = new Settings(getContext());
if (!settings.getAllowContentProviderRead()) {
Log.w(TAG, "Content provider read is disabled");
return null;
}

final String table;
String[] updatedProjection = projection;

switch (uriMatcher.match(uri)) {
case URI_VERSION:
return queryVersion();
case URI_CARDS:
table = DBHelper.LoyaltyCardDbIds.TABLE;
// Restrict columns to the default projection (omit internal columns such as zoom level)
if (projection == null) {
updatedProjection = CARDS_DEFAULT_PROJECTION;
} else {
final Set<String> defaultProjection = new HashSet<>(Arrays.asList(CARDS_DEFAULT_PROJECTION));
updatedProjection = Arrays.stream(projection).filter(defaultProjection::contains).toArray(String[]::new);
}
break;
case URI_GROUPS:
table = DBHelper.LoyaltyCardDbGroups.TABLE;
break;
case URI_CARD_GROUPS:
table = DBHelper.LoyaltyCardDbIdsGroups.TABLE;
break;
default:
Log.w(TAG, "Unrecognized URI " + uri);
return null;
}

final DBHelper dbHelper = new DBHelper(getContext());
final SQLiteDatabase database = dbHelper.getReadableDatabase();

return database.query(
table,
updatedProjection,
selection,
selectionArgs,
null,
null,
sortOrder
);
}

private Cursor queryVersion() {
final String[] columns = new String[]{Version.MAJOR_COLUMN, Version.MINOR_COLUMN};
final MatrixCursor matrixCursor = new MatrixCursor(columns);
matrixCursor.addRow(new Object[]{Version.MAJOR, Version.MINOR});

return matrixCursor;
}

@Nullable
@Override
public String getType(@NonNull final Uri uri) {
// MIME types are not relevant (for now at least)
return null;
}

@Nullable
@Override
public Uri insert(@NonNull final Uri uri,
@Nullable final ContentValues values) {
// This content provider is read-only for now, so we always return null
return null;
}

@Override
public int delete(@NonNull final Uri uri,
@Nullable final String selection,
@Nullable final String[] selectionArgs) {
// This content provider is read-only for now, so we always return 0
return 0;
}

@Override
public int update(@NonNull final Uri uri,
@Nullable final ContentValues values,
@Nullable final String selection,
@Nullable final String[] selectionArgs) {
// This content provider is read-only for now, so we always return 0
return 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ public boolean getDisableLockscreenWhileViewingCard() {
return getBoolean(R.string.settings_key_disable_lockscreen_while_viewing_card, true);
}

public boolean getAllowContentProviderRead() {
return getBoolean(R.string.settings_key_allow_content_provider_read, true);
}

public boolean getOledDark() {
return getBoolean(R.string.settings_key_oled_dark, false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import android.app.Activity;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.view.MenuItem;

Expand Down Expand Up @@ -150,6 +151,12 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
colorPreference.setEntryValues(R.array.color_values_no_dynamic);
colorPreference.setEntries(R.array.color_value_strings_no_dynamic);
}

// Disable content provider on SDK < 23 since dangerous permissions
// are granted at install-time
Preference contentProviderReadPreference = findPreference(getResources().getString(R.string.settings_key_allow_content_provider_read));
assert contentProviderReadPreference != null;
contentProviderReadPreference.setVisible(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M);
}

private void refreshActivity(boolean reloadMain) {
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
<string name="exporting">Exporting…</string>
<string name="storageReadPermissionRequired">Permission to read storage needed for this action…</string>
<string name="cameraPermissionRequired">Permission to access camera needed for this action…</string>
<string name="permissionReadCardsLabel">Read Catima Cards</string>
<string name="permissionReadCardsDescription">Read your cards and all its details, including notes and images</string>
<string name="cameraPermissionDeniedTitle">Could not access the camera</string>
<string name="noCameraPermissionDirectToSystemSetting">To scan barcodes, Catima will need access to your camera. Tap here to change your permission settings.</string>
<string name="exportOptionExplanation">The data will be written to a location of your choice.</string>
Expand Down Expand Up @@ -115,7 +117,10 @@
<string name="settings_keep_screen_on">Keep screen on</string>
<string name="settings_key_keep_screen_on" translatable="false">pref_keep_screen_on</string>
<string name="settings_disable_lockscreen_while_viewing_card">Prevent screen lock</string>
<string name="settings_allow_content_provider_read_title">Allow other apps to access my data</string>
<string name="settings_allow_content_provider_read_summary">Apps will still have to request permission to be granted access</string>
<string name="settings_key_disable_lockscreen_while_viewing_card" translatable="false">pref_disable_lockscreen_while_viewing_card</string>
<string name="settings_key_allow_content_provider_read" translatable="false">pref_allow_content_provider_read</string>
<string name="settings_key_oled_dark" translatable="false">pref_oled_dark</string>
<string name="sharedpreference_active_tab" translatable="false">sharedpreference_active_tab</string>
<string name="sharedpreference_privacy_policy_shown" translatable="false">sharedpreference_privacy_policy_shown</string>
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/res/xml/preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@
app:iconSpaceReserved="false"
app:singleLineTitle="false" />

<SwitchPreferenceCompat
android:widgetLayout="@layout/preference_material_switch"
android:defaultValue="true"
android:key="@string/settings_key_allow_content_provider_read"
android:summary="@string/settings_allow_content_provider_read_summary"
android:title="@string/settings_allow_content_provider_read_title"
app:iconSpaceReserved="false"
app:singleLineTitle="false" />

</PreferenceCategory>

</PreferenceScreen>
Expand Down
47 changes: 8 additions & 39 deletions app/src/test/java/protect/card_locker/ImportExportTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,25 +67,6 @@ public void setUp() {
mDatabase = TestHelpers.getEmptyDb(activity).getWritableDatabase();
}

/**
* Add the given number of cards, each with
* an index in the store name.
*
* @param cardsToAdd
*/
private void addLoyaltyCards(int cardsToAdd) {
// Add in reverse order to test sorting
for (int index = cardsToAdd; index > 0; index--) {
String storeName = String.format("store, \"%4d", index);
String note = String.format("note, \"%4d", index);
long id = DBHelper.insertLoyaltyCard(mDatabase, storeName, note, null, null, new BigDecimal(String.valueOf(index)), null, BARCODE_DATA, null, BARCODE_TYPE, index, 0, null,0);
boolean result = (id != -1);
assertTrue(result);
}

assertEquals(cardsToAdd, DBHelper.getLoyaltyCardCount(mDatabase));
}

private void addLoyaltyCardsFiveStarred() {
int cardsToAdd = 9;
// Add in reverse order to test sorting
Expand Down Expand Up @@ -183,18 +164,6 @@ public void addLoyaltyCardsWithExpiryNeverPastTodayFuture() {
assertEquals(4, DBHelper.getLoyaltyCardCount(mDatabase));
}

private void addGroups(int groupsToAdd) {
// Add in reverse order to test sorting
for (int index = groupsToAdd; index > 0; index--) {
String groupName = String.format("group, \"%4d", index);
long id = DBHelper.insertGroup(mDatabase, groupName);
boolean result = (id != -1);
assertTrue(result);
}

assertEquals(groupsToAdd, DBHelper.getGroupCount(mDatabase));
}

/**
* Check that all of the cards follow the pattern
* specified in addLoyaltyCards(), and are in sequential order
Expand Down Expand Up @@ -285,7 +254,7 @@ private void checkLoyaltyCardsFiveStarred() {

/**
* Check that all of the groups follow the pattern
* specified in addGroups(), and are in sequential order
* specified in {@link TestHelpers#addGroups}, and are in sequential order
* where the smallest group's index is 1
*/
private void checkGroups() {
Expand All @@ -308,7 +277,7 @@ private void checkGroups() {
public void multipleCardsExportImport() throws IOException {
final int NUM_CARDS = 10;

addLoyaltyCards(NUM_CARDS);
TestHelpers.addLoyaltyCards(mDatabase, NUM_CARDS);

ByteArrayOutputStream outData = new ByteArrayOutputStream();
OutputStreamWriter outStream = new OutputStreamWriter(outData);
Expand Down Expand Up @@ -338,7 +307,7 @@ public void multipleCardsExportImportPasswordProtected() throws IOException {
final int NUM_CARDS = 10;
List<char[]> passwords = Arrays.asList(null, "123456789".toCharArray());
for (char[] password : passwords) {
addLoyaltyCards(NUM_CARDS);
TestHelpers.addLoyaltyCards(mDatabase, NUM_CARDS);

ByteArrayOutputStream outData = new ByteArrayOutputStream();
OutputStreamWriter outStream = new OutputStreamWriter(outData);
Expand Down Expand Up @@ -411,8 +380,8 @@ public void multipleCardsExportImportWithGroups() throws IOException {
final int NUM_CARDS = 10;
final int NUM_GROUPS = 3;

addLoyaltyCards(NUM_CARDS);
addGroups(NUM_GROUPS);
TestHelpers.addLoyaltyCards(mDatabase, NUM_CARDS);
TestHelpers.addGroups(mDatabase, NUM_GROUPS);

List<Group> emptyGroup = new ArrayList<>();

Expand Down Expand Up @@ -484,7 +453,7 @@ public void multipleCardsExportImportWithGroups() throws IOException {
public void importExistingCardsNotReplace() throws IOException {
final int NUM_CARDS = 10;

addLoyaltyCards(NUM_CARDS);
TestHelpers.addLoyaltyCards(mDatabase, NUM_CARDS);

ByteArrayOutputStream outData = new ByteArrayOutputStream();
OutputStreamWriter outStream = new OutputStreamWriter(outData);
Expand Down Expand Up @@ -513,7 +482,7 @@ public void corruptedImportNothingSaved() {
final int NUM_CARDS = 10;

for (DataFormat format : DataFormat.values()) {
addLoyaltyCards(NUM_CARDS);
TestHelpers.addLoyaltyCards(mDatabase, NUM_CARDS);

ByteArrayOutputStream outData = new ByteArrayOutputStream();
OutputStreamWriter outStream = new OutputStreamWriter(outData);
Expand Down Expand Up @@ -558,7 +527,7 @@ public void useImportExportTask() throws FileNotFoundException {
final File sdcardDir = Environment.getExternalStorageDirectory();
final File exportFile = new File(sdcardDir, "Catima.csv");

addLoyaltyCards(NUM_CARDS);
TestHelpers.addLoyaltyCards(mDatabase, NUM_CARDS);

TestTaskCompleteListener listener = new TestTaskCompleteListener();

Expand Down
Loading