Add Fidme import support

This commit is contained in:
Sylvia van Os
2021-03-24 20:35:58 +01:00
parent bd1d33867d
commit b9e152e3c4
13 changed files with 335 additions and 118 deletions

View File

@@ -4,6 +4,7 @@
Changes:
- Support importing [Fidme](https://play.google.com/store/apps/details?id=fr.snapp.fidme) exports
- Change "Thank you" text on privacy dialog to "Accept" because Huawei is overly pedantic
## v1.11 (2021-03-21)

View File

@@ -166,6 +166,27 @@ public class DBHelper extends SQLiteOpenHelper
return newId;
}
public boolean insertLoyaltyCard(final SQLiteDatabase db, final String store,
final String note, final Date expiry, final BigDecimal balance,
final Currency balanceType, final String cardId,
final String barcodeType, final Integer headerColor,
final int starStatus)
{
ContentValues contentValues = new ContentValues();
contentValues.put(LoyaltyCardDbIds.STORE, store);
contentValues.put(LoyaltyCardDbIds.NOTE, note);
contentValues.put(LoyaltyCardDbIds.EXPIRY, expiry != null ? expiry.getTime() : null);
contentValues.put(LoyaltyCardDbIds.BALANCE, balance.toString());
contentValues.put(LoyaltyCardDbIds.BALANCE_TYPE, balanceType != null ? balanceType.getCurrencyCode() : null);
contentValues.put(LoyaltyCardDbIds.CARD_ID, cardId);
contentValues.put(LoyaltyCardDbIds.BARCODE_TYPE, barcodeType);
contentValues.put(LoyaltyCardDbIds.HEADER_COLOR, headerColor);
contentValues.put(LoyaltyCardDbIds.HEADER_TEXT_COLOR, Color.WHITE);
contentValues.put(LoyaltyCardDbIds.STAR_STATUS,starStatus);
final long newId = db.insert(LoyaltyCardDbIds.TABLE, null, contentValues);
return (newId != -1);
}
public boolean insertLoyaltyCard(final SQLiteDatabase db, final int id, final String store,
final String note, final Date expiry, final BigDecimal balance,
final Currency balanceType, final String cardId,

View File

@@ -3,6 +3,7 @@ package protect.card_locker;
public enum DataFormat
{
Catima,
Fidme,
VoucherVault
;
}

View File

@@ -16,6 +16,7 @@ import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import android.provider.ContactsContract;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
@@ -28,6 +29,7 @@ import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.DateFormat;
public class ImportExportActivity extends AppCompatActivity
{
@@ -39,6 +41,8 @@ public class ImportExportActivity extends AppCompatActivity
private ImportExportTask importExporter;
private String importAlertTitle;
private String importAlertMessage;
private DataFormat importDataFormat;
@Override
@@ -120,18 +124,43 @@ public class ImportExportActivity extends AppCompatActivity
switch (which) {
// Catima
case 0:
// Loyalty Card Keychain
importAlertTitle = getString(R.string.importCatima);
importAlertMessage = getString(R.string.importCatimaMessage);
importDataFormat = DataFormat.Catima;
break;
// Fidme
case 1:
importAlertTitle = getString(R.string.importFidme);
importAlertMessage = getString(R.string.importFidmeMessage);
importDataFormat = DataFormat.Fidme;
break;
// Loyalty Card Keychain
case 2:
importAlertTitle = getString(R.string.importLoyaltyCardKeychain);
importAlertMessage = getString(R.string.importLoyaltyCardKeychainMessage);
importDataFormat = DataFormat.Catima;
break;
// Voucher Vault
case 2:
case 3:
importAlertTitle = getString(R.string.importVoucherVault);
importAlertMessage = getString(R.string.importVoucherVaultMessage);
importDataFormat = DataFormat.VoucherVault;
break;
default:
throw new IllegalArgumentException("Unknown DataFormat");
}
chooseFileWithIntent(baseIntent, IMPORT);
new AlertDialog.Builder(this)
.setTitle(importAlertTitle)
.setMessage(importAlertMessage)
.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
chooseFileWithIntent(baseIntent, IMPORT);
}
})
.setNegativeButton(R.string.cancel, null)
.show();
});
builder.show();
}

View File

