Introduce read-only ContentProvider for cards (#1121)

This commit is contained in:
José Rebelo
2023-07-03 19:59:39 +01:00
committed by GitHub
parent 28c0b488e6
commit bf94d208bd
10 changed files with 595 additions and 43 deletions

View File

@@ -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"
android:protectionLevel="dangerous" />
<uses-sdk tools:overrideLibrary="com.google.zxing.client.android" />
<uses-permission android:name="android.permission.CAMERA" />
@@ -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}"

View File

@@ -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;
}
}

View File

@@ -79,6 +79,10 @@ public class Settings {
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);
}

View File

@@ -3,6 +3,7 @@ package protect.card_locker.preferences;
import android.app.Activity;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.view.MenuItem;
@@ -150,6 +151,12 @@ public class SettingsActivity extends CatimaAppCompatActivity {
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) {

View File

@@ -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>
@@ -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>

View File

@@ -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>

View File

@@ -67,25 +67,6 @@ public class ImportExportTest {
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
@@ -183,18 +164,6 @@ public class ImportExportTest {
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
@@ -285,7 +254,7 @@ public class ImportExportTest {
/**
* 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() {
@@ -308,7 +277,7 @@ public class ImportExportTest {
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);
@@ -338,7 +307,7 @@ public class ImportExportTest {
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);
@@ -411,8 +380,8 @@ public class ImportExportTest {
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<>();
@@ -484,7 +453,7 @@ public class ImportExportTest {
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);
@@ -513,7 +482,7 @@ public class ImportExportTest {
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);
@@ -558,7 +527,7 @@ public class ImportExportTest {
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();

View File

@@ -1,14 +1,23 @@
package protect.card_locker;
import android.app.Activity;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import com.google.zxing.BarcodeFormat;
import java.io.FileNotFoundException;
import java.math.BigDecimal;
public class TestHelpers {
static public DBHelper getEmptyDb(Activity activity) {
DBHelper db = new DBHelper(activity);
private static final String BARCODE_DATA = "428311627547";
private static final CatimaBarcode BARCODE_TYPE = CatimaBarcode.fromBarcode(BarcodeFormat.UPC_A);
public static DBHelper getEmptyDb(Context context) {
DBHelper db = new DBHelper(context);
SQLiteDatabase database = db.getWritableDatabase();
// Make sure no files remain
@@ -19,7 +28,7 @@ public class TestHelpers {
for (ImageLocationType imageLocationType : ImageLocationType.values()) {
try {
Utils.saveCardImage(activity.getApplicationContext(), null, cardID, imageLocationType);
Utils.saveCardImage(context.getApplicationContext(), null, cardID, imageLocationType);
} catch (FileNotFoundException ignored) {
}
}
@@ -34,4 +43,35 @@ public class TestHelpers {
return db;
}
/**
* Add the given number of cards, each with an index in the store name.
*
* @param mDatabase
* @param cardsToAdd
*/
public static void addLoyaltyCards(final SQLiteDatabase mDatabase, final 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));
}
public static void addGroups(final SQLiteDatabase mDatabase, 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));
}
}

View File

