mirror of
https://github.com/CatimaLoyalty/Android.git
synced 2025-12-23 23:28:14 -05:00
Introduce read-only ContentProvider for cards (#1121)
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
74
docs/CONTENT_PROVIDER.md
Normal 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 |
|
||||
Reference in New Issue
Block a user