@@ -61,16 +61,8 @@ class ImportExportTask extends AsyncTask<Void, Void, Boolean>
{
boolean result = false;
try
{
InputStreamReader reader = new InputStreamReader(stream, Charset.forName("UTF-8"));
result = MultiFormatImporter.importData(db, reader, format);
reader.close();
}
catch(IOException e)
{
Log.e(TAG, "Unable to import file", e);
}
result = MultiFormatImporter.importData(db, stream, format);
Log.i(TAG, "Import result: " + result);

View File

@@ -0,0 +1,93 @@
package protect.card_locker.importexport;
import org.apache.commons.csv.CSVRecord;
import protect.card_locker.FormatException;
public class CSVHelpers {
/**
* Extract a string from the items array. The index into the array
* is determined by looking up the index in the fields map using the
* "key" as the key. If no such key exists, defaultValue is returned
* if it is not null. Otherwise, a FormatException is thrown.
*/
static String extractString(String key, CSVRecord record, String defaultValue)
throws FormatException
{
String toReturn = defaultValue;
if(record.isMapped(key))
{
toReturn = record.get(key);
}
else
{
if(defaultValue == null)
{
throw new FormatException("Field not used but expected: " + key);
}
}
return toReturn;
}
/**
* Extract an integer from the items array. The index into the array
* is determined by looking up the index in the fields map using the
* "key" as the key. If no such key exists, or the data is not a valid
* int, a FormatException is thrown.
*/
static Integer extractInt(String key, CSVRecord record, boolean nullIsOk)
throws FormatException
{
if(record.isMapped(key) == false)
{
throw new FormatException("Field not used but expected: " + key);
}
String value = record.get(key);
if(value.isEmpty() && nullIsOk)
{
return null;
}
try
{
return Integer.parseInt(record.get(key));
}
catch(NumberFormatException e)
{
throw new FormatException("Failed to parse field: " + key, e);
}
}
/**
* Extract a long from the items array. The index into the array
* is determined by looking up the index in the fields map using the
* "key" as the key. If no such key exists, or the data is not a valid
* int, a FormatException is thrown.
*/
static Long extractLong(String key, CSVRecord record, boolean nullIsOk)
throws FormatException
{
if(record.isMapped(key) == false)
{
throw new FormatException("Field not used but expected: " + key);
}
String value = record.get(key);
if(value.isEmpty() && nullIsOk)
{
return null;
}
try
{
return Long.parseLong(record.get(key));
}
catch(NumberFormatException e)
{
throw new FormatException("Failed to parse field: " + key, e);
}
}
}

View File

@@ -5,15 +5,21 @@ import android.database.sqlite.SQLiteDatabase;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.json.JSONException;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.math.BigDecimal;
import java.text.ParseException;
import java.util.Currency;
import java.util.Date;
import java.util.List;
import java.util.zip.ZipFile;
import protect.card_locker.DBHelper;
import protect.card_locker.FormatException;
@@ -28,9 +34,9 @@ import protect.card_locker.Group;
*/
public class CsvDatabaseImporter implements DatabaseImporter
{
public void importData(DBHelper db, InputStreamReader input) throws IOException, FormatException, InterruptedException
public void importData(DBHelper db, InputStream input) throws IOException, FormatException, InterruptedException
{
BufferedReader bufferedReader = new BufferedReader(input);
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(input));
bufferedReader.mark(100);
@@ -200,92 +206,6 @@ public class CsvDatabaseImporter implements DatabaseImporter
}
}
/**
* Extract a string from the items array. The index into the array
* is determined by looking up the index in the fields map using the
* "key" as the key. If no such key exists, defaultValue is returned
* if it is not null. Otherwise, a FormatException is thrown.
*/
private String extractString(String key, CSVRecord record, String defaultValue)
throws FormatException
{
String toReturn = defaultValue;
if(record.isMapped(key))
{
toReturn = record.get(key);
}
else
{
if(defaultValue == null)
{
throw new FormatException("Field not used but expected: " + key);
}
}
return toReturn;
}
/**
* Extract an integer from the items array. The index into the array
* is determined by looking up the index in the fields map using the
* "key" as the key. If no such key exists, or the data is not a valid
* int, a FormatException is thrown.
*/
private Integer extractInt(String key, CSVRecord record, boolean nullIsOk)
throws FormatException
{
if(record.isMapped(key) == false)
{
throw new FormatException("Field not used but expected: " + key);
}
String value = record.get(key);
if(value.isEmpty() && nullIsOk)
{
return null;
}
try
{
return Integer.parseInt(record.get(key));
}
catch(NumberFormatException e)
{
throw new FormatException("Failed to parse field: " + key, e);
}
}
/**
* Extract a long from the items array. The index into the array
* is determined by looking up the index in the fields map using the
* "key" as the key. If no such key exists, or the data is not a valid
* int, a FormatException is thrown.
*/
private Long extractLong(String key, CSVRecord record, boolean nullIsOk)
throws FormatException
{
if(record.isMapped(key) == false)
{
throw new FormatException("Field not used but expected: " + key);
}
String value = record.get(key);
if(value.isEmpty() && nullIsOk)
{
return null;
}
try
{
return Long.parseLong(record.get(key));
}
catch(NumberFormatException e)
{
throw new FormatException("Failed to parse field: " + key, e);
}
}
/**
* Import a single loyalty card into the database using the given
* session.
@@ -293,23 +213,23 @@ public class CsvDatabaseImporter implements DatabaseImporter
private void importLoyaltyCard(SQLiteDatabase database, DBHelper helper, CSVRecord record)
throws IOException, FormatException
{
int id = extractInt(DBHelper.LoyaltyCardDbIds.ID, record, false);
int id = CSVHelpers.extractInt(DBHelper.LoyaltyCardDbIds.ID, record, false);
String store = extractString(DBHelper.LoyaltyCardDbIds.STORE, record, "");
String store = CSVHelpers.extractString(DBHelper.LoyaltyCardDbIds.STORE, record, "");
if(store.isEmpty())
{
throw new FormatException("No store listed, but is required");
}
String note = extractString(DBHelper.LoyaltyCardDbIds.NOTE, record, "");
String note = CSVHelpers.extractString(DBHelper.LoyaltyCardDbIds.NOTE, record, "");
Date expiry = null;
try {
expiry = new Date(extractLong(DBHelper.LoyaltyCardDbIds.EXPIRY, record, true));
expiry = new Date(CSVHelpers.extractLong(DBHelper.LoyaltyCardDbIds.EXPIRY, record, true));
} catch (NullPointerException | FormatException e) { }
BigDecimal balance;
try {
balance = new BigDecimal(extractString(DBHelper.LoyaltyCardDbIds.BALANCE, record, null));
balance = new BigDecimal(CSVHelpers.extractString(DBHelper.LoyaltyCardDbIds.BALANCE, record, null));
} catch (FormatException _e ) {
// These fields did not exist in versions 1.8.1 and before
// We catch this exception so we can still import old backups
@@ -317,29 +237,29 @@ public class CsvDatabaseImporter implements DatabaseImporter
}
Currency balanceType = null;
String unparsedBalanceType = extractString(DBHelper.LoyaltyCardDbIds.BALANCE_TYPE, record, "");
String unparsedBalanceType = CSVHelpers.extractString(DBHelper.LoyaltyCardDbIds.BALANCE_TYPE, record, "");
if(!unparsedBalanceType.isEmpty()) {
balanceType = Currency.getInstance(unparsedBalanceType);
}
String cardId = extractString(DBHelper.LoyaltyCardDbIds.CARD_ID, record, "");
String cardId = CSVHelpers.extractString(DBHelper.LoyaltyCardDbIds.CARD_ID, record, "");
if(cardId.isEmpty())
{
throw new FormatException("No card ID listed, but is required");
}
String barcodeType = extractString(DBHelper.LoyaltyCardDbIds.BARCODE_TYPE, record, "");
String barcodeType = CSVHelpers.extractString(DBHelper.LoyaltyCardDbIds.BARCODE_TYPE, record, "");
Integer headerColor = null;
if(record.isMapped(DBHelper.LoyaltyCardDbIds.HEADER_COLOR))
{
headerColor = extractInt(DBHelper.LoyaltyCardDbIds.HEADER_COLOR, record, true);
headerColor = CSVHelpers.extractInt(DBHelper.LoyaltyCardDbIds.HEADER_COLOR, record, true);
}
int starStatus = 0;
try {
starStatus = extractInt(DBHelper.LoyaltyCardDbIds.STAR_STATUS, record, false);
starStatus = CSVHelpers.extractInt(DBHelper.LoyaltyCardDbIds.STAR_STATUS, record, false);
} catch (FormatException _e ) {
// This field did not exist in versions 0.28 and before
// We catch this exception so we can still import old backups
@@ -355,7 +275,7 @@ public class CsvDatabaseImporter implements DatabaseImporter
private void importGroup(SQLiteDatabase database, DBHelper helper, CSVRecord record)
throws IOException, FormatException
{
String id = extractString(DBHelper.LoyaltyCardDbGroups.ID, record, null);
String id = CSVHelpers.extractString(DBHelper.LoyaltyCardDbGroups.ID, record, null);
helper.insertGroup(database, id);
}
@@ -367,8 +287,8 @@ public class CsvDatabaseImporter implements DatabaseImporter
private void importCardGroupMapping(SQLiteDatabase database, DBHelper helper, CSVRecord record)
throws IOException, FormatException
{
Integer cardId = extractInt(DBHelper.LoyaltyCardDbIdsGroups.cardID, record, false);
String groupId = extractString(DBHelper.LoyaltyCardDbIdsGroups.groupID, record, null);
Integer cardId = CSVHelpers.extractInt(DBHelper.LoyaltyCardDbIdsGroups.cardID, record, false);
String groupId = CSVHelpers.extractString(DBHelper.LoyaltyCardDbIdsGroups.groupID, record, null);
List<Group> cardGroups = helper.getLoyaltyCardGroups(cardId);
cardGroups.add(helper.getGroup(groupId));

View File

@@ -2,7 +2,9 @@ package protect.card_locker.importexport;
import org.json.JSONException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.text.ParseException;
@@ -21,5 +23,5 @@ public interface DatabaseImporter
* @throws IOException
* @throws FormatException
*/
void importData(DBHelper db, InputStreamReader input) throws IOException, FormatException, InterruptedException, JSONException, ParseException;
void importData(DBHelper db, InputStream input) throws IOException, FormatException, InterruptedException, JSONException, ParseException;
}