@@ -0,0 +1,259 @@
package protect.card_locker.contentprovider;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.pm.ProviderInfo;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Currency;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import protect.card_locker.CatimaBarcode;
import protect.card_locker.DBHelper;
import protect.card_locker.Group;
import protect.card_locker.TestHelpers;
@RunWith(RobolectricTestRunner.class)
public class CardsContentProviderTest {
private ContentResolver mResolver;
private DBHelper dbHelper;
private SQLiteDatabase mDatabase;
@Before
public void setUp() {
final ContentProvider contentProvider = new CardsContentProvider();
final ProviderInfo providerInfo = new ProviderInfo();
providerInfo.authority = CardsContentProvider.AUTHORITY;
contentProvider.attachInfo(RuntimeEnvironment.getApplication(), providerInfo);
contentProvider.onCreate();
Robolectric.buildContentProvider(CardsContentProvider.class).create(providerInfo);
mResolver = RuntimeEnvironment.getApplication().getContentResolver();
dbHelper = TestHelpers.getEmptyDb(RuntimeEnvironment.getApplication());
mDatabase = dbHelper.getWritableDatabase();
}
@After
public void cleanup() {
mDatabase.close();
dbHelper.close();
}
@Test
public void testVersion() {
final Uri versionUri = getUri("version");
try (Cursor cursor = mResolver.query(versionUri, null, null, null)) {
assertEquals("number of entries", 1, cursor.getCount());
assertEquals("number of columns", 2, cursor.getColumnCount());
assertArrayEquals("column names", new String[]{"major", "minor"}, cursor.getColumnNames());
cursor.moveToNext();
assertEquals("major version", 1, cursor.getInt(cursor.getColumnIndexOrThrow("major")));
assertEquals("minor version", 0, cursor.getInt(cursor.getColumnIndexOrThrow("minor")));
}
}
@Test
public void testCards() {
final Uri cardsUri = getUri("cards");
try (Cursor cursor = mResolver.query(cardsUri, null, null, null)) {
assertEquals(cursor.getCount(), 0);
}
final String store = "the best store";
final String note = "this is a note";
final Date validFrom = Date.from(Instant.ofEpochMilli(1687112209000L));
final Date expiry = Date.from(Instant.ofEpochMilli(1687112277000L));
final BigDecimal balance = new BigDecimal("123.20");
final Currency balanceType = Currency.getInstance("EUR");
final String cardId = "a-card-id";
final String barcodeId = "barcode-id";
final CatimaBarcode barcodeType = CatimaBarcode.fromName("QR_CODE");
final int headerColor = 0xFFFF00FF;
final int starStatus = 1;
final long lastUsed = 1687112282000L;
final int archiveStatus = 1;
long id = DBHelper.insertLoyaltyCard(
mDatabase, store, note, validFrom, expiry, balance, balanceType,
cardId, barcodeId, barcodeType, headerColor, starStatus, lastUsed,
archiveStatus
);
assertEquals("expect first card", 1, id);
try (Cursor cursor = mResolver.query(cardsUri, null, null, null)) {
assertEquals("number of cards", 1, cursor.getCount());
final String[] expectedColumns = new String[]{
"_id", "store", "validfrom", "expiry", "balance", "balancetype",
"note", "headercolor", "cardid", "barcodeid",
"barcodetype", "starstatus", "lastused", "archive"
};
assertEquals("number of columns", expectedColumns.length, cursor.getColumnCount());
assertEquals(
"column names",
new HashSet<>(Arrays.asList(expectedColumns)),
new HashSet<>(Arrays.asList(cursor.getColumnNames()))
);
cursor.moveToNext();
final int actualId = cursor.getInt(cursor.getColumnIndexOrThrow("_id"));
final String actualName = cursor.getString(cursor.getColumnIndexOrThrow("store"));
final String actualNote = cursor.getString(cursor.getColumnIndexOrThrow("note"));
final long actualValidFrom = cursor.getLong(cursor.getColumnIndexOrThrow("validfrom"));
final long actualExpiry = cursor.getLong(cursor.getColumnIndexOrThrow("expiry"));
final BigDecimal actualBalance = new BigDecimal(cursor.getString(cursor.getColumnIndexOrThrow("balance")));
final String actualBalanceType = cursor.getString(cursor.getColumnIndexOrThrow("balancetype"));
final String actualCardId = cursor.getString(cursor.getColumnIndexOrThrow("cardid"));
final String actualBarcodeId = cursor.getString(cursor.getColumnIndexOrThrow("barcodeid"));
final String actualBarcodeType = cursor.getString(cursor.getColumnIndexOrThrow("barcodetype"));
final int actualHeaderColor = cursor.getInt(cursor.getColumnIndexOrThrow("headercolor"));
final int actualStarred = cursor.getInt(cursor.getColumnIndexOrThrow("starstatus"));
final long actualLastUsed = cursor.getLong(cursor.getColumnIndexOrThrow("lastused"));
final int actualArchiveStatus = cursor.getInt(cursor.getColumnIndexOrThrow("archive"));
assertEquals("Id", 1, actualId);
assertEquals("Name", store, actualName);
assertEquals("Note", note, actualNote);
assertEquals("ValidFrom", validFrom.getTime(), actualValidFrom);
assertEquals("Expiry", expiry.getTime(), actualExpiry);
assertEquals("Balance", balance, actualBalance);
assertEquals("BalanceTypeColumn", balanceType.toString(), actualBalanceType);
assertEquals("CardId", cardId, actualCardId);
assertEquals("BarcodeId", barcodeId, actualBarcodeId);
assertEquals("BarcodeType", barcodeType.format().name(), actualBarcodeType);
assertEquals("HeaderColorColumn", headerColor, actualHeaderColor);
assertEquals("Starred", starStatus, actualStarred);
assertEquals("LastUsed", lastUsed, actualLastUsed);
assertEquals("ArchiveStatus", archiveStatus, actualArchiveStatus);
}
}
@Test
public void testCardsProjection() {
final Uri cardsUri = getUri("cards");
try (Cursor cursor = mResolver.query(cardsUri, null, null, null)) {
assertEquals(cursor.getCount(), 0);
}
TestHelpers.addLoyaltyCards(mDatabase, 1);
// Query with projection of columns, including internal column names, which should be filtered out
try (Cursor cursor = mResolver.query(cardsUri, new String[] {"_id", "store", "zoomlevel"}, null, null)) {
assertEquals("number of cards", 1, cursor.getCount());
assertEquals("number of columns", 2, cursor.getColumnCount());
assertArrayEquals("column names", new String[]{"_id", "store"}, cursor.getColumnNames());
cursor.moveToNext();
final int actualId = cursor.getInt(cursor.getColumnIndexOrThrow("_id"));
final String actualName = cursor.getString(cursor.getColumnIndexOrThrow("store"));
assertEquals("id", 1, actualId);
assertEquals("store", "store, \" 1", actualName);
}
}
@Test
public void testGroups() {
final Uri groupsUri = getUri("groups");
try (Cursor cursor = mResolver.query(groupsUri, null, null, null)) {
assertEquals("start without groups", 0, cursor.getCount());
}
TestHelpers.addGroups(mDatabase, 4);
try (Cursor cursor = mResolver.query(groupsUri, null, null, null)) {
assertEquals("number of groups", 4, cursor.getCount());
assertEquals("number of columns", 2, cursor.getColumnCount());
assertArrayEquals("column names", new String[]{"_id", "orderId"}, cursor.getColumnNames());
for (int i = 0; i < 4; i++) {
cursor.moveToNext();
assertEquals(
String.format("groups[%d]._id", i),
String.format("group, \"%4d", 4 - i),
cursor.getString(cursor.getColumnIndexOrThrow("_id"))
);
assertEquals(
String.format("groups[%d].orderId", i),
String.valueOf(i),
cursor.getString(cursor.getColumnIndexOrThrow("orderId"))
);
}
}
}
@Test
public void testCardGroups() {
final Uri cardGroupsUri = getUri("card_groups");
try (Cursor cursor = mResolver.query(cardGroupsUri, null, null, null)) {
assertEquals(cursor.getCount(), 0);
}
TestHelpers.addLoyaltyCards(mDatabase, 5);
TestHelpers.addGroups(mDatabase, 4);
final List<Group> groupsForOne = new ArrayList<>();
groupsForOne.add(DBHelper.getGroup(mDatabase, "group, \" 1"));
final List<Group> groupsForTwo = new ArrayList<>();
groupsForTwo.add(DBHelper.getGroup(mDatabase, "group, \" 1"));
groupsForTwo.add(DBHelper.getGroup(mDatabase, "group, \" 2"));
DBHelper.setLoyaltyCardGroups(mDatabase, 1, groupsForOne);
DBHelper.setLoyaltyCardGroups(mDatabase, 2, groupsForTwo);
final Map<String, List<String>> expectedGroups = new HashMap<>() {{
put("group, \" 1", Arrays.asList("1", "2"));
put("group, \" 2", Collections.singletonList("2"));
}};
try (Cursor cursor = mResolver.query(cardGroupsUri, null, null, null)) {
assertEquals("number of card groups", 3, cursor.getCount());
assertEquals("number of columns", 2, cursor.getColumnCount());
assertArrayEquals("column names", new String[]{"cardId", "groupId"}, cursor.getColumnNames());
final Map<String, List<String>> groups = new HashMap<>();
while (cursor.moveToNext()) {
final String cardId = cursor.getString(cursor.getColumnIndexOrThrow("cardId"));
final String groupId = cursor.getString(cursor.getColumnIndexOrThrow("groupId"));
groups.computeIfAbsent(groupId, k -> new ArrayList<>()).add(cardId);
}
assertEquals("expected groups with cards", expectedGroups, groups);
}
}
private Uri getUri(final String endpoint) {
return Uri.parse(String.format(Locale.ROOT, "content://%s/%s", CardsContentProvider.AUTHORITY, endpoint));
}
}

