diff --git a/app/src/main/java/protect/card_locker/DBHelper.java b/app/src/main/java/protect/card_locker/DBHelper.java index 88ad9167bb..87e4a2ed03 100644 --- a/app/src/main/java/protect/card_locker/DBHelper.java +++ b/app/src/main/java/protect/card_locker/DBHelper.java @@ -12,10 +12,10 @@ import java.io.FileNotFoundException; import java.math.BigDecimal; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Currency; -import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -23,7 +23,7 @@ public class DBHelper extends SQLiteOpenHelper { public static final String DATABASE_NAME = "Catima.db"; public static final int ORIGINAL_DATABASE_VERSION = 1; - public static final int DATABASE_VERSION = 17; + public static final int DATABASE_VERSION = 18; // NB: changing these values requires a migration public static final int DEFAULT_ZOOM_LEVEL = 100; @@ -335,17 +335,70 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("ALTER TABLE " + LoyaltyCardDbIds.TABLE + " ADD COLUMN " + LoyaltyCardDbIds.ZOOM_LEVEL_WIDTH + " INTEGER DEFAULT '100' "); } + + if (oldVersion < 18 && newVersion >= 18) { + db.beginTransaction(); + + try { + // Add new temporary columns + db.execSQL("ALTER TABLE " + LoyaltyCardDbIds.TABLE + " ADD COLUMN " + LoyaltyCardDbIds.VALID_FROM + "_new TEXT"); + db.execSQL("ALTER TABLE " + LoyaltyCardDbIds.TABLE + " ADD COLUMN " + LoyaltyCardDbIds.EXPIRY + "_new TEXT"); + + // 2. Read old values, convert, and update new columns + String[] columnsToRead = {LoyaltyCardDbIds.ID, LoyaltyCardDbIds.VALID_FROM, LoyaltyCardDbIds.EXPIRY, LoyaltyCardDbIds.LAST_USED}; + Cursor cursor = db.query(LoyaltyCardDbIds.TABLE, columnsToRead, null, null, null, null, null); + + if (cursor.moveToFirst()) { + do { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.ID)); + long validFromLong = cursor.getLong(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.VALID_FROM)); + long expiryLong = cursor.getLong(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.EXPIRY)); + + ContentValues values = new ContentValues(); + + // Convert epoch milliseconds to ISO 8601 string (e.g., "2025-09-28T16:30:30Z") + if (validFromLong > 0) { + values.put(LoyaltyCardDbIds.VALID_FROM + "_new", Instant.ofEpochMilli(validFromLong).toString()); + } + if (expiryLong > 0) { + values.put(LoyaltyCardDbIds.EXPIRY + "_new", Instant.ofEpochMilli(expiryLong).toString()); + } + + if (values.size() > 0) { + db.update(LoyaltyCardDbIds.TABLE, values, LoyaltyCardDbIds.ID + " = ?", new String[]{String.valueOf(id)}); + } + } while (cursor.moveToNext()); + } + cursor.close(); + + // Drop the old integer columns + // Note: This requires a newer version of SQLite. For maximum compatibility, + // the old "create new table -> copy data -> drop old -> rename" method is safer. + // However, DROP COLUMN is supported on most modern Android devices. + db.execSQL("ALTER TABLE " + LoyaltyCardDbIds.TABLE + " DROP COLUMN " + LoyaltyCardDbIds.VALID_FROM); + db.execSQL("ALTER TABLE " + LoyaltyCardDbIds.TABLE + " DROP COLUMN " + LoyaltyCardDbIds.EXPIRY); + + // Rename the new columns to their final names + db.execSQL("ALTER TABLE " + LoyaltyCardDbIds.TABLE + " RENAME COLUMN " + LoyaltyCardDbIds.VALID_FROM + "_new TO " + LoyaltyCardDbIds.VALID_FROM); + db.execSQL("ALTER TABLE " + LoyaltyCardDbIds.TABLE + " RENAME COLUMN " + LoyaltyCardDbIds.EXPIRY + "_new TO " + LoyaltyCardDbIds.EXPIRY); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } } public static Set imageFiles(Context context, final SQLiteDatabase database) { Set files = new HashSet<>(); - Cursor cardCursor = getLoyaltyCardCursor(database); - while (cardCursor.moveToNext()) { - LoyaltyCard card = LoyaltyCard.fromCursor(context, cardCursor); - for (ImageLocationType imageLocationType : ImageLocationType.values()) { - String name = Utils.getCardImageFileName(card.id, imageLocationType); - if (card.getImageForImageLocationType(context, imageLocationType) != null) { - files.add(name); + try(Cursor cardCursor = getLoyaltyCardCursor(database)){ + while (cardCursor.moveToNext()) { + LoyaltyCard card = LoyaltyCard.fromCursor(context, cardCursor); + for (ImageLocationType imageLocationType : ImageLocationType.values()) { + String name = Utils.getCardImageFileName(card.id, imageLocationType); + if (card.getImageForImageLocationType(context, imageLocationType) != null) { + files.add(name); + } } } } @@ -385,6 +438,7 @@ private static ContentValues generateFTSContentValues(final int id, final String } private static void insertFTS(final SQLiteDatabase db, final int id, final String store, final String note) { + Log.d("DB_DEBUG", "------ insertFTS called FOR id: " + id); db.insert(LoyaltyCardDbFTS.TABLE, null, generateFTSContentValues(id, store, note)); } @@ -394,18 +448,19 @@ private static void updateFTS(final SQLiteDatabase db, final int id, final Strin } public static long insertLoyaltyCard( - final SQLiteDatabase database, final String store, final String note, final Date validFrom, - final Date expiry, final BigDecimal balance, final Currency balanceType, final String cardId, + final SQLiteDatabase database, final String store, final String note, final Instant validFrom, + final Instant expiry, final BigDecimal balance, final Currency balanceType, final String cardId, final String barcodeId, final CatimaBarcode barcodeType, final Integer headerColor, final int starStatus, final Long lastUsed, final int archiveStatus) { + Log.d("DB_DEBUG", "--- insertLoyaltyCard called to GENERATE new id."); database.beginTransaction(); // Card ContentValues contentValues = new ContentValues(); contentValues.put(LoyaltyCardDbIds.STORE, store); contentValues.put(LoyaltyCardDbIds.NOTE, note); - contentValues.put(LoyaltyCardDbIds.VALID_FROM, validFrom != null ? validFrom.getTime() : null); - contentValues.put(LoyaltyCardDbIds.EXPIRY, expiry != null ? expiry.getTime() : null); + contentValues.put(LoyaltyCardDbIds.VALID_FROM, validFrom != null ? validFrom.toString() : null); + contentValues.put(LoyaltyCardDbIds.EXPIRY, expiry != null ? expiry.toString() : null); contentValues.put(LoyaltyCardDbIds.BALANCE, balance.toString()); contentValues.put(LoyaltyCardDbIds.BALANCE_TYPE, balanceType != null ? balanceType.getCurrencyCode() : null); contentValues.put(LoyaltyCardDbIds.CARD_ID, cardId); @@ -428,10 +483,11 @@ public static long insertLoyaltyCard( public static long insertLoyaltyCard( final SQLiteDatabase database, final int id, final String store, final String note, - final Date validFrom, final Date expiry, final BigDecimal balance, + final Instant validFrom, final Instant expiry, final BigDecimal balance, final Currency balanceType, final String cardId, final String barcodeId, final CatimaBarcode barcodeType, final Integer headerColor, final int starStatus, final Long lastUsed, final int archiveStatus) { + Log.d("DB_DEBUG", "--- insertLoyaltyCard called with PRESET id: " + id); database.beginTransaction(); // Card @@ -439,8 +495,8 @@ public static long insertLoyaltyCard( contentValues.put(LoyaltyCardDbIds.ID, id); contentValues.put(LoyaltyCardDbIds.STORE, store); contentValues.put(LoyaltyCardDbIds.NOTE, note); - contentValues.put(LoyaltyCardDbIds.VALID_FROM, validFrom != null ? validFrom.getTime() : null); - contentValues.put(LoyaltyCardDbIds.EXPIRY, expiry != null ? expiry.getTime() : null); + contentValues.put(LoyaltyCardDbIds.VALID_FROM, validFrom != null ? validFrom.toString() : null); + contentValues.put(LoyaltyCardDbIds.EXPIRY, expiry != null ? expiry.toString() : null); contentValues.put(LoyaltyCardDbIds.BALANCE, balance.toString()); contentValues.put(LoyaltyCardDbIds.BALANCE_TYPE, balanceType != null ? balanceType.getCurrencyCode() : null); contentValues.put(LoyaltyCardDbIds.CARD_ID, cardId); @@ -453,6 +509,7 @@ public static long insertLoyaltyCard( database.insert(LoyaltyCardDbIds.TABLE, null, contentValues); // FTS + Log.d("DBHelper", "insertFTS :: note => " + note + " Store: => "+ store); insertFTS(database, id, store, note); database.setTransactionSuccessful(); @@ -463,7 +520,7 @@ public static long insertLoyaltyCard( public static boolean updateLoyaltyCard( SQLiteDatabase database, final int id, final String store, final String note, - final Date validFrom, final Date expiry, final BigDecimal balance, + final Instant validFrom, final Instant expiry, final BigDecimal balance, final Currency balanceType, final String cardId, final String barcodeId, final CatimaBarcode barcodeType, final Integer headerColor, final int starStatus, final Long lastUsed, final int archiveStatus) { @@ -473,8 +530,8 @@ public static boolean updateLoyaltyCard( ContentValues contentValues = new ContentValues(); contentValues.put(LoyaltyCardDbIds.STORE, store); contentValues.put(LoyaltyCardDbIds.NOTE, note); - contentValues.put(LoyaltyCardDbIds.VALID_FROM, validFrom != null ? validFrom.getTime() : null); - contentValues.put(LoyaltyCardDbIds.EXPIRY, expiry != null ? expiry.getTime() : null); + contentValues.put(LoyaltyCardDbIds.VALID_FROM, validFrom != null ? validFrom.toString() : null); + contentValues.put(LoyaltyCardDbIds.EXPIRY, expiry != null ? expiry.toString() : null); contentValues.put(LoyaltyCardDbIds.BALANCE, balance.toString()); contentValues.put(LoyaltyCardDbIds.BALANCE_TYPE, balanceType != null ? balanceType.getCurrencyCode() : null); contentValues.put(LoyaltyCardDbIds.CARD_ID, cardId); diff --git a/app/src/main/java/protect/card_locker/DateTimeUtils.java b/app/src/main/java/protect/card_locker/DateTimeUtils.java new file mode 100644 index 0000000000..36956a3af4 --- /dev/null +++ b/app/src/main/java/protect/card_locker/DateTimeUtils.java @@ -0,0 +1,84 @@ +package protect.card_locker; + +import androidx.annotation.Nullable; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; + +public class DateTimeUtils { + static public Instant longToInstant(Long value) { + if(value == null) return null; + return Instant.ofEpochMilli(value); + } + + static public @Nullable ZonedDateTime instantToZonedDateTime(@Nullable Instant value) { + if(value == null) return null; + ZoneId systemZone = ZoneId.systemDefault(); + return value.atZone(systemZone); + } + + /** + * Returns an Instant representing the start of the current day (00:00:00) + * in the system's default timezone. + */ + private static Instant getStartOfTodayAsInstant() { + // Get the current date in the device's timezone (e.g., "2025-09-28") + LocalDate today = LocalDate.now(ZoneId.systemDefault()); + + // Get the start of that day (midnight) in the same timezone + ZonedDateTime startOfToday = today.atStartOfDay(ZoneId.systemDefault()); + + // Convert to an Instant for universal comparison + return startOfToday.toInstant(); + } + + /** + * Checks if an item is not yet valid based on exact date AND time. + * Different from original behavior - now considers exact timestamps. + * + * New behavior: Item becomes valid only at the exact validFrom instant + * - If validFrom = "2024-01-15 14:30:00" and current time is "2024-01-15 14:29:59" → NOT YET VALID (returns true) + * - If validFrom = "2024-01-15 14:30:00" and current time is "2024-01-15 14:30:00" → VALID (returns false) + */ + public static boolean isNotYetValid(Instant validFrom) { + if (validFrom == null) { + return false; + } + return validFrom.isAfter(Instant.now()); + } + + /** + * Checks if an item has expired, considering exact date AND time. + * @param expiry The Instant the item expires. If null, it never expires. + * @return true if the expiry date/time is in the past. + */ + public static boolean hasExpired(Instant expiry) { + if (expiry == null) { + return false; // Never expires + } + return expiry.isBefore(Instant.now()); + } + static public DateTimeFormatter longDateShortTimeFormatter = DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); + + static public DateTimeFormatter mediumDateShortTimeFormatter = DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT); + + static public String formatMedium(Instant value) { + if(value == null) return null; + ZonedDateTime zoneDate = instantToZonedDateTime(value); + if(zoneDate == null) return null; + return zoneDate.format(mediumDateShortTimeFormatter); + } + + static public String formatLong(Instant value) { + if(value == null) return null; + ZonedDateTime zoneDate = instantToZonedDateTime(value); + if(zoneDate == null) return null; + return zoneDate.format(longDateShortTimeFormatter); + } +} \ No newline at end of file diff --git a/app/src/main/java/protect/card_locker/ImportURIHelper.java b/app/src/main/java/protect/card_locker/ImportURIHelper.java index 99c904b7e4..b7fc568595 100644 --- a/app/src/main/java/protect/card_locker/ImportURIHelper.java +++ b/app/src/main/java/protect/card_locker/ImportURIHelper.java @@ -10,8 +10,8 @@ import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.Currency; -import java.util.Date; import java.util.HashMap; import java.util.List; @@ -66,8 +66,8 @@ public LoyaltyCard parse(Uri uri) throws InvalidObjectException { try { // These values are allowed to be null CatimaBarcode barcodeType = null; - Date validFrom = null; - Date expiry = null; + Instant validFrom = null; + Instant expiry = null; BigDecimal balance = new BigDecimal("0"); Currency balanceType = null; Integer headerColor = null; @@ -113,11 +113,11 @@ public LoyaltyCard parse(Uri uri) throws InvalidObjectException { } String unparsedValidFrom = kv.get(VALID_FROM); if (unparsedValidFrom != null && !unparsedValidFrom.equals("")) { - validFrom = new Date(Long.parseLong(unparsedValidFrom)); + validFrom = Instant.parse(unparsedValidFrom); } String unparsedExpiry = kv.get(EXPIRY); - if (unparsedExpiry != null && !unparsedExpiry.equals("")) { - expiry = new Date(Long.parseLong(unparsedExpiry)); + if (unparsedExpiry != null && !unparsedExpiry.isEmpty()) { + expiry = Instant.parse(unparsedExpiry); } String unparsedHeaderColor = kv.get(HEADER_COLOR); @@ -182,10 +182,10 @@ protected Uri toUri(LoyaltyCard loyaltyCard) throws UnsupportedEncodingException fragment = appendFragment(fragment, BALANCE_TYPE, loyaltyCard.balanceType.getCurrencyCode()); } if (loyaltyCard.validFrom != null) { - fragment = appendFragment(fragment, VALID_FROM, String.valueOf(loyaltyCard.validFrom.getTime())); + fragment = appendFragment(fragment, VALID_FROM, loyaltyCard.validFrom.toString()); } if (loyaltyCard.expiry != null) { - fragment = appendFragment(fragment, EXPIRY, String.valueOf(loyaltyCard.expiry.getTime())); + fragment = appendFragment(fragment, EXPIRY, loyaltyCard.expiry.toString()); } fragment = appendFragment(fragment, CARD_ID, loyaltyCard.cardId); if (loyaltyCard.barcodeId != null) { diff --git a/app/src/main/java/protect/card_locker/LoyaltyCard.java b/app/src/main/java/protect/card_locker/LoyaltyCard.java index b4fe334983..0532ce5a2d 100644 --- a/app/src/main/java/protect/card_locker/LoyaltyCard.java +++ b/app/src/main/java/protect/card_locker/LoyaltyCard.java @@ -4,13 +4,14 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.os.Bundle; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.math.BigDecimal; +import java.time.Instant; import java.util.Currency; -import java.util.Date; import java.util.List; import java.util.Objects; @@ -19,9 +20,9 @@ public class LoyaltyCard { public String store; public String note; @Nullable - public Date validFrom; + public Instant validFrom; @Nullable - public Date expiry; + public Instant expiry; public BigDecimal balance; @Nullable public Currency balanceType; @@ -121,8 +122,8 @@ public LoyaltyCard() { * @param zoomLevelWidth * @param archiveStatus */ - public LoyaltyCard(final int id, final String store, final String note, @Nullable final Date validFrom, - @Nullable final Date expiry, final BigDecimal balance, @Nullable final Currency balanceType, + public LoyaltyCard(final int id, final String store, final String note, @Nullable final Instant validFrom, + @Nullable final Instant expiry, final BigDecimal balance, @Nullable final Currency balanceType, final String cardId, @Nullable final String barcodeId, @Nullable final CatimaBarcode barcodeType, @Nullable final Integer headerColor, final int starStatus, final long lastUsed, final int zoomLevel, final int zoomLevelWidth, final int archiveStatus, @@ -216,11 +217,11 @@ public void setNote(@NonNull String note) { this.note = note; } - public void setValidFrom(@Nullable Date validFrom) { + public void setValidFrom(@Nullable Instant validFrom) { this.validFrom = validFrom; } - public void setExpiry(@Nullable Date expiry) { + public void setExpiry(@Nullable Instant expiry) { this.expiry = expiry; } @@ -342,13 +343,13 @@ public void updateFromBundle(@NonNull Bundle bundle, boolean requireFull) { } if (bundle.containsKey(BUNDLE_LOYALTY_CARD_VALID_FROM)) { long tmpValidFrom = bundle.getLong(BUNDLE_LOYALTY_CARD_VALID_FROM); - setValidFrom(tmpValidFrom > 0 ? new Date(tmpValidFrom) : null); + setValidFrom(tmpValidFrom > 0 ? DateTimeUtils.longToInstant(tmpValidFrom) : null); } else if (requireFull) { throw new IllegalArgumentException("Missing key " + BUNDLE_LOYALTY_CARD_VALID_FROM); } if (bundle.containsKey(BUNDLE_LOYALTY_CARD_EXPIRY)) { long tmpExpiry = bundle.getLong(BUNDLE_LOYALTY_CARD_EXPIRY); - setExpiry(tmpExpiry > 0 ? new Date(tmpExpiry) : null); + setExpiry(tmpExpiry > 0 ? DateTimeUtils.longToInstant(tmpExpiry) : null); } else if (requireFull) { throw new IllegalArgumentException("Missing key " + BUNDLE_LOYALTY_CARD_EXPIRY); } @@ -442,10 +443,10 @@ public Bundle toBundle(Context context, List exportLimit) { bundle.putString(BUNDLE_LOYALTY_CARD_NOTE, note); } if (!exportIsLimited || exportLimit.contains(BUNDLE_LOYALTY_CARD_VALID_FROM)) { - bundle.putLong(BUNDLE_LOYALTY_CARD_VALID_FROM, validFrom != null ? validFrom.getTime() : -1); + bundle.putLong(BUNDLE_LOYALTY_CARD_VALID_FROM, validFrom != null ? validFrom.toEpochMilli() : -1); } if (!exportIsLimited || exportLimit.contains(BUNDLE_LOYALTY_CARD_EXPIRY)) { - bundle.putLong(BUNDLE_LOYALTY_CARD_EXPIRY, expiry != null ? expiry.getTime() : -1); + bundle.putLong(BUNDLE_LOYALTY_CARD_EXPIRY, expiry != null ? expiry.toEpochMilli() : -1); } if (!exportIsLimited || exportLimit.contains(BUNDLE_LOYALTY_CARD_BALANCE)) { bundle.putString(BUNDLE_LOYALTY_CARD_BALANCE, balance.toString()); @@ -516,16 +517,26 @@ public Bundle toBundle(Context context, List exportLimit) { public static LoyaltyCard fromCursor(Context context, Cursor cursor) { // id int id = cursor.getInt(cursor.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.ID)); + Log.d("From_Cursor", "\nfromCursor id: " + id); // store String store = cursor.getString(cursor.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.STORE)); // note String note = cursor.getString(cursor.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.NOTE)); // validFrom - long validFromLong = cursor.getLong(cursor.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.VALID_FROM)); - Date validFrom = validFromLong > 0 ? new Date(validFromLong) : null; + String validFromString = cursor.getString(cursor.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.VALID_FROM)); + Log.d("From_Cursor", "validFromString: " + validFromString); + Instant validFrom = null; + if(validFromString != null && !validFromString.isEmpty()){ + validFrom = Instant.parse(validFromString); + } + Log.d("From_Cursor", "validFrom: " + validFrom); // expiry - long expiryLong = cursor.getLong(cursor.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.EXPIRY)); - Date expiry = expiryLong > 0 ? new Date(expiryLong) : null; + String expiryString = cursor.getString(cursor.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.EXPIRY)); + Log.d("From_Cursor", "expiryString: " + expiryString + "\n"); + Instant expiry = null; + if(expiryString != null && !expiryString.isEmpty()){ + expiry = Instant.parse(expiryString); + } // balance BigDecimal balance = new BigDecimal(cursor.getString(cursor.getColumnIndexOrThrow(DBHelper.LoyaltyCardDbIds.BALANCE))); // balanceType diff --git a/app/src/main/java/protect/card_locker/LoyaltyCardCursorAdapter.java b/app/src/main/java/protect/card_locker/LoyaltyCardCursorAdapter.java index 84968d753b..ece2f996aa 100644 --- a/app/src/main/java/protect/card_locker/LoyaltyCardCursorAdapter.java +++ b/app/src/main/java/protect/card_locker/LoyaltyCardCursorAdapter.java @@ -1,13 +1,11 @@ package protect.card_locker; import android.content.Context; -import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.util.SparseBooleanArray; -import android.util.TypedValue; import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.View; @@ -26,7 +24,6 @@ import com.google.android.material.color.MaterialColors; import java.math.BigDecimal; -import java.text.DateFormat; import java.util.ArrayList; import protect.card_locker.databinding.LoyaltyCardLayoutBinding; @@ -112,13 +109,13 @@ public void onBindViewHolder(LoyaltyCardListItemViewHolder inputHolder, Cursor i } if (mLoyaltyCardListDisplayOptions.showingValidity() && loyaltyCard.validFrom != null) { - inputHolder.setExtraField(inputHolder.mValidFromField, DateFormat.getDateInstance(DateFormat.MEDIUM).format(loyaltyCard.validFrom), Utils.isNotYetValid(loyaltyCard.validFrom) ? Color.RED : null, showDivider); + inputHolder.setExtraField(inputHolder.mValidFromField, DateTimeUtils.formatMedium(loyaltyCard.validFrom), DateTimeUtils.isNotYetValid(loyaltyCard.validFrom) ? Color.RED : null, showDivider); } else { inputHolder.setExtraField(inputHolder.mValidFromField, null, null, false); } if (mLoyaltyCardListDisplayOptions.showingValidity() && loyaltyCard.expiry != null) { - inputHolder.setExtraField(inputHolder.mExpiryField, DateFormat.getDateInstance(DateFormat.MEDIUM).format(loyaltyCard.expiry), Utils.hasExpired(loyaltyCard.expiry) ? Color.RED : null, showDivider); + inputHolder.setExtraField(inputHolder.mExpiryField, DateTimeUtils.formatMedium(loyaltyCard.expiry), DateTimeUtils.hasExpired(loyaltyCard.expiry) ? Color.RED : null, showDivider); } else { inputHolder.setExtraField(inputHolder.mExpiryField, null, null, false); } diff --git a/app/src/main/java/protect/card_locker/LoyaltyCardEditActivity.java b/app/src/main/java/protect/card_locker/LoyaltyCardEditActivity.java index eb1c52dd3c..6e7815e105 100644 --- a/app/src/main/java/protect/card_locker/LoyaltyCardEditActivity.java +++ b/app/src/main/java/protect/card_locker/LoyaltyCardEditActivity.java @@ -44,9 +44,6 @@ import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; import androidx.exifinterface.media.ExifInterface; import androidx.lifecycle.ViewModelProvider; @@ -60,6 +57,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.tabs.TabLayout; +import com.google.android.material.timepicker.MaterialTimePicker; +import com.google.android.material.timepicker.TimeFormat; import com.jaredrummler.android.colorpicker.ColorPickerDialog; import com.jaredrummler.android.colorpicker.ColorPickerDialogListener; import com.yalantis.ucrop.UCrop; @@ -70,34 +69,40 @@ import java.io.IOException; import java.io.InvalidObjectException; import java.math.BigDecimal; -import java.text.DateFormat; import java.text.ParseException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Currency; -import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.NoSuchElementException; -import java.util.Objects; import java.util.concurrent.Callable; import protect.card_locker.async.TaskHandler; import protect.card_locker.databinding.LayoutChipChoiceBinding; import protect.card_locker.databinding.LoyaltyCardEditActivityBinding; +import protect.card_locker.viewmodels.LoyaltyCardDateType; import protect.card_locker.viewmodels.LoyaltyCardEditActivityViewModel; +import protect.card_locker.viewmodels.LoyaltyCardEditUiAction; public class LoyaltyCardEditActivity extends CatimaAppCompatActivity implements BarcodeImageWriterResultCallback, ColorPickerDialogListener { private static final String TAG = "Catima"; protected LoyaltyCardEditActivityViewModel viewModel; private LoyaltyCardEditActivityBinding binding; - private static final String PICK_DATE_REQUEST_KEY = "pick_date_request"; - private static final String NEWLY_PICKED_DATE_ARGUMENT_KEY = "newly_picked_date"; + public static final String PICK_DATE_TIME_REQUEST_KEY = "PICK_DATE_TIME_REQUEST"; + public static final String NEWLY_PICKED_DATE_TIME_ARGUMENT_KEY = "NEWLY_PICKED_DATE_TIME_ISO"; + public static final String NEWLY_PICKED_DATE_TIME_TYPE_KEY = "NEWLY_PICKED_DATE_TIME_TYPE_KEY"; private final String TEMP_CAMERA_IMAGE_NAME = LoyaltyCardEditActivity.class.getSimpleName() + "_camera_image.jpg"; private final String TEMP_CROP_IMAGE_NAME = LoyaltyCardEditActivity.class.getSimpleName() + "_crop_image.png"; @@ -181,13 +186,13 @@ protected void setLoyaltyCardNote(@NonNull String note) { viewModel.setHasChanged(true); } - protected void setLoyaltyCardValidFrom(@Nullable Date validFrom) { + protected void setLoyaltyCardValidFrom(@Nullable Instant validFrom) { viewModel.getLoyaltyCard().setValidFrom(validFrom); viewModel.setHasChanged(true); } - protected void setLoyaltyCardExpiry(@Nullable Date expiry) { + protected void setLoyaltyCardExpiry(@Nullable Instant expiry) { viewModel.getLoyaltyCard().setExpiry(expiry); viewModel.setHasChanged(true); @@ -367,9 +372,9 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { } }); - addDateFieldTextChangedListener(validFromField, R.string.anyDate, R.string.chooseValidFromDate, LoyaltyCardField.validFrom); + addDateFieldTextChangedListener(validFromField, R.string.anyDate, R.string.chooseValidFromDate, LoyaltyCardDateType.VALID_FROM); - addDateFieldTextChangedListener(expiryField, R.string.never, R.string.chooseExpiryDate, LoyaltyCardField.expiry); + addDateFieldTextChangedListener(expiryField, R.string.never, R.string.chooseExpiryDate, LoyaltyCardDateType.EXPIRY); setMaterialDatePickerResultListener(); @@ -767,8 +772,8 @@ protected void onResume() { storeFieldEdit.setText(viewModel.getLoyaltyCard().store); noteFieldEdit.setText(viewModel.getLoyaltyCard().note); - formatDateField(this, validFromField, viewModel.getLoyaltyCard().validFrom); - formatDateField(this, expiryField, viewModel.getLoyaltyCard().expiry); + formatDateField(this, validFromField, DateTimeUtils.instantToZonedDateTime(viewModel.getLoyaltyCard().validFrom)); + formatDateField(this, expiryField, DateTimeUtils.instantToZonedDateTime(viewModel.getLoyaltyCard().expiry)); cardIdFieldView.setText(viewModel.getLoyaltyCard().cardId); String barcodeId = viewModel.getLoyaltyCard().barcodeId; barcodeIdField.setText(barcodeId != null && !barcodeId.isEmpty() ? barcodeId : getString(R.string.sameAsCardId)); @@ -913,7 +918,12 @@ protected void setCardImage(ImageLocationType imageLocationType, ImageView image } } - protected void addDateFieldTextChangedListener(AutoCompleteTextView dateField, @StringRes int defaultOptionStringId, @StringRes int chooseDateOptionStringId, LoyaltyCardField loyaltyCardField) { + protected void addDateFieldTextChangedListener( + AutoCompleteTextView dateField, + @StringRes int defaultOptionStringId, + @StringRes int chooseDateOptionStringId, + LoyaltyCardDateType loyaltyCardField + ) { dateField.addTextChangedListener(new SimpleTextWatcher() { CharSequence lastValue; @@ -927,10 +937,10 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { if (s.toString().equals(getString(defaultOptionStringId))) { dateField.setTag(null); switch (loyaltyCardField) { - case validFrom: + case LoyaltyCardDateType.VALID_FROM: setLoyaltyCardValidFrom(null); break; - case expiry: + case LoyaltyCardDateType.EXPIRY: setLoyaltyCardExpiry(null); break; default: @@ -940,13 +950,26 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { if (!lastValue.toString().equals(getString(chooseDateOptionStringId))) { dateField.setText(lastValue); } - showDatePicker( + + ZonedDateTime selectedDateTime = (ZonedDateTime) dateField.getTag(); + ZonedDateTime validFromDateTime = (ZonedDateTime) validFromField.getTag(); + ZonedDateTime expiryDateTime = (ZonedDateTime) expiryField.getTag(); + + // Determine the min and max constraints based on the field being edited. + // This logic is identical to before, but more readable. + ZonedDateTime minDateTime = (loyaltyCardField == LoyaltyCardDateType.EXPIRY) + ? validFromDateTime + : null; + + ZonedDateTime maxDateTime = (loyaltyCardField == LoyaltyCardDateType.VALID_FROM) + ? expiryDateTime + : null; + + showDateTimePicker( loyaltyCardField, - (Date) dateField.getTag(), - // if the expiry date is being set, set date picker's minDate to the 'valid from' date - loyaltyCardField == LoyaltyCardField.expiry ? (Date) validFromField.getTag() : null, - // if the 'valid from' date is being set, set date picker's maxDate to the expiry date - loyaltyCardField == LoyaltyCardField.validFrom ? (Date) expiryField.getTag() : null + selectedDateTime, + minDateTime, + maxDateTime ); } } @@ -962,7 +985,7 @@ public void afterTextChanged(Editable s) { }); } - protected static void formatDateField(Context context, EditText textField, Date date) { + protected static void formatDateField(Context context, EditText textField, ZonedDateTime date) { textField.setTag(date); if (date == null) { @@ -976,7 +999,7 @@ protected static void formatDateField(Context context, EditText textField, Date } textField.setText(text); } else { - textField.setText(DateFormat.getDateInstance(DateFormat.LONG).format(date)); + textField.setText(date.format(DateTimeUtils.longDateShortTimeFormatter)); } } @@ -1129,7 +1152,7 @@ private void selectImageFromGallery(int type) { Intent contentIntent = new Intent(Intent.ACTION_GET_CONTENT); contentIntent.setType("image/*"); Intent chooserIntent = Intent.createChooser(photoPickerIntent, getString(R.string.addFromImage)); - chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[] { contentIntent }); + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{contentIntent}); try { mPhotoPickerLauncher.launch(chooserIntent); @@ -1312,97 +1335,150 @@ public void onDialogDismissed(int dialogId) { // Nothing to do, no change made } - private void showDatePicker( - LoyaltyCardField loyaltyCardField, - @Nullable Date selectedDate, - @Nullable Date minDate, - @Nullable Date maxDate + /** + * Shows pickers to select a date and time. + * + * @param selectedDateTime The currently selected ZonedDateTime, or null. + */ + void showDateTimePicker( + LoyaltyCardDateType loyaltyCardField, + @Nullable ZonedDateTime selectedDateTime, + @Nullable ZonedDateTime minDateTime, + @Nullable ZonedDateTime maxDateTime ) { - // Create a new instance of MaterialDatePicker and return it - long startDate = minDate != null ? minDate.getTime() : getDefaultMinDateOfDatePicker(); - long endDate = maxDate != null ? maxDate.getTime() : getDefaultMaxDateOfDatePicker(); - - CalendarConstraints.DateValidator dateValidator; - switch (loyaltyCardField) { - case validFrom: - dateValidator = DateValidatorPointBackward.before(endDate); - break; - case expiry: - dateValidator = DateValidatorPointForward.from(startDate); - break; - default: - throw new AssertionError("Unexpected field: " + loyaltyCardField); + // For MaterialDatePicker, we still need to work with milliseconds since epoch (UTC). + // We get this from an Instant. + long selectionMillis ; + if(selectedDateTime != null){ + LocalDate localDate = selectedDateTime.toLocalDate(); + ZonedDateTime startOfDay = localDate.atStartOfDay(selectedDateTime.getZone()); + selectionMillis = startOfDay.toInstant().toEpochMilli(); + } else { + selectionMillis = MaterialDatePicker.todayInUtcMilliseconds(); } + // Use min/max if provided, otherwise use a default range + long startMillis = minDateTime != null + ? minDateTime.toInstant().toEpochMilli() + : getDefaultMinDateOfDatePicker(); // Assuming you have a default + + long endMillis = maxDateTime != null + ? maxDateTime.toInstant().toEpochMilli() + : getDefaultMaxDateOfDatePicker(); // Assuming you have a default + + // The existing validation logic works perfectly, as it operates on milliseconds. + CalendarConstraints.DateValidator dateValidator = switch (loyaltyCardField) { + case LoyaltyCardDateType.VALID_FROM -> + // The 'valid from' date cannot be after the expiry date + DateValidatorPointBackward.before(endMillis); + case LoyaltyCardDateType.EXPIRY -> + // The expiry date cannot be before the 'valid from' date + DateValidatorPointForward.from(startMillis); + }; + CalendarConstraints calendarConstraints = new CalendarConstraints.Builder() .setValidator(dateValidator) - .setStart(startDate) - .setEnd(endDate) + .setStart(startMillis) + .setEnd(endMillis) .build(); - // Use the selected date as the default date in the picker - final Calendar calendar = Calendar.getInstance(); - if (selectedDate != null) { - calendar.setTime(selectedDate); - } - - MaterialDatePicker materialDatePicker = MaterialDatePicker.Builder.datePicker() - .setSelection(calendar.getTimeInMillis()) + MaterialDatePicker datePicker = MaterialDatePicker.Builder.datePicker() + .setTitleText("Select Date") + .setSelection(selectionMillis) .setCalendarConstraints(calendarConstraints) .build(); // Required to handle configuration changes // See https://github.com/material-components/material-components-android/issues/1688 - viewModel.setTempLoyaltyCardField(loyaltyCardField); - getSupportFragmentManager().addFragmentOnAttachListener((fragmentManager, fragment) -> { - if (fragment instanceof MaterialDatePicker && Objects.equals(fragment.getTag(), PICK_DATE_REQUEST_KEY)) { - ((MaterialDatePicker) fragment).addOnPositiveButtonClickListener(selection -> { - Bundle args = new Bundle(); - args.putLong(NEWLY_PICKED_DATE_ARGUMENT_KEY, selection); - getSupportFragmentManager().setFragmentResult(PICK_DATE_REQUEST_KEY, args); - }); - } + viewModel.onAction(new LoyaltyCardEditUiAction.SetLoyaltyCardDateType(loyaltyCardField)); + datePicker.addOnPositiveButtonClickListener(utcSelectionMillis -> { + // The date is selected, now show the time picker. + showTimePicker(loyaltyCardField, utcSelectionMillis, selectedDateTime); }); - materialDatePicker.show(getSupportFragmentManager(), PICK_DATE_REQUEST_KEY); + datePicker.show(getSupportFragmentManager(), "DATE_PICKER"); + } + + /** + * Shows the MaterialTimePicker after a date has been selected. + * + * @param utcDateSelectionMillis The UTC millisecond timestamp for the chosen date. + * @param initialDateTime The original ZonedDateTime to pre-fill the time. + */ + void showTimePicker(LoyaltyCardDateType dateType, long utcDateSelectionMillis, @Nullable ZonedDateTime initialDateTime) { + // Default to current time or use the time from the initial selection + LocalTime initialTime = initialDateTime != null + ? initialDateTime.toLocalTime() + : LocalTime.now(); + + MaterialTimePicker timePicker = new MaterialTimePicker.Builder() + .setTimeFormat(TimeFormat.CLOCK_12H) + .setHour(initialTime.getHour()) + .setMinute(initialTime.getMinute()) + .setTitleText("Select Time") + .build(); + + timePicker.addOnPositiveButtonClickListener(dialog -> { + // 1. Get the selected date part + // Convert the UTC millis from the date picker to a LocalDate in the device's timezone + Instant selectedInstant = Instant.ofEpochMilli(utcDateSelectionMillis); + + LocalDate selectedDate = selectedInstant.atZone(ZoneOffset.UTC).toLocalDate(); + + // 2. Get the selected time part + LocalTime selectedTime = LocalTime.of(timePicker.getHour(), timePicker.getMinute()); + + // 3. Combine them into a ZonedDateTime + ZonedDateTime finalZonedDateTime = ZonedDateTime.of(selectedDate, selectedTime, ZoneId.systemDefault()); + + // 4. Send the result back as an ISO 8601 string (the standard format) + // This is safer than sending a long, which can be misinterpreted. + Bundle args = new Bundle(); + args.putString(NEWLY_PICKED_DATE_TIME_ARGUMENT_KEY, finalZonedDateTime.toString()); + args.putInt(NEWLY_PICKED_DATE_TIME_TYPE_KEY, dateType.ordinal()); + getSupportFragmentManager().setFragmentResult(PICK_DATE_TIME_REQUEST_KEY, args); + }); + + timePicker.show(getSupportFragmentManager(), "TIME_PICKER"); } // Required to handle configuration changes // See https://github.com/material-components/material-components-android/issues/1688 private void setMaterialDatePickerResultListener() { - MaterialDatePicker fragment = (MaterialDatePicker) getSupportFragmentManager().findFragmentByTag(PICK_DATE_REQUEST_KEY); + MaterialDatePicker fragment = (MaterialDatePicker) getSupportFragmentManager().findFragmentByTag(PICK_DATE_TIME_REQUEST_KEY); if (fragment != null) { fragment.addOnPositiveButtonClickListener(selection -> { Bundle args = new Bundle(); - args.putLong(NEWLY_PICKED_DATE_ARGUMENT_KEY, selection); - getSupportFragmentManager().setFragmentResult(PICK_DATE_REQUEST_KEY, args); + args.putLong(NEWLY_PICKED_DATE_TIME_ARGUMENT_KEY, selection); + getSupportFragmentManager().setFragmentResult(PICK_DATE_TIME_REQUEST_KEY, args); }); } getSupportFragmentManager().setFragmentResultListener( - PICK_DATE_REQUEST_KEY, + PICK_DATE_TIME_REQUEST_KEY, this, (requestKey, result) -> { - long selection = result.getLong(NEWLY_PICKED_DATE_ARGUMENT_KEY); - - Date newDate = new Date(selection); - - LoyaltyCardField tempLoyaltyCardField = viewModel.getTempLoyaltyCardField(); - if (tempLoyaltyCardField == null) { - throw new AssertionError("tempLoyaltyCardField is null unexpectedly!"); - } - - switch (tempLoyaltyCardField) { - case validFrom: - formatDateField(LoyaltyCardEditActivity.this, validFromField, newDate); - setLoyaltyCardValidFrom(newDate); - break; - case expiry: - formatDateField(LoyaltyCardEditActivity.this, expiryField, newDate); - setLoyaltyCardExpiry(newDate); - break; - default: - throw new AssertionError("Unexpected field: " + tempLoyaltyCardField); + String selectedDateTimeIso = result.getString(NEWLY_PICKED_DATE_TIME_ARGUMENT_KEY); + int selectedDateType = result.getInt(NEWLY_PICKED_DATE_TIME_TYPE_KEY); + + if (selectedDateTimeIso != null) { + ZonedDateTime finalDateTime = ZonedDateTime.parse(selectedDateTimeIso); + Instant newDate = finalDateTime.toInstant(); + + LoyaltyCardDateType tempLoyaltyCardField = LoyaltyCardDateType.getEntries().get(selectedDateType); + + switch (tempLoyaltyCardField) { + case LoyaltyCardDateType.VALID_FROM: + formatDateField(LoyaltyCardEditActivity.this, validFromField, finalDateTime); + setLoyaltyCardValidFrom(newDate); + break; + case LoyaltyCardDateType.EXPIRY: + formatDateField(LoyaltyCardEditActivity.this, expiryField, finalDateTime); + setLoyaltyCardExpiry(newDate); + break; + default: + throw new AssertionError("Unexpected field: " + tempLoyaltyCardField); + } } } ); diff --git a/app/src/main/java/protect/card_locker/LoyaltyCardViewActivity.java b/app/src/main/java/protect/card_locker/LoyaltyCardViewActivity.java index 705b4a515f..e12b5828d8 100644 --- a/app/src/main/java/protect/card_locker/LoyaltyCardViewActivity.java +++ b/app/src/main/java/protect/card_locker/LoyaltyCardViewActivity.java @@ -2,7 +2,6 @@ import android.content.ActivityNotFoundException; import android.content.Intent; -import android.content.pm.ActivityInfo; import android.content.res.ColorStateList; import android.content.res.Configuration; import android.database.sqlite.SQLiteDatabase; @@ -57,11 +56,11 @@ import java.io.File; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; -import java.text.DateFormat; import java.text.ParseException; +import java.time.Instant; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Arrays; -import java.util.Date; import java.util.List; import java.util.function.Predicate; @@ -425,9 +424,9 @@ private void showInfoDialog() { infoText.append(getString(R.string.balanceSentence, Utils.formatBalance(this, loyaltyCard.balance, loyaltyCard.balanceType))); } - appendDateInfo(infoText, loyaltyCard.validFrom, (Utils::isNotYetValid), R.string.validFromSentence, R.string.validFromSentence); + appendDateInfo(infoText, loyaltyCard.validFrom, (DateTimeUtils::isNotYetValid), R.string.validFromSentence, R.string.validFromSentence); - appendDateInfo(infoText, loyaltyCard.expiry, (Utils::hasExpired), R.string.expiryStateSentenceExpired, R.string.expiryStateSentence); + appendDateInfo(infoText, loyaltyCard.expiry, (DateTimeUtils::hasExpired), R.string.expiryStateSentenceExpired, R.string.expiryStateSentence); infoTextview.setText(infoText); @@ -436,9 +435,10 @@ private void showInfoDialog() { infoDialog.create().show(); } - private void appendDateInfo(SpannableStringBuilder infoText, Date date, Predicate dateCheck, @StringRes int dateCheckTrueString, @StringRes int dateCheckFalseString) { + private void appendDateInfo(SpannableStringBuilder infoText, Instant date, Predicate dateCheck, @StringRes int dateCheckTrueString, @StringRes int dateCheckFalseString) { if (date != null) { - String formattedDate = DateFormat.getDateInstance(DateFormat.LONG).format(date); + ZonedDateTime zoneDate = DateTimeUtils.instantToZonedDateTime(date); + String formattedDate = zoneDate == null ? "" : zoneDate.format(DateTimeUtils.longDateShortTimeFormatter); padSpannableString(infoText); if (dateCheck.test(date)) { diff --git a/app/src/main/java/protect/card_locker/PkpassParser.kt b/app/src/main/java/protect/card_locker/PkpassParser.kt index 149236150c..c771ead588 100644 --- a/app/src/main/java/protect/card_locker/PkpassParser.kt +++ b/app/src/main/java/protect/card_locker/PkpassParser.kt @@ -16,10 +16,10 @@ import java.io.IOException import java.math.BigDecimal import java.text.DateFormat import java.text.ParseException +import java.time.Instant import java.time.ZonedDateTime import java.time.format.DateTimeParseException import java.util.Currency -import java.util.Date class PkpassParser(context: Context, uri: Uri?) { private var mContext = context @@ -30,8 +30,8 @@ class PkpassParser(context: Context, uri: Uri?) { private var store: String? = null private var note: String? = null - private var validFrom: Date? = null - private var expiry: Date? = null + private var validFrom: Instant? = null + private var expiry: Instant? = null private val balance: BigDecimal = BigDecimal(0) private val balanceType: Currency? = null // FIXME: Some cards may not have any barcodes, but Catima doesn't accept null card ID @@ -200,8 +200,15 @@ class PkpassParser(context: Context, uri: Uri?) { return Color.rgb(red, green, blue) } - private fun parseDateTime(dateTime: String): Date { - return Date.from(ZonedDateTime.parse(dateTime).toInstant()) + private fun parseDateTime(dateTime: String?): ZonedDateTime? { + if(dateTime.isNullOrEmpty()) return null + Log.d("PARSE", "parseDateTime: $dateTime") + return try { + ZonedDateTime.parse(dateTime) + } catch (_: IllegalArgumentException) { + // The string was not in the correct ISO 8601 format + null + } } private fun parseLanguageStrings(data: String): Map { @@ -256,11 +263,11 @@ class PkpassParser(context: Context, uri: Uri?) { noteText.append(getTranslation(jsonObject.getString("description"), locale)) try { - validFrom = parseDateTime(jsonObject.getString("relevantDate")) + validFrom = parseDateTime(jsonObject.getString("relevantDate"))?.toInstant() } catch (ignored: JSONException) {} try { - expiry = parseDateTime(jsonObject.getString("expirationDate")) + expiry = parseDateTime(jsonObject.getString("expirationDate"))?.toInstant() } catch (ignored: JSONException) {} try { diff --git a/app/src/main/java/protect/card_locker/Utils.java b/app/src/main/java/protect/card_locker/Utils.java index 2703dca10b..05677f108d 100644 --- a/app/src/main/java/protect/card_locker/Utils.java +++ b/app/src/main/java/protect/card_locker/Utils.java @@ -53,9 +53,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.zxing.BinaryBitmap; +import com.google.zxing.DecodeHintType; import com.google.zxing.LuminanceSource; import com.google.zxing.MultiFormatReader; -import com.google.zxing.DecodeHintType; import com.google.zxing.NotFoundException; import com.google.zxing.RGBLuminanceSource; import com.google.zxing.Result; @@ -76,15 +76,14 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.NumberFormat; -import java.text.DecimalFormat; import java.text.ParseException; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Currency; -import java.util.Date; import java.util.EnumMap; import java.util.GregorianCalendar; import java.util.List; @@ -516,31 +515,6 @@ static public void makeUserChooseParseResultFromList(Context context, List parseV1(bufferedReader); + case 2 -> parseV2(bufferedReader); + default -> + throw new FormatException(String.format("No code to parse version %s", version)); + }; } public ImportedData parseV1(BufferedReader input) throws IOException, FormatException, InterruptedException { @@ -403,26 +402,24 @@ private LoyaltyCard importLoyaltyCard(CSVRecord record) throws FormatException { String note = CSVHelpers.extractString(DBHelper.LoyaltyCardDbIds.NOTE, record, ""); - Date validFrom = null; - Long validFromLong; - try { - validFromLong = CSVHelpers.extractLong(DBHelper.LoyaltyCardDbIds.VALID_FROM, record); - } catch (FormatException ignored) { - validFromLong = null; - } + Instant validFrom = null; + String validFromLong = CSVHelpers.extractString(DBHelper.LoyaltyCardDbIds.VALID_FROM, record, null); if (validFromLong != null) { - validFrom = new Date(validFromLong); + try { + validFrom = Instant.parse(validFromLong); + } catch (DateTimeParseException e) { + e.printStackTrace(); + } } - Date expiry = null; - Long expiryLong; - try { - expiryLong = CSVHelpers.extractLong(DBHelper.LoyaltyCardDbIds.EXPIRY, record); - } catch (FormatException ignored) { - expiryLong = null; - } + Instant expiry = null; + String expiryLong = CSVHelpers.extractString(DBHelper.LoyaltyCardDbIds.EXPIRY, record, null); if (expiryLong != null) { - expiry = new Date(expiryLong); + try { + expiry = Instant.parse(expiryLong); + } catch (DateTimeParseException e) { + e.printStackTrace(); + } } // These fields did not exist in versions 1.8.1 and before diff --git a/app/src/main/java/protect/card_locker/importexport/MultiFormatImporter.java b/app/src/main/java/protect/card_locker/importexport/MultiFormatImporter.java index d6ae954da0..dcc48dd4ee 100644 --- a/app/src/main/java/protect/card_locker/importexport/MultiFormatImporter.java +++ b/app/src/main/java/protect/card_locker/importexport/MultiFormatImporter.java @@ -28,53 +28,41 @@ public class MultiFormatImporter { * the database. */ public static ImportExportResult importData(Context context, SQLiteDatabase database, InputStream input, DataFormat format, char[] password) { - Importer importer = null; + Importer importer = switch (format) { + case Catima -> new CatimaImporter(); + case Fidme -> new FidmeImporter(); + case VoucherVault -> new VoucherVaultImporter(); + }; - switch (format) { - case Catima: - importer = new CatimaImporter(); - break; - case Fidme: - importer = new FidmeImporter(); - break; - case VoucherVault: - importer = new VoucherVaultImporter(); - break; - } + String error; + File inputFile; - String error = null; - if (importer != null) { - File inputFile; + try { + inputFile = Utils.copyToTempFile(context, input, TEMP_ZIP_NAME); + database.beginTransaction(); try { - inputFile = Utils.copyToTempFile(context, input, TEMP_ZIP_NAME); - database.beginTransaction(); - try { - importer.importData(context, database, inputFile, password); - database.setTransactionSuccessful(); - return new ImportExportResult(ImportExportResultType.Success); - } catch (ZipException e) { - if (e.getType().equals(ZipException.Type.WRONG_PASSWORD)) { - return new ImportExportResult(ImportExportResultType.BadPassword); - } else { - Log.e(TAG, "Failed to import data", e); - error = e.toString(); - } - } catch (Exception e) { + importer.importData(context, database, inputFile, password); + database.setTransactionSuccessful(); + return new ImportExportResult(ImportExportResultType.Success); + } catch (ZipException e) { + if (e.getType().equals(ZipException.Type.WRONG_PASSWORD)) { + return new ImportExportResult(ImportExportResultType.BadPassword); + } else { Log.e(TAG, "Failed to import data", e); error = e.toString(); - } finally { - database.endTransaction(); - if (!inputFile.delete()) { - Log.w(TAG, "Failed to delete temporary ZIP file (should not be a problem) " + inputFile); - } } - } catch (IOException e) { - Log.e(TAG, "Failed to copy ZIP file", e); + } catch (Exception e) { + Log.e(TAG, "Failed to import data", e); error = e.toString(); + } finally { + database.endTransaction(); + if (!inputFile.delete()) { + Log.w(TAG, "Failed to delete temporary ZIP file (should not be a problem) " + inputFile); + } } - } else { - error = "Unsupported data format imported: " + format.name(); - Log.e(TAG, error); + } catch (IOException e) { + Log.e(TAG, "Failed to copy ZIP file", e); + error = e.toString(); } return new ImportExportResult(ImportExportResultType.GenericFailure, error); diff --git a/app/src/main/java/protect/card_locker/importexport/VoucherVaultImporter.java b/app/src/main/java/protect/card_locker/importexport/VoucherVaultImporter.java index bf0dc076a7..1d74fda456 100644 --- a/app/src/main/java/protect/card_locker/importexport/VoucherVaultImporter.java +++ b/app/src/main/java/protect/card_locker/importexport/VoucherVaultImporter.java @@ -1,6 +1,5 @@ package protect.card_locker.importexport; -import android.annotation.SuppressLint; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.graphics.Color; @@ -20,12 +19,13 @@ import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.text.ParseException; -import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Currency; -import java.util.Date; import java.util.List; -import java.util.TimeZone; import protect.card_locker.CatimaBarcode; import protect.card_locker.DBHelper; @@ -76,11 +76,21 @@ public ImportedData importJSON(JSONArray jsonArray) throws FormatException, JSON String store = jsonCard.getString("description"); - Date expiry = null; + Instant expiry = null; if (!jsonCard.isNull("expires")) { - @SuppressLint("SimpleDateFormat") SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); - dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - expiry = dateFormat.parse(jsonCard.getString("expires")); + try { + String expiryString = jsonCard.getString("expires"); + + // Parse the string as a LocalDateTime since it has no timezone info + LocalDateTime localDateTime = LocalDateTime.parse(expiryString); + + // Convert it to an Instant, specifying it represents a time in UTC + expiry = localDateTime.toInstant(ZoneOffset.UTC); + + } catch (JSONException | DateTimeParseException e) { + // Handle cases where the key is missing or the date format is invalid + e.printStackTrace(); + } } BigDecimal balance = new BigDecimal("0"); diff --git a/app/src/main/java/protect/card_locker/viewmodels/LoyaltyCardEditActivityViewModel.kt b/app/src/main/java/protect/card_locker/viewmodels/LoyaltyCardEditActivityViewModel.kt index 32f689ef44..b0f8144312 100644 --- a/app/src/main/java/protect/card_locker/viewmodels/LoyaltyCardEditActivityViewModel.kt +++ b/app/src/main/java/protect/card_locker/viewmodels/LoyaltyCardEditActivityViewModel.kt @@ -2,11 +2,21 @@ package protect.card_locker.viewmodels import android.net.Uri import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import protect.card_locker.LoyaltyCard import protect.card_locker.LoyaltyCardField import protect.card_locker.async.TaskHandler +data class LoyaltyCardEditState( + val loyaltyCardDateType: LoyaltyCardDateType? = null, +) class LoyaltyCardEditActivityViewModel : ViewModel() { + + private val _state = MutableStateFlow(LoyaltyCardEditState()) + val state : StateFlow = _state.asStateFlow() + var initialized: Boolean = false var hasChanged: Boolean = false @@ -24,4 +34,20 @@ class LoyaltyCardEditActivityViewModel : ViewModel() { var tempLoyaltyCardField: LoyaltyCardField? = null var loyaltyCard: LoyaltyCard = LoyaltyCard() + + fun onAction(action: LoyaltyCardEditUiAction) { + when (action) { + is LoyaltyCardEditUiAction.SetLoyaltyCardDateType -> { + _state.value = _state.value.copy(loyaltyCardDateType = action.loyaltyCardDateType) + } + } + } } + +enum class LoyaltyCardDateType { + VALID_FROM, EXPIRY +} + +sealed interface LoyaltyCardEditUiAction { + data class SetLoyaltyCardDateType(val loyaltyCardDateType: LoyaltyCardDateType) : LoyaltyCardEditUiAction +} \ No newline at end of file diff --git a/app/src/test/java/protect/card_locker/DateTimeUtilsTest.java b/app/src/test/java/protect/card_locker/DateTimeUtilsTest.java new file mode 100644 index 0000000000..a019ccc808 --- /dev/null +++ b/app/src/test/java/protect/card_locker/DateTimeUtilsTest.java @@ -0,0 +1,278 @@ +package protect.card_locker; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {28}) +public class DateTimeUtilsTest { + + @Test + public void testLongToInstant() { + // Given + long epochMillis = 1609459200000L; // 2021-01-01 00:00:00 UTC + + // When + Instant result = DateTimeUtils.longToInstant(epochMillis); + + // Then + assertNotNull(result); + assertEquals(epochMillis, result.toEpochMilli()); + } + + @Test + public void testLongToInstant_withNull() { + // Given + Long nullMillis = null; + + // When + Instant result = DateTimeUtils.longToInstant(nullMillis); + + // Then + assertNull(result); + } + + @Test + public void testInstantToZonedDateTime() { + // Given + Instant instant = Instant.parse("2024-01-15T10:30:00Z"); + + // When + ZonedDateTime result = DateTimeUtils.instantToZonedDateTime(instant); + + // Then + assertNotNull(result); + assertEquals(instant, result.toInstant()); + assertEquals(ZoneId.systemDefault(), result.getZone()); + } + + @Test + public void testInstantToZonedDateTime_withNull() { + // When + ZonedDateTime result = DateTimeUtils.instantToZonedDateTime(null); + + // Then + assertNull(result); + } + + @Test + public void testIsNotYetValid_withFutureDate() { + // Given - A date in the future (tomorrow) + Instant futureDate = Instant.now().plus(1, ChronoUnit.DAYS); + + // When + boolean result = DateTimeUtils.isNotYetValid(futureDate); + + // Then + assertTrue("Future date should be 'not yet valid'", result); + } + + @Test + public void testIsNotYetValid_withPastDate() { + // Given - A date in the past (yesterday) + Instant pastDate = Instant.now().minus(1, ChronoUnit.DAYS); + + // When + boolean result = DateTimeUtils.isNotYetValid(pastDate); + + // Then + assertFalse("Past date should not be 'not yet valid'", result); + } + + @Test + public void testIsNotYetValid_withTodayStart() { + // Given - Start of today + LocalDate today = LocalDate.now(ZoneId.systemDefault()); + ZonedDateTime startOfToday = today.atStartOfDay(ZoneId.systemDefault()); + Instant todayStart = startOfToday.toInstant(); + + // When + boolean result = DateTimeUtils.isNotYetValid(todayStart); + + // Then + assertFalse("Start of today should not be 'not yet valid'", result); + } + + @Test + public void testIsNotYetValid_withNull() { + // When + boolean result = DateTimeUtils.isNotYetValid(null); + + // Then + assertFalse("Null date should not be 'not yet valid'", result); + } + + @Test + public void testHasExpired_withPastDate() { + // Given - A date in the past (yesterday) + Instant pastDate = Instant.now().minus(1, ChronoUnit.DAYS); + + // When + boolean result = DateTimeUtils.hasExpired(pastDate); + + // Then + assertTrue("Past date should be expired", result); + } + + @Test + public void testHasExpired_withFutureDate() { + // Given - A date in the future (tomorrow) + Instant futureDate = Instant.now().plus(1, ChronoUnit.DAYS); + + // When + boolean result = DateTimeUtils.hasExpired(futureDate); + + // Then + assertFalse("Future date should not be expired", result); + } + + @Test + public void testHasExpired_withTodayStart() { + // Given - Start of today + LocalDate today = LocalDate.now(ZoneId.systemDefault()); + ZonedDateTime startOfToday = today.atStartOfDay(ZoneId.systemDefault()); + Instant todayStart = startOfToday.toInstant(); + + // When + boolean result = DateTimeUtils.hasExpired(todayStart); + + // Then + assertTrue("Start of today should be considered expired", result); + } + + @Test + public void testHasExpired_withNull() { + // When + boolean result = DateTimeUtils.hasExpired(null); + + // Then + assertFalse("Null expiry should never expire", result); + } + + @Test + public void testFormatMedium_withValidInstant() { + // Given + Instant instant = Instant.parse("2024-01-15T10:30:00Z"); + + // When + String result = DateTimeUtils.formatMedium(instant); + + // Then + assertNotNull(result); + assertFalse(result.isEmpty()); + + // Verify it follows the expected pattern by parsing it back + ZonedDateTime zdt = Instant.parse("2024-01-15T10:30:00Z") + .atZone(ZoneId.systemDefault()); + String expected = zdt.format(DateTimeFormatter.ofLocalizedDateTime( + java.time.format.FormatStyle.MEDIUM, + java.time.format.FormatStyle.SHORT + )); + assertEquals(expected, result); + } + + @Test + public void testFormatMedium_withNull() { + // When + String result = DateTimeUtils.formatMedium(null); + + // Then + assertNull(result); + } + + @Test + public void testFormatLong_withValidInstant() { + // Given + Instant instant = Instant.parse("2024-01-15T10:30:00Z"); + + // When + String result = DateTimeUtils.formatLong(instant); + + // Then + assertNotNull(result); + assertFalse(result.isEmpty()); + + // Verify it follows the expected pattern by parsing it back + ZonedDateTime zdt = Instant.parse("2024-01-15T10:30:00Z") + .atZone(ZoneId.systemDefault()); + String expected = zdt.format(DateTimeFormatter.ofLocalizedDateTime( + java.time.format.FormatStyle.LONG, + java.time.format.FormatStyle.SHORT + )); + assertEquals(expected, result); + } + + @Test + public void testFormatLong_withNull() { + // When + String result = DateTimeUtils.formatLong(null); + + // Then + assertNull(result); + } + + @Test + public void testDateTimeFormattersInitialization() { + // Verify that the formatters are properly initialized + assertNotNull(DateTimeUtils.longDateShortTimeFormatter); + assertNotNull(DateTimeUtils.mediumDateShortTimeFormatter); + + // Test that formatters work correctly + Instant instant = Instant.parse("2024-01-15T10:30:00Z"); + ZonedDateTime zdt = instant.atZone(ZoneId.systemDefault()); + + String longFormat = zdt.format(DateTimeUtils.longDateShortTimeFormatter); + String mediumFormat = zdt.format(DateTimeUtils.mediumDateShortTimeFormatter); + + assertNotNull(longFormat); + assertNotNull(mediumFormat); + assertFalse(longFormat.isEmpty()); + assertFalse(mediumFormat.isEmpty()); + } + + @Test + public void testIsNotYetValid_RelativeToNow() { + Instant now = Instant.now(); + + // Test with timestamps relative to current moment + Instant oneMinuteAgo = now.minus(1, ChronoUnit.MINUTES); + Instant oneMinuteLater = now.plus(1, ChronoUnit.MINUTES); + Instant oneHourLater = now.plus(1, ChronoUnit.HOURS); + + // One minute ago should be valid (not "not yet valid") + assertFalse(DateTimeUtils.isNotYetValid(oneMinuteAgo)); + + // Future times should be "not yet valid" + assertTrue(DateTimeUtils.isNotYetValid(oneMinuteLater)); + assertTrue(DateTimeUtils.isNotYetValid(oneHourLater)); + } + + @Test + public void testConsistencyBetweenMethods() { + // Test that the methods work consistently together + long epochMillis = 1609459200000L; // 2021-01-01 00:00:00 UTC + + Instant instant = DateTimeUtils.longToInstant(epochMillis); + ZonedDateTime zonedDateTime = DateTimeUtils.instantToZonedDateTime(instant); + + assertNotNull(instant); + assertNotNull(zonedDateTime); + assertEquals(epochMillis, instant.toEpochMilli()); + assertEquals(instant, zonedDateTime.toInstant()); + } +} \ No newline at end of file diff --git a/app/src/test/java/protect/card_locker/ImportExportTest.java b/app/src/test/java/protect/card_locker/ImportExportTest.java index 370af487f8..e75dfbd543 100644 --- a/app/src/test/java/protect/card_locker/ImportExportTest.java +++ b/app/src/test/java/protect/card_locker/ImportExportTest.java @@ -17,6 +17,7 @@ import com.google.zxing.BarcodeFormat; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -37,10 +38,11 @@ import java.io.OutputStreamWriter; import java.math.BigDecimal; import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Currency; -import java.util.Date; import java.util.HashMap; import java.util.List; @@ -54,6 +56,7 @@ @RunWith(RobolectricTestRunner.class) public class ImportExportTest { private Activity activity; + private DBHelper mDbHelper; private SQLiteDatabase mDatabase; private final String BARCODE_DATA = "428311627547"; @@ -64,7 +67,17 @@ public void setUp() { ShadowLog.stream = System.out; activity = Robolectric.setupActivity(MainActivity.class); - mDatabase = TestHelpers.getEmptyDb(activity).getWritableDatabase(); + mDbHelper = TestHelpers.getEmptyDb(activity); + mDatabase = mDbHelper.getWritableDatabase(); + } + + @After + public void tearDown() { + // This method runs after every test and is the perfect place to clean up. + // Closing the helper will also close the underlying database connection. + if (mDbHelper != null) { + mDbHelper.close(); + } } private void addLoyaltyCardsFiveStarred() { @@ -107,7 +120,7 @@ public void addLoyaltyCardsWithExpiryNeverPastTodayFuture() { assertEquals(Integer.valueOf(0), card.headerColor); assertEquals(0, card.starStatus); - id = DBHelper.insertLoyaltyCard(mDatabase, "Past", "", null, new Date((long) 1), new BigDecimal("0"), null, BARCODE_DATA, null, BARCODE_TYPE, 0, 0, null,0); + id = DBHelper.insertLoyaltyCard(mDatabase, "Past", "", null, Instant.ofEpochMilli(1L), new BigDecimal("0"), null, BARCODE_DATA, null, BARCODE_TYPE, 0, 0, null,0); result = (id != -1); assertTrue(result); @@ -115,7 +128,7 @@ public void addLoyaltyCardsWithExpiryNeverPastTodayFuture() { assertEquals("Past", card.store); assertEquals("", card.note); assertEquals(null, card.validFrom); - assertTrue(card.expiry.before(new Date())); + assertTrue(card.expiry.isBefore(Instant.now())); assertEquals(new BigDecimal("0"), card.balance); assertEquals(null, card.balanceType); assertEquals(BARCODE_DATA, card.cardId); @@ -124,7 +137,7 @@ public void addLoyaltyCardsWithExpiryNeverPastTodayFuture() { assertEquals(Integer.valueOf(0), card.headerColor); assertEquals(0, card.starStatus); - id = DBHelper.insertLoyaltyCard(mDatabase, "Today", "", null, new Date(), new BigDecimal("0"), null, BARCODE_DATA, null, BARCODE_TYPE, 0, 0, null,0); + id = DBHelper.insertLoyaltyCard(mDatabase, "Today", "", null, Instant.now(), new BigDecimal("0"), null, BARCODE_DATA, null, BARCODE_TYPE, 0, 0, null,0); result = (id != -1); assertTrue(result); @@ -132,8 +145,8 @@ public void addLoyaltyCardsWithExpiryNeverPastTodayFuture() { assertEquals("Today", card.store); assertEquals("", card.note); assertEquals(null, card.validFrom); - assertTrue(card.expiry.before(new Date(new Date().getTime() + 86400))); - assertTrue(card.expiry.after(new Date(new Date().getTime() - 86400))); + assertTrue(card.expiry.isBefore(Instant.now().plus(1L, ChronoUnit.DAYS))); + assertTrue(card.expiry.isAfter(Instant.now().minus(1L, ChronoUnit.DAYS))); assertEquals(new BigDecimal("0"), card.balance); assertEquals(null, card.balanceType); assertEquals(BARCODE_DATA, card.cardId); @@ -144,7 +157,7 @@ public void addLoyaltyCardsWithExpiryNeverPastTodayFuture() { // This will break after 19 January 2038 // If someone is still maintaining this code base by then: I love you - id = DBHelper.insertLoyaltyCard(mDatabase, "Future", "", null, new Date(2147483648000L), new BigDecimal("0"), null, BARCODE_DATA, null, BARCODE_TYPE, 0, 0, null,0); + id = DBHelper.insertLoyaltyCard(mDatabase, "Future", "", null, Instant.ofEpochMilli(2147483648000L), new BigDecimal("0"), null, BARCODE_DATA, null, BARCODE_TYPE, 0, 0, null,0); result = (id != -1); assertTrue(result); @@ -152,7 +165,7 @@ public void addLoyaltyCardsWithExpiryNeverPastTodayFuture() { assertEquals("Future", card.store); assertEquals("", card.note); assertEquals(null, card.validFrom); - assertTrue(card.expiry.after(new Date(new Date().getTime() + 86400))); + assertTrue(card.expiry.isAfter(Instant.now().plus(1L, ChronoUnit.DAYS))); assertEquals(new BigDecimal("0"), card.balance); assertEquals(null, card.balanceType); assertEquals(BARCODE_DATA, card.cardId); @@ -830,7 +843,7 @@ public void exportImportV2Zip() throws FileNotFoundException { HashMap loyaltyCardIconImages = new HashMap<>(); // Create card 1 - int loyaltyCardId = (int) DBHelper.insertLoyaltyCard(mDatabase, "Card 1", "Note 1", new Date(1601510400), new Date(1618053234), new BigDecimal("100"), Currency.getInstance("USD"), "1234", "5432", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE), 1, 0, null,0); + int loyaltyCardId = (int) DBHelper.insertLoyaltyCard(mDatabase, "Card 1", "Note 1", Instant.ofEpochMilli(1601510400000L), Instant.ofEpochMilli(1618053234000L), new BigDecimal("100"), Currency.getInstance("USD"), "1234", "5432", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE), 1, 0, null,0); loyaltyCardHashMap.put(loyaltyCardId, DBHelper.getLoyaltyCard(activity.getApplicationContext(), mDatabase, loyaltyCardId)); DBHelper.insertGroup(mDatabase, "One"); List groups = Arrays.asList(DBHelper.getGroup(mDatabase, "One")); @@ -954,8 +967,8 @@ public void importV2CSV() { assertEquals("Card 1", card1.store); assertEquals("Note 1", card1.note); - assertEquals(new Date(1601510400), card1.validFrom); - assertEquals(new Date(1618053234), card1.expiry); + assertEquals(Instant.parse("2020-10-01T00:00:00Z"), card1.validFrom); + assertEquals(Instant.parse("2021-04-10T11:13:54Z"), card1.expiry); assertEquals(new BigDecimal("100"), card1.balance); assertEquals(Currency.getInstance("USD"), card1.balanceType); assertEquals("1234", card1.cardId); @@ -989,7 +1002,7 @@ public void importV2CSV() { assertEquals("Department Store", card2.store); assertEquals("", card2.note); assertEquals(null, card2.validFrom); - assertEquals(new Date(1618041729), card2.expiry); + assertEquals(Instant.parse("2021-04-10T08:02:09Z"), card2.expiry); assertEquals(new BigDecimal("0"), card2.balance); assertEquals(null, card2.balanceType); assertEquals("A", card2.cardId); @@ -1151,7 +1164,7 @@ public void importVoucherVault() { assertEquals("Department Store", card.store); assertEquals("", card.note); assertEquals(null, card.validFrom); - assertEquals(new Date(1616716800000L), card.expiry); + assertEquals(Instant.ofEpochMilli(1616716800000L), card.expiry); assertEquals(new BigDecimal("3.5"), card.balance); assertEquals(Currency.getInstance("USD"), card.balanceType); assertEquals("26846363", card.cardId); diff --git a/app/src/test/java/protect/card_locker/ImportURITest.java b/app/src/test/java/protect/card_locker/ImportURITest.java index f0b8678271..37e003ad81 100644 --- a/app/src/test/java/protect/card_locker/ImportURITest.java +++ b/app/src/test/java/protect/card_locker/ImportURITest.java @@ -20,8 +20,8 @@ import java.io.InvalidObjectException; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; +import java.time.Instant; import java.util.Currency; -import java.util.Date; @RunWith(RobolectricTestRunner.class) public class ImportURITest { @@ -39,7 +39,7 @@ public void setUp() { @Test public void ensureNoDataLoss() throws InvalidObjectException, UnsupportedEncodingException { // Generate card - Date date = new Date(); + Instant date = Instant.now(); DBHelper.insertLoyaltyCard(mDatabase, "store", "This note contains evil symbols like & and = that will break the parser if not escaped right $#!%()*+;:á", date, date, new BigDecimal("100"), null, BarcodeFormat.UPC_E.toString(), BarcodeFormat.UPC_A.toString(), CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE), Color.BLACK, 1, null,0); diff --git a/app/src/test/java/protect/card_locker/LoyaltyCardViewActivityTest.java b/app/src/test/java/protect/card_locker/LoyaltyCardViewActivityTest.java index 2fc8d6121f..2ae6f516a0 100644 --- a/app/src/test/java/protect/card_locker/LoyaltyCardViewActivityTest.java +++ b/app/src/test/java/protect/card_locker/LoyaltyCardViewActivityTest.java @@ -26,6 +26,7 @@ import android.graphics.drawable.ColorDrawable; import android.net.Uri; import android.os.Bundle; +import android.os.Looper; import android.view.Menu; import android.view.View; import android.view.WindowInsets; @@ -41,16 +42,21 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; import androidx.test.core.app.ApplicationProvider; import com.google.android.material.bottomappbar.BottomAppBar; +import com.google.android.material.datepicker.MaterialDatePicker; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.tabs.TabLayout; import com.google.android.material.textfield.MaterialAutoCompleteTextView; import com.google.android.material.textfield.TextInputLayout; +import com.google.android.material.timepicker.MaterialTimePicker; import com.google.zxing.BarcodeFormat; import com.google.zxing.client.android.Intents; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -65,12 +71,15 @@ import java.io.IOException; import java.math.BigDecimal; -import java.text.DateFormat; import java.text.ParseException; import java.time.Instant; -import java.time.temporal.ChronoUnit; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Currency; -import java.util.Date; + +import protect.card_locker.viewmodels.LoyaltyCardDateType; @RunWith(RobolectricTestRunner.class) public class LoyaltyCardViewActivityTest { @@ -84,7 +93,6 @@ enum ViewMode { ADD_CARD, VIEW_CARD, UPDATE_CARD, - ; } enum FieldTypeView { @@ -93,10 +101,23 @@ enum FieldTypeView { ImageView } + Context context; + ActivityController activityController; + LoyaltyCardEditActivity activity; + @Before public void setUp() { // Output logs emitted during tests so they may be accessed ShadowLog.stream = System.out; + context = ApplicationProvider.getApplicationContext(); + activityController = Robolectric.buildActivity(LoyaltyCardEditActivity.class); + activity = activityController.get(); + activityController.setup(); + } + + @After + public void tearDown() { + activityController.destroy(); } /** @@ -176,14 +197,14 @@ private void saveLoyaltyCardWithArguments(final Activity activity, if (validFrom.equals(activity.getApplicationContext().getString(R.string.anyDate))) { assertEquals(null, card.validFrom); } else { - assertEquals(DateFormat.getDateInstance().parse(validFrom), card.validFrom); + assertEquals(Instant.parse(validFrom), card.validFrom); } // The special "Never" string shouldn't actually be written to the loyalty card if (expiry.equals(activity.getApplicationContext().getString(R.string.never))) { assertEquals(null, card.expiry); } else { - assertEquals(DateFormat.getDateInstance().parse(expiry), card.expiry); + assertEquals(Instant.parse(expiry), card.expiry); } // The special "Points" string shouldn't actually be written to the loyalty card @@ -345,7 +366,7 @@ private void checkAllFields(final Activity activity, ViewMode mode, } @Test - @Config(qualifiers="de") + @Config(qualifiers = "de") public void noCrashOnRegionlessLocale() { ActivityController activityController = Robolectric.buildActivity(LoyaltyCardEditActivity.class).create(); @@ -410,17 +431,17 @@ public void noDataLossOnResumeOrRotate() { final ImageView backImageView = activity.findViewById(R.id.backImage); Currency currency = Currency.getInstance("EUR"); - Date validFromDate = Date.from(Instant.now().minus(20, ChronoUnit.DAYS)); - Date expiryDate = new Date(); + ZonedDateTime validFromDate = ZonedDateTime.now().minusDays(20); + ZonedDateTime expiryDate = ZonedDateTime.now(); Bitmap frontBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.circle); Bitmap backBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_done); storeField.setText("correct store"); noteField.setText("correct note"); LoyaltyCardEditActivity.formatDateField(context, validFromField, validFromDate); - activity.setLoyaltyCardValidFrom(validFromDate); + activity.setLoyaltyCardValidFrom(validFromDate.toInstant()); LoyaltyCardEditActivity.formatDateField(context, expiryField, expiryDate); - activity.setLoyaltyCardExpiry(expiryDate); + activity.setLoyaltyCardExpiry(expiryDate.toInstant()); balanceField.setText("100"); balanceTypeField.setText(currency.getSymbol()); cardIdField.setText("12345678"); @@ -432,7 +453,7 @@ public void noDataLossOnResumeOrRotate() { shadowOf(getMainLooper()).idle(); // Check if changed - checkAllFields(activity, newCard ? ViewMode.ADD_CARD : ViewMode.UPDATE_CARD, "correct store", "correct note", DateFormat.getDateInstance(DateFormat.LONG).format(validFromDate), DateFormat.getDateInstance(DateFormat.LONG).format(expiryDate), "100.00", currency.getSymbol(), "12345678", "87654321", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE).prettyName(), frontBitmap, backBitmap); + checkAllFields(activity, newCard ? ViewMode.ADD_CARD : ViewMode.UPDATE_CARD, "correct store", "correct note", validFromDate.format(DateTimeUtils.longDateShortTimeFormatter), expiryDate.format(DateTimeUtils.longDateShortTimeFormatter), "100.00", currency.getSymbol(), "12345678", "87654321", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE).prettyName(), frontBitmap, backBitmap); // Resume activityController.pause(); @@ -441,7 +462,7 @@ public void noDataLossOnResumeOrRotate() { shadowOf(getMainLooper()).idle(); // Check if no changes lost - checkAllFields(activity, newCard ? ViewMode.ADD_CARD : ViewMode.UPDATE_CARD, "correct store", "correct note", DateFormat.getDateInstance(DateFormat.LONG).format(validFromDate), DateFormat.getDateInstance(DateFormat.LONG).format(expiryDate), "100.00", currency.getSymbol(), "12345678", "87654321", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE).prettyName(), frontBitmap, backBitmap); + checkAllFields(activity, newCard ? ViewMode.ADD_CARD : ViewMode.UPDATE_CARD, "correct store", "correct note", validFromDate.format(DateTimeUtils.longDateShortTimeFormatter), expiryDate.format(DateTimeUtils.longDateShortTimeFormatter), "100.00", currency.getSymbol(), "12345678", "87654321", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE).prettyName(), frontBitmap, backBitmap); // Rotate to landscape activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); @@ -449,7 +470,7 @@ public void noDataLossOnResumeOrRotate() { shadowOf(getMainLooper()).idle(); // Check if no changes lost - checkAllFields(activity, newCard ? ViewMode.ADD_CARD : ViewMode.UPDATE_CARD, "correct store", "correct note", DateFormat.getDateInstance(DateFormat.LONG).format(validFromDate), DateFormat.getDateInstance(DateFormat.LONG).format(expiryDate), "100.00", currency.getSymbol(), "12345678", "87654321", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE).prettyName(), frontBitmap, backBitmap); + checkAllFields(activity, newCard ? ViewMode.ADD_CARD : ViewMode.UPDATE_CARD, "correct store", "correct note", validFromDate.format(DateTimeUtils.longDateShortTimeFormatter), expiryDate.format(DateTimeUtils.longDateShortTimeFormatter), "100.00", currency.getSymbol(), "12345678", "87654321", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE).prettyName(), frontBitmap, backBitmap); // Rotate to portrait shadowOf(getMainLooper()).idle(); @@ -457,7 +478,7 @@ public void noDataLossOnResumeOrRotate() { activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); // Check if no changes lost - checkAllFields(activity, newCard ? ViewMode.ADD_CARD : ViewMode.UPDATE_CARD, "correct store", "correct note", DateFormat.getDateInstance(DateFormat.LONG).format(validFromDate), DateFormat.getDateInstance(DateFormat.LONG).format(expiryDate), "100.00", currency.getSymbol(), "12345678", "87654321", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE).prettyName(), frontBitmap, backBitmap); + checkAllFields(activity, newCard ? ViewMode.ADD_CARD : ViewMode.UPDATE_CARD, "correct store", "correct note", validFromDate.format(DateTimeUtils.longDateShortTimeFormatter), expiryDate.format(DateTimeUtils.longDateShortTimeFormatter), "100.00", currency.getSymbol(), "12345678", "87654321", CatimaBarcode.fromBarcode(BarcodeFormat.QR_CODE).prettyName(), frontBitmap, backBitmap); } } @@ -726,14 +747,83 @@ public void startWithLoyaltyCardWithReceiptUpdateReceiptCancel() throws IOExcept } @Test - public void startWithLoyaltyCardNoExpirySetExpiry() throws IOException { - final Context context = ApplicationProvider.getApplicationContext(); - SQLiteDatabase database = TestHelpers.getEmptyDb(context).getWritableDatabase(); + public void testShowDateTimePicker() { + LoyaltyCardDateType dateType = LoyaltyCardDateType.VALID_FROM; + ZonedDateTime selectedDateTime = ZonedDateTime.now(); + ZonedDateTime minDateTime = null; + ZonedDateTime maxDateTime = null; + + activity.showDateTimePicker( + dateType, selectedDateTime, minDateTime, maxDateTime + ); + + shadowOf(Looper.getMainLooper()).idle(); + + // Verify date picker is shown + FragmentManager fm = activity.getSupportFragmentManager(); + MaterialDatePicker datePicker = (MaterialDatePicker) fm.findFragmentByTag("DATE_PICKER"); + + assertNotNull(datePicker); + assertTrue(datePicker.isVisible()); + } + + @Test + public void testTimePickerShowsAfterDateSelection() { + // Given + LoyaltyCardDateType dateType = LoyaltyCardDateType.VALID_FROM; + long utcDateSelectionMillis = System.currentTimeMillis(); + ZonedDateTime initialDateTime = ZonedDateTime.now(); + + // When + activity.showTimePicker(dateType, utcDateSelectionMillis, initialDateTime); + + shadowOf(Looper.getMainLooper()).idle(); + + // Then - Verify time picker is shown + FragmentManager fragmentManager = activity.getSupportFragmentManager(); + MaterialTimePicker timePicker = (MaterialTimePicker) + fragmentManager.findFragmentByTag("TIME_PICKER"); + + assertNotNull(timePicker); + assertTrue(timePicker.isVisible()); + } + + @Test + public void testDatePickerPositiveButtonShowsTimePicker() { + LoyaltyCardDateType dateType = LoyaltyCardDateType.VALID_FROM; + ZonedDateTime selectedDateTime = ZonedDateTime.now(); + ZonedDateTime minDateTime = null; + ZonedDateTime maxDateTime = null; + + activity.showDateTimePicker( + dateType, selectedDateTime, minDateTime, maxDateTime + ); + + shadowOf(Looper.getMainLooper()).idle(); + + FragmentManager fm = activity.getSupportFragmentManager(); + MaterialDatePicker datePicker = (MaterialDatePicker) fm.findFragmentByTag("DATE_PICKER"); + Dialog dialog = datePicker.getDialog(); + assertNotNull(dialog); + datePicker.getDialog().findViewById(com.google.android.material.R.id.confirm_button).performClick(); + + shadowOf(Looper.getMainLooper()).idle(); + MaterialTimePicker timePicker = (MaterialTimePicker) + fm.findFragmentByTag("TIME_PICKER"); + + assertNotNull(timePicker); + assertTrue(timePicker.isVisible()); + } + + + @Test + public void startWithLoyaltyCardNoExpirySetExpiry() { + SQLiteDatabase database = TestHelpers.getEmptyDb(context).getWritableDatabase(); long cardId = DBHelper.insertLoyaltyCard(database, "store", "note", null, null, new BigDecimal("0"), null, EAN_BARCODE_DATA, null, EAN_BARCODE_TYPE, Color.BLACK, 0, null, 0); - ActivityController activityController = createActivityWithLoyaltyCard(true, (int) cardId); - Activity activity = (Activity) activityController.get(); + activityController = createActivityWithLoyaltyCard(true, (int) cardId); + activity = activityController.get(); activityController.start(); activityController.visible(); @@ -741,19 +831,38 @@ public void startWithLoyaltyCardNoExpirySetExpiry() throws IOException { checkAllFields(activity, ViewMode.UPDATE_CARD, "store", "note", context.getString(R.string.anyDate), context.getString(R.string.never), "0", context.getString(R.string.points), EAN_BARCODE_DATA, context.getString(R.string.sameAsCardId), EAN_BARCODE_TYPE.prettyName(), null, null); + ZonedDateTime selectedDateTime = ZonedDateTime.now(); + // Set date to today MaterialAutoCompleteTextView expiryField = activity.findViewById(R.id.expiryField); expiryField.setText(expiryField.getAdapter().getItem(1).toString(), false); - shadowOf(getMainLooper()).idle(); + shadowOf(Looper.getMainLooper()).idle(); - Dialog datePickerDialog = ShadowDialog.getLatestDialog(); - assertNotNull(datePickerDialog); - datePickerDialog.findViewById(com.google.android.material.R.id.confirm_button).performClick(); + FragmentManager fm = activity.getSupportFragmentManager(); + MaterialDatePicker datePicker = (MaterialDatePicker) fm.findFragmentByTag("DATE_PICKER"); + Dialog dialog = datePicker.getDialog(); + assertNotNull(dialog); + datePicker.getDialog().findViewById(com.google.android.material.R.id.confirm_button).performClick(); + + shadowOf(Looper.getMainLooper()).idle(); + + MaterialTimePicker timePicker = (MaterialTimePicker) fm.findFragmentByTag("TIME_PICKER"); + assertNotNull(timePicker); + assertTrue(timePicker.isVisible()); + + timePicker.setHour(20); + timePicker.setMinute(30); + + timePicker.getDialog().findViewById(com.google.android.material.R.id.material_timepicker_ok_button).performClick(); shadowOf(getMainLooper()).idle(); - checkAllFields(activity, ViewMode.UPDATE_CARD, "store", "note", context.getString(R.string.anyDate), DateFormat.getDateInstance(DateFormat.LONG).format(new Date()), "0", context.getString(R.string.points), EAN_BARCODE_DATA, context.getString(R.string.sameAsCardId), EAN_BARCODE_TYPE.prettyName(), null, null); + LocalDate selectedDate = selectedDateTime.toLocalDate(); + LocalTime selectedTime = LocalTime.of(timePicker.getHour(), timePicker.getMinute()); + ZonedDateTime selectedDateAndTime = ZonedDateTime.of(selectedDate, selectedTime, ZoneId.systemDefault()); + + checkAllFields(activity, ViewMode.UPDATE_CARD, "store", "note", context.getString(R.string.anyDate), selectedDateAndTime.format(DateTimeUtils.longDateShortTimeFormatter), "0", context.getString(R.string.points), EAN_BARCODE_DATA, context.getString(R.string.sameAsCardId), EAN_BARCODE_TYPE.prettyName(), null, null); database.close(); } @@ -763,7 +872,7 @@ public void startWithLoyaltyCardExpirySetNoExpiry() throws IOException { final Context context = ApplicationProvider.getApplicationContext(); SQLiteDatabase database = TestHelpers.getEmptyDb(context).getWritableDatabase(); - long cardId = DBHelper.insertLoyaltyCard(database, "store", "note", null, new Date(), new BigDecimal("0"), null, EAN_BARCODE_DATA, null, EAN_BARCODE_TYPE, Color.BLACK, 0, null, 0); + long cardId = DBHelper.insertLoyaltyCard(database, "store", "note", null, Instant.now(), new BigDecimal("0"), null, EAN_BARCODE_DATA, null, EAN_BARCODE_TYPE, Color.BLACK, 0, null, 0); ActivityController activityController = createActivityWithLoyaltyCard(true, (int) cardId); Activity activity = (Activity) activityController.get(); @@ -772,7 +881,7 @@ public void startWithLoyaltyCardExpirySetNoExpiry() throws IOException { activityController.visible(); activityController.resume(); - checkAllFields(activity, ViewMode.UPDATE_CARD, "store", "note", context.getString(R.string.anyDate), DateFormat.getDateInstance(DateFormat.LONG).format(new Date()), "0", context.getString(R.string.points), EAN_BARCODE_DATA, context.getString(R.string.sameAsCardId), EAN_BARCODE_TYPE.prettyName(), null, null); + checkAllFields(activity, ViewMode.UPDATE_CARD, "store", "note", context.getString(R.string.anyDate), DateTimeUtils.formatLong(Instant.now()), "0", context.getString(R.string.points), EAN_BARCODE_DATA, context.getString(R.string.sameAsCardId), EAN_BARCODE_TYPE.prettyName(), null, null); // Set date to never MaterialAutoCompleteTextView expiryField = activity.findViewById(R.id.expiryField); @@ -826,7 +935,7 @@ public void onFocusChange(View v, boolean hasFocus) { shadowOf(getMainLooper()).idle(); - checkAllFields(activity, ViewMode.UPDATE_CARD, "store", "note", DateFormat.getDateInstance(DateFormat.LONG).format(new Date()), DateFormat.getDateInstance(DateFormat.LONG).format(new Date()), "10.00", "€", EAN_BARCODE_DATA, null, EAN_BARCODE_TYPE.toString(), null, null); + checkAllFields(activity, ViewMode.UPDATE_CARD, "store", "note", DateTimeUtils.formatLong(Instant.now()), DateTimeUtils.formatLong(Instant.now()), "10.00", "€", EAN_BARCODE_DATA, null, EAN_BARCODE_TYPE.toString(), null, null); database.close(); } @@ -1348,9 +1457,9 @@ public void checkNoBarcodeFullscreenWorkflow() { @Test public void importCard() { - Date date = new Date(); + Instant date = Instant.now(); - Uri importUri = Uri.parse("https://catima.app/share#store%3DExample%2BStore%26note%3D%26validfrom%3D" + date.getTime() + "%26expiry%3D" + date.getTime() + "%26balance%3D10.00%26balancetype%3DUSD%26cardid%3D123456%26barcodetype%3DAZTEC%26headercolor%3D-416706"); + Uri importUri = Uri.parse("https://catima.app/share#store%3DExample%2BStore%26note%3D%26validfrom%3D" + date.toString() + "%26expiry%3D" + date + "%26balance%3D10.00%26balancetype%3DUSD%26cardid%3D123456%26barcodetype%3DAZTEC%26headercolor%3D-416706"); Intent intent = new Intent(); intent.setData(importUri); @@ -1366,7 +1475,7 @@ public void importCard() { shadowOf(getMainLooper()).idle(); - checkAllFields(activity, ViewMode.ADD_CARD, "Example Store", "", DateFormat.getDateInstance(DateFormat.LONG).format(date), DateFormat.getDateInstance(DateFormat.LONG).format(date), "10.00", "$", "123456", context.getString(R.string.sameAsCardId), "Aztec", null, null); + checkAllFields(activity, ViewMode.ADD_CARD, "Example Store", "", DateTimeUtils.formatLong(date), DateTimeUtils.formatLong(date), "10.00", "$", "123456", context.getString(R.string.sameAsCardId), "Aztec", null, null); assertEquals(-416706, ((ColorDrawable) activity.findViewById(R.id.thumbnail).getBackground()).getColor()); } diff --git a/app/src/test/java/protect/card_locker/PkpassTest.kt b/app/src/test/java/protect/card_locker/PkpassTest.kt index 884b85c31f..740790958b 100644 --- a/app/src/test/java/protect/card_locker/PkpassTest.kt +++ b/app/src/test/java/protect/card_locker/PkpassTest.kt @@ -14,7 +14,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.shadows.ShadowContentResolver import org.robolectric.shadows.ShadowLog import java.math.BigDecimal -import java.util.Date +import java.time.Instant @RunWith(RobolectricTestRunner::class) class PkpassTest { @@ -94,7 +94,7 @@ class PkpassTest { "0180 6 320 320 ( 0:00 Uhr - 24:00 Uhr )\n" + "\n" + "(0,20 € pro Anruf aus dem Festnetz der Deutschen Telekom - Mobilfunk maximal 0,60 € pro Anruf).", parsedCard.note) - Assert.assertEquals(Date(1567911600000), parsedCard.validFrom) + Assert.assertEquals(Instant.ofEpochMilli(1567911600000), parsedCard.validFrom) Assert.assertEquals(null, parsedCard.expiry) Assert.assertEquals(BigDecimal(0), parsedCard.balance) Assert.assertEquals(null, parsedCard.balanceType) @@ -160,7 +160,7 @@ class PkpassTest { "\n" + "Eurowings shall not be liable for any items which passengers are prohibited from carrying in their hand baggage for security reasons and are required to surrender at the security checkpoint.\n" + "Contact: https://mobile.eurowings.com/booking/StaticContactInfo.aspx?culture=en-GB&back=home", parsedCard.note) - Assert.assertEquals(Date(1567911600000), parsedCard.validFrom) + Assert.assertEquals(Instant.ofEpochMilli(1567911600000), parsedCard.validFrom) Assert.assertEquals(null, parsedCard.expiry) Assert.assertEquals(BigDecimal(0), parsedCard.balance) Assert.assertEquals(null, parsedCard.balanceType) @@ -257,7 +257,7 @@ class PkpassTest { "0180 6 320 320 ( 0:00 Uhr - 24:00 Uhr )\n" + "\n" + "(0,20 € pro Anruf aus dem Festnetz der Deutschen Telekom - Mobilfunk maximal 0,60 € pro Anruf).", parsedCard.note) - Assert.assertEquals(Date(1567911600000), parsedCard.validFrom) + Assert.assertEquals(Instant.ofEpochMilli(1567911600000), parsedCard.validFrom) Assert.assertEquals(null, parsedCard.expiry) Assert.assertEquals(BigDecimal(0), parsedCard.balance) Assert.assertEquals(null, parsedCard.balanceType) @@ -323,7 +323,7 @@ class PkpassTest { "\n" + "Eurowings shall not be liable for any items which passengers are prohibited from carrying in their hand baggage for security reasons and are required to surrender at the security checkpoint.\n" + "Contact: https://mobile.eurowings.com/booking/StaticContactInfo.aspx?culture=en-GB&back=home", parsedCard.note) - Assert.assertEquals(Date(1567911600000), parsedCard.validFrom) + Assert.assertEquals(Instant.ofEpochMilli(1567911600000), parsedCard.validFrom) Assert.assertEquals(null, parsedCard.expiry) Assert.assertEquals(BigDecimal(0), parsedCard.balance) Assert.assertEquals(null, parsedCard.balanceType) @@ -366,7 +366,7 @@ class PkpassTest { Assert.assertEquals(-1, parsedCard.id) Assert.assertEquals("EUROWINGS", parsedCard.store) Assert.assertEquals("Eurowings Boarding Pass", parsedCard.note) - Assert.assertEquals(Date(1567911600000), parsedCard.validFrom) + Assert.assertEquals(Instant.ofEpochMilli(1567911600000), parsedCard.validFrom) Assert.assertEquals(null, parsedCard.expiry) Assert.assertEquals(BigDecimal(0), parsedCard.balance) Assert.assertEquals(null, parsedCard.balanceType) @@ -388,7 +388,7 @@ class PkpassTest { Assert.assertEquals(-1, parsedCard.id) Assert.assertEquals("EUROWINGS", parsedCard.store) Assert.assertEquals("Eurowings Boarding Pass", parsedCard.note) - Assert.assertEquals(Date(1567911600000), parsedCard.validFrom) + Assert.assertEquals(Instant.ofEpochMilli(1567911600000), parsedCard.validFrom) Assert.assertEquals(null, parsedCard.expiry) Assert.assertEquals(BigDecimal(0), parsedCard.balance) Assert.assertEquals(null, parsedCard.balanceType) @@ -478,7 +478,7 @@ class PkpassTest { "0180 6 320 320 ( 0:00 Uhr - 24:00 Uhr )\n" + "\n" + "(0,20 € pro Anruf aus dem Festnetz der Deutschen Telekom - Mobilfunk maximal 0,60 € pro Anruf).", parsedCard.note) - Assert.assertEquals(Date(1567911600000), parsedCard.validFrom) + Assert.assertEquals(Instant.ofEpochMilli(1567911600000), parsedCard.validFrom) Assert.assertEquals(null, parsedCard.expiry) Assert.assertEquals(BigDecimal(0), parsedCard.balance) Assert.assertEquals(null, parsedCard.balanceType) @@ -544,7 +544,7 @@ class PkpassTest { "\n" + "Eurowings shall not be liable for any items which passengers are prohibited from carrying in their hand baggage for security reasons and are required to surrender at the security checkpoint.\n" + "Contact: https://mobile.eurowings.com/booking/StaticContactInfo.aspx?culture=en-GB&back=home", parsedCard.note) - Assert.assertEquals(Date(1567911600000), parsedCard.validFrom) + Assert.assertEquals(Instant.ofEpochMilli(1567911600000), parsedCard.validFrom) Assert.assertEquals(null, parsedCard.expiry) Assert.assertEquals(BigDecimal(0), parsedCard.balance) Assert.assertEquals(null, parsedCard.balanceType) diff --git a/app/src/test/java/protect/card_locker/TestHelpers.java b/app/src/test/java/protect/card_locker/TestHelpers.java index 1704902a27..c44f21e274 100644 --- a/app/src/test/java/protect/card_locker/TestHelpers.java +++ b/app/src/test/java/protect/card_locker/TestHelpers.java @@ -21,20 +21,23 @@ public static DBHelper getEmptyDb(Context context) { SQLiteDatabase database = db.getWritableDatabase(); // Make sure no files remain - Cursor cursor = DBHelper.getLoyaltyCardCursor(database); - cursor.moveToFirst(); - while (!cursor.isAfterLast()) { - int cardID = cursor.getColumnIndex(DBHelper.LoyaltyCardDbIds.ID); + // Use a try-with-resources block to automatically close the cursor + try (Cursor cursor = DBHelper.getLoyaltyCardCursor(database)) { + // A simpler loop pattern is while(cursor.moveToNext()) + while (cursor.moveToNext()) { + // Note: getColumnIndex is expensive, it's better to get it once before the loop + int cardIDColumnIndex = cursor.getColumnIndex(DBHelper.LoyaltyCardDbIds.ID); + int cardID = cursor.getInt(cardIDColumnIndex); - for (ImageLocationType imageLocationType : ImageLocationType.values()) { - try { - Utils.saveCardImage(context.getApplicationContext(), null, cardID, imageLocationType); - } catch (FileNotFoundException ignored) { + for (ImageLocationType imageLocationType : ImageLocationType.values()) { + try { + // It's generally better to pass the application context + Utils.saveCardImage(context.getApplicationContext(), null, cardID, imageLocationType); + } catch (FileNotFoundException ignored) { + } } } - - cursor.moveToNext(); - } + } // cursor.close() is automatically called here, even if an exception occurs! // Make sure DB is empty database.execSQL("delete from " + DBHelper.LoyaltyCardDbIds.TABLE); @@ -55,7 +58,7 @@ public static void addLoyaltyCards(final SQLiteDatabase mDatabase, final int car 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); + 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); } diff --git a/app/src/test/java/protect/card_locker/contentprovider/CardsContentProviderTest.java b/app/src/test/java/protect/card_locker/contentprovider/CardsContentProviderTest.java index ffe75a6396..1b907dfaa2 100644 --- a/app/src/test/java/protect/card_locker/contentprovider/CardsContentProviderTest.java +++ b/app/src/test/java/protect/card_locker/contentprovider/CardsContentProviderTest.java @@ -24,7 +24,6 @@ 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; @@ -87,8 +86,8 @@ public void testCards() { 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 Instant validFrom = Instant.ofEpochMilli(1687112209000L); + final Instant expiry = Instant.ofEpochMilli(1687112277000L); final BigDecimal balance = new BigDecimal("123.20"); final Currency balanceType = Currency.getInstance("EUR"); final String cardId = "a-card-id"; @@ -126,8 +125,8 @@ public void testCards() { 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 String actualValidFrom = cursor.getString(cursor.getColumnIndexOrThrow("validfrom")); + final String actualExpiry = cursor.getString(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")); @@ -141,8 +140,8 @@ public void testCards() { assertEquals("Id", 1, actualId); assertEquals("Name", store, actualName); assertEquals("Note", note, actualNote); - assertEquals("ValidFrom", validFrom.getTime(), actualValidFrom); - assertEquals("Expiry", expiry.getTime(), actualExpiry); + assertEquals("ValidFrom", validFrom.toString(), actualValidFrom); + assertEquals("Expiry", expiry.toString(), actualExpiry); assertEquals("Balance", balance, actualBalance); assertEquals("BalanceTypeColumn", balanceType.toString(), actualBalanceType); assertEquals("CardId", cardId, actualCardId); diff --git a/app/src/test/res/protect/card_locker/catima_v2.csv b/app/src/test/res/protect/card_locker/catima_v2.csv index 57dbc79da0..5d02a49feb 100644 --- a/app/src/test/res/protect/card_locker/catima_v2.csv +++ b/app/src/test/res/protect/card_locker/catima_v2.csv @@ -6,9 +6,9 @@ Food Fashion _id,store,note,validfrom,expiry,balance,balancetype,cardid,barcodeid,headercolor,barcodetype,starstatus -1,Card 1,Note 1,1601510400,1618053234,100,USD,1234,5432,1,QR_CODE,0, +1,Card 1,Note 1,2020-10-01T00:00:00Z,2021-04-10T11:13:54Z,100,USD,1234,5432,1,QR_CODE,0, 8,Clothes Store,Note about store,,,0,,a,,-5317,,0, -2,Department Store,,,1618041729,0,,A,,-9977996,,0, +2,Department Store,,,2021-04-10T08:02:09Z,0,,A,,-9977996,,0, 3,Grocery Store,"Multiline note about grocery store with blank line",,,150,,dhd,,-9977996,,0,