View File

@@ -0,0 +1,144 @@
package protect.card_locker.importexport;
import android.annotation.SuppressLint;
import android.database.sqlite.SQLiteDatabase;
import android.graphics.Color;
import com.google.zxing.BarcodeFormat;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.math.BigDecimal;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Currency;
import java.util.Date;
import java.util.TimeZone;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import protect.card_locker.DBHelper;
import protect.card_locker.FormatException;
/**
* Class for importing a database from CSV (Comma Separate Values)
* formatted data.
*
* The database's loyalty cards are expected to appear in the CSV data.
* A header is expected for the each table showing the names of the columns.
*/
public class FidmeImporter implements DatabaseImporter
{
public void importData(DBHelper db, InputStream input) throws IOException, FormatException, JSONException, ParseException {
// We actually retrieve a .zip file
ZipInputStream zipInputStream = new ZipInputStream(input);
StringBuilder loyaltyCards = new StringBuilder();
byte[] buffer = new byte[1024];
int read = 0;
ZipEntry zipEntry;
while ((zipEntry = zipInputStream.getNextEntry()) != null) {
if (zipEntry.getName().equals("loyalty_programs.csv")) {
while ((read = zipInputStream.read(buffer, 0, 1024)) >= 0) {
loyaltyCards.append(new String(buffer, 0, read));
}
}
}
if (loyaltyCards.length() == 0) {
throw new FormatException("Couldn't find loyalty_programs.csv in zip file or it is empty");
}
SQLiteDatabase database = db.getWritableDatabase();
database.beginTransaction();
final CSVParser fidmeParser = new CSVParser(new StringReader(loyaltyCards.toString()), CSVFormat.RFC4180.withDelimiter(';').withHeader());
try {
for (CSVRecord record : fidmeParser) {
importLoyaltyCard(database, db, record);
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException();
}
}
} catch (IllegalArgumentException | IllegalStateException | InterruptedException e) {
throw new FormatException("Issue parsing CSV data", e);
} finally {
fidmeParser.close();
}
database.setTransactionSuccessful();
database.endTransaction();
database.close();
}
/**
* Import a single loyalty card into the database using the given
* session.
*/
private void importLoyaltyCard(SQLiteDatabase database, DBHelper helper, CSVRecord record)
throws IOException, FormatException
{
// A loyalty card export from Fidme contains the following fields:
// Retailer (store name)
// Program (program name)
// Added at (YYYY-MM-DD HH:MM:SS UTC)
// Reference (card ID)
// Firstname (card holder first name)
// Lastname (card holder last name)
// The store is called Retailer
String store = CSVHelpers.extractString("Retailer", record, "");
if (store.isEmpty())
{
throw new FormatException("No store listed, but is required");
}
// There seems to be no note field in the CSV? So let's combine other fields instead...
String program = CSVHelpers.extractString("Program", record, "");
String addedAt = CSVHelpers.extractString("Added At", record, "");
String firstName = CSVHelpers.extractString("Firstname", record, "");
String lastName = CSVHelpers.extractString("Lastname", record, "");
String combinedName = String.format("%s %s", firstName, lastName);
StringBuilder noteBuilder = new StringBuilder();
if (!program.isEmpty()) noteBuilder.append(program).append('\n');
if (!addedAt.isEmpty()) noteBuilder.append(addedAt).append('\n');
if (!combinedName.isEmpty()) noteBuilder.append(combinedName).append('\n');
String note = noteBuilder.toString();
// The ID is called reference
String cardId = CSVHelpers.extractString("Reference", record, "");
if(cardId.isEmpty())
{
throw new FormatException("No card ID listed, but is required");
}
// Sadly, Fidme exports don't contain the card type
// I guess they have an online DB of all the different companies and what type they use
// TODO: Hook this into our own loyalty card DB if we ever get one
String barcodeType = "";
// No favourite data in the export either
int starStatus = 0;
helper.insertLoyaltyCard(database, store, note, null, BigDecimal.valueOf(0), null, cardId, barcodeType, null, starStatus);
}
}