74
docs/CONTENT_PROVIDER.md Normal file
View File

@@ -0,0 +1,74 @@
# Content Provider
Catima implements a [content provider](https://developer.android.com/guide/topics/providers/content-providers) to allow for external apps to interact with the cards database. Right now, this only provides read-only functionality.
Since runtime permissions are only available since Android 6.0 (API level 23), the content provider is disabled for older android versions in order to prevent unwanted access to the data.
## Authority
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 |
## Permissions
The content provider requires the following permissions:
- `me.hackerchick.catima.READ_CARDS` - in order to access any of the URIs.
## URIs
### /version
Returns a single record with the current API version for the content provider.
A major version change implies breaking changes (eg. columns being renamed or removed).
| Column | Type | Description | Value |
|---------|-------|-------------------|-------|
| `major` | `int` | The major version | `1` |
| `minor` | `int` | The minor version | `0` |
### /cards
| Column | Type | Description |
|---------------|----------|----------------------------|
| `_id` | `int` | Unique card ID |
| `store` | `String` | Card name |
| `validfrom` | `long` | Timestamp from which the card is valid (unix epoch millis). |
| `expiry` | `long` | Expiration timestamp (unix epoch millis). |
| `balance` | `String` | Current balance, as a string-formatted big decimal. |
| `balancetype` | `String` | Balance currency code, ISO 4217. |
| `note` | `String` | A note. |
| `headercolor` | `int` | Header color, in RGBA. |
| `cardid` | `String` | Card ID. |
| `barcodeid` | `String` | Barcode value. If empty, it's the same as the card ID. |
| `barcodetype` | `String` | The barcode type name, matching [com.google.zxing.BarcodeFormat](https://zxing.github.io/zxing/apidocs/com/google/zxing/BarcodeFormat.html). |
| `starstatus` | `int` | 1 if starred, 0 if not |
| `lastused` | `long` | Timestamp of last card usage (unix epoch millis). |
| `archive` | `int` | 1 if archived, 0 if not |
### /groups
| Column | Type | Description |
|-----------|----------|----------------------------|
| `_id` | `String` | Group name (unique) |
| `orderId` | `int` | Group order, in the UI |
### /card_groups
Returns the mapping between cards and groups, by ID.
- A card can be in 0 or more groups.
- A group can contain 0 or more cards.
| Column | Type | Description |
|-----------|----------|--------------|
| `cardId` | `String` | Card ID |
| `groupId` | `String` | Group ID |