View File

@@ -5,6 +5,7 @@ import android.util.Log;
import org.json.JSONException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.text.ParseException;
@@ -29,7 +30,7 @@ public class MultiFormatImporter
* false otherwise. If false, no data was written to
* the database.
*/
public static boolean importData(DBHelper db, InputStreamReader input, DataFormat format)
public static boolean importData(DBHelper db, InputStream input, DataFormat format)
{
DatabaseImporter importer = null;
@@ -38,6 +39,9 @@ public class MultiFormatImporter
case Catima:
importer = new CsvDatabaseImporter();
break;
case Fidme:
importer = new FidmeImporter();
break;
case VoucherVault:
importer = new VoucherVaultImporter();
break;

View File

@@ -12,6 +12,7 @@ import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.text.ParseException;
@@ -32,8 +33,8 @@ import protect.card_locker.FormatException;
*/
public class VoucherVaultImporter implements DatabaseImporter
{
public void importData(DBHelper db, InputStreamReader input) throws IOException, FormatException, JSONException, ParseException {
BufferedReader bufferedReader = new BufferedReader(input);
public void importData(DBHelper db, InputStream input) throws IOException, FormatException, JSONException, ParseException {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(input));
StringBuilder sb = new StringBuilder();
String line;

View File

@@ -2,6 +2,7 @@
<resources>
<string-array name="import_types_array">
<item>Catima</item>
<item>Fidme</item>
<item>@string/app_loyalty_card_keychain</item>
<item>Voucher Vault</item>
</string-array>

View File

@@ -161,4 +161,12 @@
<string name="privacy_policy">Privacy Policy</string>
<string name="privacy_policy_popup_text">Many app stores require apps to show their privacy policy on first start. Here is ours:\n\nWe collect NO DATA AT ALL and our app is Open Source so anyone can confirm this to be true.</string>
<string name="accept">Accept</string>
<string name="importCatima">Import from Catima</string>
<string name="importCatimaMessage">Please select your Catima export file. It is most likely named Catima.csv.\n\nA Catima export file can be created by going to the Import/Export menu and pressing "Export".</string>
<string name="importFidme">Import from Fidme</string>
<string name="importFidmeMessage">Please select your Fidme export file. It is most likely named something like fidme-export-request-xxxxxx.zip.\n\nA Fidme export file can be created in the Fidme app by going to your profile, choosing "Data Protection" and then pressing "Extract my data".\n\nPlease note that Fidme does not store the barcode type in the export data so you will have to edit the imported cards manually.</string>
<string name="importLoyaltyCardKeychain">Import from Loyalty Card Keychain</string>
<string name="importLoyaltyCardKeychainMessage">Please select your Loyalty Card Keychain export file. It is most likely named LoyaltyCardKeychain.csv.\n\nA Loyalty Card Keychain export file can be created by going to the Import/Export menu and pressing "Export".</string>
<string name="importVoucherVault">Import from Voucher Vault</string>
<string name="importVoucherVaultMessage">Please select your Voucher Vault export file. It is most likely named vouchervault.json.\n\nA Voucher Vault export file can be created by pressing "Export".</string>
</resources>