diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 69a2e77c5..b46765384 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -26,6 +26,9 @@
+
+
+
+
{
+ final Intent intent = new Intent(getContext(), LoyaltyCardsSettingsActivity.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, getDevice());
+ startActivity(intent);
+ return true;
+ });
+ }
+
if (deviceSpecificSettingsCustomizer != null) {
deviceSpecificSettingsCustomizer.customizeSettings(this, prefs);
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsActivity.java
new file mode 100644
index 000000000..1a9bf9686
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsActivity.java
@@ -0,0 +1,118 @@
+/* Copyright (C) 2023 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards;
+
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.ActivityCompat;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+
+public class LoyaltyCardsSettingsActivity extends AbstractGBActivity implements
+ PreferenceFragmentCompat.OnPreferenceStartScreenCallback,
+ ActivityCompat.OnRequestPermissionsResultCallback {
+
+ public static final int PERMISSION_REQUEST_CODE = 0;
+
+ private GBDevice device;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_loyalty_cards);
+ if (savedInstanceState == null) {
+ Fragment fragment = getSupportFragmentManager().findFragmentByTag(LoyaltyCardsSettingsFragment.FRAGMENT_TAG);
+ if (fragment == null) {
+ fragment = LoyaltyCardsSettingsFragment.newInstance(device);
+ }
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.settings_container, fragment, LoyaltyCardsSettingsFragment.FRAGMENT_TAG)
+ .commit();
+ }
+ }
+
+ @Override
+ public boolean onPreferenceStartScreen(final PreferenceFragmentCompat caller, final PreferenceScreen preferenceScreen) {
+ final PreferenceFragmentCompat fragment = LoyaltyCardsSettingsFragment.newInstance(device);
+ final Bundle args = fragment.getArguments();
+ if (args != null) {
+ args.putString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT, preferenceScreen.getKey());
+ fragment.setArguments(args);
+ }
+
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.settings_container, fragment, preferenceScreen.getKey())
+ .addToBackStack(preferenceScreen.getKey())
+ .commit();
+
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ // Simulate a back press, so that we don't actually exit the activity when
+ // in a nested PreferenceScreen
+ this.onBackPressed();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(final int requestCode,
+ @NonNull final String[] permissions,
+ @NonNull final int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+
+ if (requestCode != PERMISSION_REQUEST_CODE) {
+ return;
+ }
+
+ if (grantResults.length == 0) {
+ return;
+ }
+
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ final FragmentManager fragmentManager = getSupportFragmentManager();
+ final Fragment fragment = fragmentManager.findFragmentByTag(LoyaltyCardsSettingsFragment.FRAGMENT_TAG);
+ if (fragment == null) {
+ return;
+ }
+
+ if (fragment instanceof LoyaltyCardsSettingsFragment) {
+ ((LoyaltyCardsSettingsFragment) fragment).reloadPreferences(null);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsConst.java
new file mode 100644
index 000000000..f7c80a0d8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsConst.java
@@ -0,0 +1,37 @@
+/* Copyright (C) 2022 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards;
+
+public final class LoyaltyCardsSettingsConst {
+ public static final String PREF_KEY_LOYALTY_CARDS = "pref_key_loyalty_cards";
+
+ public static final String PREF_KEY_HEADER_LOYALTY_CARDS_CATIMA = "pref_key_header_loyalty_cards_catima";
+ public static final String PREF_KEY_HEADER_LOYALTY_CARDS_SYNC = "pref_key_header_loyalty_cards_sync";
+ public static final String PREF_KEY_HEADER_LOYALTY_CARDS_SYNC_OPTIONS = "pref_key_header_loyalty_cards_sync_options";
+
+ public static final String LOYALTY_CARDS_CATIMA_PACKAGE = "loyalty_cards_catima_package";
+ public static final String LOYALTY_CARDS_OPEN_CATIMA = "loyalty_cards_open_catima";
+ public static final String LOYALTY_CARDS_CATIMA_NOT_INSTALLED = "loyalty_cards_catima_not_installed";
+ public static final String LOYALTY_CARDS_CATIMA_NOT_COMPATIBLE = "loyalty_cards_catima_not_compatible";
+ public static final String LOYALTY_CARDS_INSTALL_CATIMA = "loyalty_cards_install_catima";
+ public static final String LOYALTY_CARDS_CATIMA_PERMISSIONS = "loyalty_cards_catima_permissions";
+ public static final String LOYALTY_CARDS_SYNC = "loyalty_cards_sync";
+ public static final String LOYALTY_CARDS_SYNC_GROUPS_ONLY = "loyalty_cards_sync_groups_only";
+ public static final String LOYALTY_CARDS_SYNC_GROUPS = "loyalty_cards_sync_groups";
+ public static final String LOYALTY_CARDS_SYNC_ARCHIVED = "loyalty_cards_sync_archived";
+ public static final String LOYALTY_CARDS_SYNC_STARRED = "loyalty_cards_sync_starred";
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsFragment.java
new file mode 100644
index 000000000..0b6d46619
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsFragment.java
@@ -0,0 +1,260 @@
+/* Copyright (C) 2022 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_NOT_COMPATIBLE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_NOT_INSTALLED;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_PACKAGE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_PERMISSIONS;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_INSTALL_CATIMA;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_OPEN_CATIMA;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_ARCHIVED;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_GROUPS;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_GROUPS_ONLY;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_STARRED;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.PREF_KEY_HEADER_LOYALTY_CARDS_CATIMA;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.PREF_KEY_HEADER_LOYALTY_CARDS_SYNC;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.PREF_KEY_HEADER_LOYALTY_CARDS_SYNC_OPTIONS;
+
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+import androidx.preference.ListPreference;
+import androidx.preference.MultiSelectListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.SwitchPreference;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.CatimaContentProvider;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.CatimaManager;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public class LoyaltyCardsSettingsFragment extends PreferenceFragmentCompat {
+ private static final Logger LOG = LoggerFactory.getLogger(LoyaltyCardsSettingsFragment.class);
+
+ static final String FRAGMENT_TAG = "LOYALTY_CARDS_SETTINGS_FRAGMENT";
+
+ private GBDevice device;
+
+ private void setSettingsFileSuffix(final String settingsFileSuffix) {
+ Bundle args = new Bundle();
+ args.putString("settingsFileSuffix", settingsFileSuffix);
+ setArguments(args);
+ }
+
+ private void setDevice(final GBDevice device) {
+ final Bundle args = getArguments() != null ? getArguments() : new Bundle();
+ args.putParcelable("device", device);
+ setArguments(args);
+ }
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ final Bundle arguments = getArguments();
+ if (arguments == null) {
+ return;
+ }
+ final String settingsFileSuffix = arguments.getString("settingsFileSuffix", null);
+ this.device = arguments.getParcelable("device");
+
+ if (settingsFileSuffix == null) {
+ return;
+ }
+
+ getPreferenceManager().setSharedPreferencesName("devicesettings_" + settingsFileSuffix);
+ setPreferencesFromResource(R.xml.loyalty_cards, rootKey);
+
+ reloadPreferences(null);
+ }
+
+ static LoyaltyCardsSettingsFragment newInstance(GBDevice device) {
+ final String settingsFileSuffix = device.getAddress();
+ final LoyaltyCardsSettingsFragment fragment = new LoyaltyCardsSettingsFragment();
+ fragment.setSettingsFileSuffix(settingsFileSuffix);
+ fragment.setDevice(device);
+
+ return fragment;
+ }
+
+ protected void reloadPreferences(String catimaPackageName) {
+ final CatimaManager catimaManager = new CatimaManager(requireContext());
+
+ final List installedCatimaPackages = catimaManager.findInstalledCatimaPackages();
+ final boolean catimaInstalled = !installedCatimaPackages.isEmpty();
+
+ final ListPreference catimaPackage = findPreference(LOYALTY_CARDS_CATIMA_PACKAGE);
+ CatimaContentProvider catima = null;
+
+ if (catimaPackage != null) {
+ catimaPackage.setEntries(installedCatimaPackages.toArray(new CharSequence[0]));
+ catimaPackage.setEntryValues(installedCatimaPackages.toArray(new CharSequence[0]));
+ catimaPackage.setOnPreferenceChangeListener((preference, newValue) -> {
+ LOG.info("Catima package changed to {}", newValue);
+ reloadPreferences((String) newValue);
+ return true;
+ });
+
+ if (StringUtils.isNullOrEmpty(catimaPackage.getValue()) || !installedCatimaPackages.contains(catimaPackage.getValue())) {
+ catimaPackage.setValue(installedCatimaPackages.get(0).toString());
+ }
+
+ if (installedCatimaPackages.size() <= 1) {
+ catimaPackage.setVisible(false);
+ }
+
+ catima = new CatimaContentProvider(requireContext(), catimaPackageName != null ? catimaPackageName : catimaPackage.getValue());
+ }
+
+ final Preference openCatima = findPreference(LOYALTY_CARDS_OPEN_CATIMA);
+ if (openCatima != null) {
+ openCatima.setVisible(catimaInstalled);
+ openCatima.setOnPreferenceClickListener(preference -> {
+ if (catimaPackage != null) {
+ final PackageManager packageManager = requireContext().getPackageManager();
+ final Intent launchIntent = packageManager.getLaunchIntentForPackage(catimaPackageName != null ? catimaPackageName : catimaPackage.getValue());
+ if (launchIntent != null) {
+ startActivity(launchIntent);
+ }
+ }
+ return true;
+ });
+ }
+
+ final Preference catimaNotInstalled = findPreference(LOYALTY_CARDS_CATIMA_NOT_INSTALLED);
+ if (catimaNotInstalled != null) {
+ catimaNotInstalled.setVisible(!catimaInstalled);
+ }
+
+ final Preference installCatima = findPreference(LOYALTY_CARDS_INSTALL_CATIMA);
+ if (installCatima != null) {
+ installCatima.setVisible(!catimaInstalled);
+ installCatima.setOnPreferenceClickListener(preference -> {
+ installCatima();
+ return true;
+ });
+ }
+
+ final boolean permissionGranted = ContextCompat.checkSelfPermission(requireContext(), CatimaContentProvider.PERMISSION_READ_CARDS) == PackageManager.PERMISSION_GRANTED;
+ if (catimaInstalled) {
+ final Preference catimaPermissions = findPreference(LOYALTY_CARDS_CATIMA_PERMISSIONS);
+ if (catimaPermissions != null) {
+ catimaPermissions.setVisible(!permissionGranted);
+ catimaPermissions.setOnPreferenceClickListener(preference -> {
+ ActivityCompat.requestPermissions(
+ requireActivity(),
+ new String[]{CatimaContentProvider.PERMISSION_READ_CARDS},
+ LoyaltyCardsSettingsActivity.PERMISSION_REQUEST_CODE
+ );
+ return true;
+ });
+ }
+ }
+
+ final boolean catimaCompatible = catima != null && catima.isCatimaCompatible();
+ final Preference catimaNotCompatible = findPreference(LOYALTY_CARDS_CATIMA_NOT_COMPATIBLE);
+ if (catimaNotCompatible != null) {
+ catimaNotCompatible.setVisible(catimaInstalled && permissionGranted && !catimaCompatible);
+ }
+
+ final Preference sync = findPreference(LOYALTY_CARDS_SYNC);
+ if (sync != null) {
+ sync.setEnabled(catimaInstalled);
+ sync.setOnPreferenceClickListener(preference -> {
+ catimaManager.sync(device);
+ return true;
+ });
+ }
+
+ final PreferenceCategory headerCatima = findPreference(PREF_KEY_HEADER_LOYALTY_CARDS_CATIMA);
+ if (headerCatima != null) {
+ boolean allHidden = true;
+ for (int i = 0; i < headerCatima.getPreferenceCount(); i++) {
+ if (headerCatima.getPreference(i).isVisible()) {
+ allHidden = false;
+ break;
+ }
+ }
+ headerCatima.setVisible(!allHidden);
+ }
+
+ if (catimaInstalled && catimaCompatible && permissionGranted) {
+ final MultiSelectListPreference syncGroups = findPreference(LOYALTY_CARDS_SYNC_GROUPS);
+ if (syncGroups != null) {
+ final List groups = catima.getGroups();
+ final CharSequence[] entries = groups.toArray(new CharSequence[0]);
+ syncGroups.setEntries(entries);
+ syncGroups.setEntryValues(entries);
+
+ // Remove groups that do not exist anymore from the summary
+ final Set values = new HashSet<>(syncGroups.getValues());
+ final Set toRemove = new HashSet<>();
+ for (final String group : values) {
+ if (!groups.contains(group)) {
+ toRemove.add(group);
+ }
+ }
+ values.removeAll(toRemove);
+ syncGroups.setSummary(String.join(", ", values));
+ }
+ }
+
+ final boolean allowSync = catimaInstalled && permissionGranted && catimaCompatible;
+ final PreferenceCategory syncCategory = findPreference(PREF_KEY_HEADER_LOYALTY_CARDS_SYNC);
+ if (syncCategory != null) {
+ for (int i = 0; i < syncCategory.getPreferenceCount(); i++) {
+ syncCategory.getPreference(i).setEnabled(allowSync);
+ }
+ }
+ final PreferenceCategory syncOptionsCategory = findPreference(PREF_KEY_HEADER_LOYALTY_CARDS_SYNC_OPTIONS);
+ if (syncOptionsCategory != null) {
+ for (int i = 0; i < syncOptionsCategory.getPreferenceCount(); i++) {
+ syncOptionsCategory.getPreference(i).setEnabled(allowSync);
+ }
+ }
+ }
+
+ private void installCatima() {
+ try {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=me.hackerchick.catima")));
+ } catch (final ActivityNotFoundException e) {
+ GB.toast(requireContext(), requireContext().getString(R.string.loyalty_cards_install_catima_fail), Toast.LENGTH_LONG, GB.WARN);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/BarcodeFormat.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/BarcodeFormat.java
new file mode 100644
index 000000000..0dd7f7b29
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/BarcodeFormat.java
@@ -0,0 +1,36 @@
+/* Copyright (C) 2023 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards;
+
+/**
+ * Matching com.google.zxing.BarcodeFormat
+ */
+public enum BarcodeFormat {
+ AZTEC,
+ CODE_39,
+ CODE_93,
+ CODE_128,
+ CODABAR,
+ DATA_MATRIX,
+ EAN_8,
+ EAN_13,
+ ITF,
+ PDF_417,
+ QR_CODE,
+ UPC_A,
+ UPC_E,
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaContentProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaContentProvider.java
new file mode 100644
index 000000000..3828dfe9d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaContentProvider.java
@@ -0,0 +1,260 @@
+/* Copyright (C) 2023 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Currency;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public class CatimaContentProvider {
+ private static final Logger LOG = LoggerFactory.getLogger(CatimaContentProvider.class);
+
+ public static final List KNOWN_PACKAGES = new ArrayList() {{
+ add("me.hackerchick.catima");
+ add("me.hackerchick.catima.debug");
+ }};
+
+ public static final String PERMISSION_READ_CARDS = "me.hackerchick.catima.READ_CARDS";
+
+ private final Context mContext;
+ private final Uri versionUri;
+ private final Uri cardsUri;
+ private final Uri groupsUri;
+ private final Uri cardGroupsUri;
+
+ public CatimaContentProvider(final Context context, final String catimaPackageName) {
+ this.mContext = context;
+ final String catimaAuthority = catimaPackageName + ".contentprovider.cards";
+ this.versionUri = Uri.parse(String.format(Locale.ROOT, "content://%s/version", catimaAuthority));
+ this.cardsUri = Uri.parse(String.format(Locale.ROOT, "content://%s/cards", catimaAuthority));
+ this.groupsUri = Uri.parse(String.format(Locale.ROOT, "content://%s/groups", catimaAuthority));
+ this.cardGroupsUri = Uri.parse(String.format(Locale.ROOT, "content://%s/card_groups", catimaAuthority));
+ }
+
+ public boolean isCatimaCompatible() {
+ final ContentResolver contentResolver = mContext.getContentResolver();
+ try (Cursor cursor = contentResolver.query(versionUri, null, null, null, null)) {
+ if (cursor == null || cursor.getCount() == 0) {
+ LOG.warn("Catima content provider version not found");
+ return false;
+ }
+
+ cursor.moveToNext();
+ final int major = cursor.getInt(cursor.getColumnIndexOrThrow("major"));
+ final int minor = cursor.getInt(cursor.getColumnIndexOrThrow("minor"));
+
+ LOG.info("Got catima content provider version: {}.{}", major, minor);
+
+ // We only support version 1.x for now
+ return major == 1;
+ } catch (final Exception e) {
+ LOG.error("Failed to get content provider version from Catima", e);
+ }
+
+ return false;
+ }
+
+ public List getCards() {
+ final List cards = new ArrayList<>();
+ final ContentResolver contentResolver = mContext.getContentResolver();
+ try (Cursor cursor = contentResolver.query(cardsUri, null, null, null, null)) {
+ if (cursor == null || cursor.getCount() == 0) {
+ LOG.debug("No cards found");
+ return cards;
+ }
+
+ while (cursor.moveToNext()) {
+ final LoyaltyCard loyaltyCard = toLoyaltyCard(cursor);
+ cards.add(loyaltyCard);
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to list cards from Catima", e);
+ return cards;
+ }
+
+ return cards;
+ }
+
+ public List getGroups() {
+ final List groups = new ArrayList<>();
+ final ContentResolver contentResolver = mContext.getContentResolver();
+ try (Cursor cursor = contentResolver.query(groupsUri, null, null, null, null)) {
+ if (cursor == null || cursor.getCount() == 0) {
+ LOG.debug("No groups found");
+ return groups;
+ }
+
+ while (cursor.moveToNext()) {
+ final String groupId = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbGroups.ID));
+ groups.add(groupId);
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to list groups from Catima", e);
+ return groups;
+ }
+
+ return groups;
+ }
+
+ /**
+ * Gets the mapping of group to list of card IDs.
+ *
+ * @return the mapping of group to list of card IDS.
+ */
+ public Map> getGroupCards() {
+ final Map> groupCards = new HashMap<>();
+ final ContentResolver contentResolver = mContext.getContentResolver();
+ try (Cursor cursor = contentResolver.query(cardGroupsUri, null, null, null, null)) {
+ if (cursor == null || cursor.getCount() == 0) {
+ LOG.debug("No card groups found");
+ return groupCards;
+ }
+
+ while (cursor.moveToNext()) {
+ final int cardId = cursor.getInt(cursor.getColumnIndexOrThrow(LoyaltyCardDbIdsGroups.cardID));
+ final String groupId = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIdsGroups.groupID));
+ final List group;
+ if (groupCards.containsKey(groupId)) {
+ group = groupCards.get(groupId);
+ } else {
+ group = new ArrayList<>();
+ groupCards.put(groupId, group);
+ }
+ group.add(cardId);
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to get group cards from Catima", e);
+ return groupCards;
+ }
+
+ return groupCards;
+ }
+
+ public static LoyaltyCard toLoyaltyCard(final Cursor cursor) {
+ final int id = cursor.getInt(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.ID));
+ final String name = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.STORE));
+ final String note = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.NOTE));
+ final long expiryLong = cursor.getLong(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.EXPIRY));
+ final BigDecimal balance = new BigDecimal(cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.BALANCE)));
+ final String cardId = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.CARD_ID));
+ final String barcodeId = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.BARCODE_ID));
+ final int starred = cursor.getInt(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.STAR_STATUS));
+ final long lastUsed = cursor.getLong(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.LAST_USED));
+ final int archiveStatus = cursor.getInt(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.ARCHIVE_STATUS));
+
+ int barcodeTypeColumn = cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.BARCODE_TYPE);
+ int balanceTypeColumn = cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.BALANCE_TYPE);
+ int headerColorColumn = cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.HEADER_COLOR);
+
+ BarcodeFormat barcodeFormat = null;
+ Currency balanceType = null;
+ Date expiry = null;
+ Integer headerColor = null;
+
+ if (!cursor.isNull(barcodeTypeColumn)) {
+ try {
+ barcodeFormat = BarcodeFormat.valueOf(cursor.getString(barcodeTypeColumn));
+ } catch (final IllegalArgumentException e) {
+ LOG.error("Unknown barcode format {}", barcodeTypeColumn);
+ }
+ }
+
+ if (!cursor.isNull(balanceTypeColumn)) {
+ balanceType = Currency.getInstance(cursor.getString(balanceTypeColumn));
+ }
+
+ if (expiryLong > 0) {
+ expiry = new Date(expiryLong);
+ }
+
+ if (!cursor.isNull(headerColorColumn)) {
+ headerColor = cursor.getInt(headerColorColumn);
+ }
+
+ return new LoyaltyCard(
+ id,
+ name,
+ note,
+ expiry,
+ balance,
+ balanceType,
+ cardId,
+ barcodeId,
+ barcodeFormat,
+ headerColor,
+ starred != 0,
+ archiveStatus != 0,
+ lastUsed
+ );
+ }
+
+ /**
+ * Copied from Catima, protect.card_locker.DBHelper.LoyaltyCardDbGroups.
+ * Commit: 8607e1c2
+ */
+ public static class LoyaltyCardDbGroups {
+ public static final String TABLE = "groups";
+ public static final String ID = "_id";
+ public static final String ORDER = "orderId";
+ }
+
+ /**
+ * Copied from Catima, protect.card_locker.DBHelper.LoyaltyCardDbIds.
+ * Commit: 8607e1c2
+ */
+ public static class LoyaltyCardDbIds {
+ public static final String TABLE = "cards";
+ public static final String ID = "_id";
+ public static final String STORE = "store";
+ public static final String EXPIRY = "expiry";
+ public static final String BALANCE = "balance";
+ public static final String BALANCE_TYPE = "balancetype";
+ public static final String NOTE = "note";
+ public static final String HEADER_COLOR = "headercolor";
+ public static final String HEADER_TEXT_COLOR = "headertextcolor";
+ public static final String CARD_ID = "cardid";
+ public static final String BARCODE_ID = "barcodeid";
+ public static final String BARCODE_TYPE = "barcodetype";
+ public static final String STAR_STATUS = "starstatus";
+ public static final String LAST_USED = "lastused";
+ public static final String ZOOM_LEVEL = "zoomlevel";
+ public static final String ARCHIVE_STATUS = "archive";
+ }
+
+ /**
+ * Copied from Catima, protect.card_locker.DBHelper.LoyaltyCardDbIdsGroups.
+ * Commit: 8607e1c2
+ */
+ public static class LoyaltyCardDbIdsGroups {
+ public static final String TABLE = "cardsGroups";
+ public static final String cardID = "cardId";
+ public static final String groupID = "groupId";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaManager.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaManager.java
new file mode 100644
index 000000000..63e9f286f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaManager.java
@@ -0,0 +1,128 @@
+/* Copyright (C) 2023 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_PACKAGE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_ARCHIVED;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_GROUPS;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_GROUPS_ONLY;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_STARRED;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.widget.Toast;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class CatimaManager {
+ private static final Logger LOG = LoggerFactory.getLogger(CatimaManager.class);
+
+ private final Context context;
+
+ public CatimaManager(final Context context) {
+ this.context = context;
+ }
+
+ public void sync(final GBDevice gbDevice) {
+ final List installedCatimaPackages = findInstalledCatimaPackages();
+ if (installedCatimaPackages.isEmpty()) {
+ LOG.warn("Catima is not installed");
+ return;
+ }
+
+ final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
+
+ final boolean syncGroupsOnly = prefs.getBoolean(LOYALTY_CARDS_SYNC_GROUPS_ONLY, false);
+ final Set syncGroups = prefs.getStringSet(LOYALTY_CARDS_SYNC_GROUPS, Collections.emptySet());
+ final boolean syncArchived = prefs.getBoolean(LOYALTY_CARDS_SYNC_ARCHIVED, false);
+ final boolean syncStarred = prefs.getBoolean(LOYALTY_CARDS_SYNC_STARRED, false);
+
+ final String catimaPackage = prefs.getString(LOYALTY_CARDS_CATIMA_PACKAGE, installedCatimaPackages.get(0).toString());
+ final CatimaContentProvider catima = new CatimaContentProvider(context, catimaPackage);
+
+ if (!catima.isCatimaCompatible()) {
+ LOG.warn("Catima is not compatible");
+ return;
+ }
+
+ final List cards = catima.getCards();
+ final Map> groupCards = catima.getGroupCards();
+
+ final Set cardsInGroupsToSync = new HashSet<>();
+ if (syncGroupsOnly) {
+ for (final Map.Entry> groupCardsEntry : groupCards.entrySet()) {
+ if (syncGroups.contains(groupCardsEntry.getKey())) {
+ cardsInGroupsToSync.addAll(groupCardsEntry.getValue());
+ }
+ }
+ }
+
+ final ArrayList cardsToSync = new ArrayList<>();
+ for (final LoyaltyCard card : cards) {
+ if (syncGroupsOnly && !cardsInGroupsToSync.contains(card.getId())) {
+ continue;
+ }
+ if (!syncArchived && card.isArchived()) {
+ continue;
+ }
+ if (syncStarred && !card.isStarred()) {
+ continue;
+ }
+ cardsToSync.add(card);
+ }
+
+ Collections.sort(cardsToSync);
+
+ LOG.debug("Will sync cards: {}", cardsToSync);
+
+ GB.toast(context, context.getString(R.string.loyalty_cards_syncing, cardsToSync.size()), Toast.LENGTH_LONG, GB.INFO);
+
+ GBApplication.deviceService(gbDevice).onSetLoyaltyCards(cardsToSync);
+ }
+
+ public List findInstalledCatimaPackages() {
+ final List installedCatimaPackages = new ArrayList<>();
+ for (final String knownPackage : CatimaContentProvider.KNOWN_PACKAGES) {
+ if (isPackageInstalled(knownPackage)) {
+ installedCatimaPackages.add(knownPackage);
+ }
+ }
+ return installedCatimaPackages;
+ }
+
+ private boolean isPackageInstalled(final String packageName) {
+ try {
+ return context.getPackageManager().getApplicationInfo(packageName, 0).enabled;
+ } catch (final PackageManager.NameNotFoundException e) {
+ return false;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/LoyaltyCard.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/LoyaltyCard.java
new file mode 100644
index 000000000..8fe79487a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/LoyaltyCard.java
@@ -0,0 +1,153 @@
+/* Copyright (C) 2023 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards;
+
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.builder.CompareToBuilder;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Currency;
+import java.util.Date;
+import java.util.Locale;
+
+public class LoyaltyCard implements Serializable, Comparable {
+
+ private final int id;
+ private final String name;
+ private final String note;
+ private final Date expiry;
+ private final BigDecimal balance;
+ private final Currency balanceType;
+ private final String cardId;
+
+ @Nullable
+ private final String barcodeId;
+
+ @Nullable
+ private final BarcodeFormat barcodeFormat;
+
+ @Nullable
+ private final Integer color;
+
+ private final boolean starred;
+ private final boolean archived;
+ private final long lastUsed;
+
+ public LoyaltyCard(final int id,
+ final String name,
+ final String note,
+ final Date expiry,
+ final BigDecimal balance,
+ final Currency balanceType,
+ final String cardId,
+ @Nullable final String barcodeId,
+ @Nullable final BarcodeFormat barcodeFormat,
+ @Nullable final Integer color,
+ final boolean starred,
+ final boolean archived,
+ final long lastUsed) {
+ this.id = id;
+ this.name = name;
+ this.note = note;
+ this.expiry = expiry;
+ this.balance = balance;
+ this.balanceType = balanceType;
+ this.cardId = cardId;
+ this.barcodeId = barcodeId;
+ this.barcodeFormat = barcodeFormat;
+ this.color = color;
+ this.starred = starred;
+ this.archived = archived;
+ this.lastUsed = lastUsed;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getNote() {
+ return note;
+ }
+
+ public Date getExpiry() {
+ return expiry;
+ }
+
+ public BigDecimal getBalance() {
+ return balance;
+ }
+
+ public Currency getBalanceType() {
+ return balanceType;
+ }
+
+ public String getCardId() {
+ return cardId;
+ }
+
+ @Nullable
+ public String getBarcodeId() {
+ return barcodeId;
+ }
+
+ @Nullable
+ public BarcodeFormat getBarcodeFormat() {
+ return barcodeFormat;
+ }
+
+ @Nullable
+ public Integer getColor() {
+ return color;
+ }
+
+ public boolean isStarred() {
+ return starred;
+ }
+
+ public boolean isArchived() {
+ return archived;
+ }
+
+ public long getLastUsed() {
+ return lastUsed;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ Locale.ROOT,
+ "LoyaltyCard{id=%s, name=%s, cardId=%s}",
+ id, name, cardId
+ );
+ }
+
+ @Override
+ public int compareTo(final LoyaltyCard o) {
+ return new CompareToBuilder()
+ .append(isStarred(), o.isStarred())
+ .append(isArchived(), o.isArchived())
+ .append(getName(), o.getName())
+ .append(getCardId(), o.getCardId())
+ .build();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java
index 164919765..05b00847f 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java
@@ -24,6 +24,7 @@ import android.net.Uri;
import java.util.ArrayList;
import java.util.UUID;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
@@ -53,6 +54,8 @@ public interface EventHandler {
void onSetReminders(ArrayList extends Reminder> reminders);
+ void onSetLoyaltyCards(ArrayList cards);
+
void onSetWorldClocks(ArrayList extends WorldClock> clocks);
void onSetContacts(ArrayList extends Contact> contacts);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java
index 551c57ef4..a22ef0fb4 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java
@@ -52,6 +52,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuami2
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAlexaService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsContactsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLogsService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLoyaltyCardService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsRemindersService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsShortcutCardsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService;
@@ -277,6 +278,15 @@ public abstract class Huami2021Coordinator extends HuamiCoordinator {
public int[] getSupportedDeviceSpecificSettings(final GBDevice device) {
final List settings = new ArrayList<>();
+ //
+ // Apps
+ // TODO: These should go somewhere else
+ //
+ settings.add(R.xml.devicesettings_header_apps);
+ if (ZeppOsLoyaltyCardService.isSupported(getPrefs(device))) {
+ settings.add(R.xml.devicesettings_loyalty_cards);
+ }
+
//
// Time
//
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021SettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021SettingsCustomizer.java
index 315936d75..c4f5fa237 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021SettingsCustomizer.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021SettingsCustomizer.java
@@ -42,6 +42,7 @@ import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst;
import nodomain.freeyourgadget.gadgetbridge.capabilities.GpsCapability;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType;
@@ -199,6 +200,9 @@ public class Huami2021SettingsCustomizer extends HuamiSettingsCustomizer {
}
// Hides the headers if none of the preferences under them are available
+ hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_APPS, Arrays.asList(
+ LoyaltyCardsSettingsConst.PREF_KEY_LOYALTY_CARDS
+ ));
hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_TIME, Arrays.asList(
DeviceSettingsPreferenceConst.PREF_TIMEFORMAT,
DeviceSettingsPreferenceConst.PREF_DATEFORMAT,
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java
index 1e69b1736..c9637a250 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java
@@ -25,16 +25,15 @@ import android.content.Intent;
import android.database.Cursor;
import android.location.Location;
import android.net.Uri;
-import android.os.Build;
import android.os.Parcelable;
import android.provider.ContactsContract;
import java.util.ArrayList;
import java.util.UUID;
-import androidx.annotation.Nullable;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
@@ -263,6 +262,13 @@ public class GBDeviceService implements DeviceService {
invokeService(intent);
}
+ @Override
+ public void onSetLoyaltyCards(final ArrayList cards) {
+ final Intent intent = createIntent().setAction(ACTION_SET_LOYALTY_CARDS)
+ .putExtra(EXTRA_LOYALTY_CARDS, cards);
+ invokeService(intent);
+ }
+
@Override
public void onSetWorldClocks(ArrayList extends WorldClock> clocks) {
Intent intent = createIntent().setAction(ACTION_SET_WORLD_CLOCKS)
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java
index 02a530ced..e9a15373f 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java
@@ -60,6 +60,7 @@ public interface DeviceService extends EventHandler {
String ACTION_SET_ALARMS = PREFIX + ".action.set_alarms";
String ACTION_SAVE_ALARMS = PREFIX + ".action.save_alarms";
String ACTION_SET_REMINDERS = PREFIX + ".action.set_reminders";
+ String ACTION_SET_LOYALTY_CARDS = PREFIX + ".action.set_loyalty_cards";
String ACTION_SET_WORLD_CLOCKS = PREFIX + ".action.set_world_clocks";
String ACTION_SET_CONTACTS = PREFIX + ".action.set_contacts";
String ACTION_ENABLE_REALTIME_STEPS = PREFIX + ".action.enable_realtime_steps";
@@ -123,6 +124,7 @@ public interface DeviceService extends EventHandler {
String EXTRA_CONFIG = "config";
String EXTRA_ALARMS = "alarms";
String EXTRA_REMINDERS = "reminders";
+ String EXTRA_LOYALTY_CARDS = "loyalty_cards";
String EXTRA_WORLD_CLOCKS = "world_clocks";
String EXTRA_CONTACTS = "contacts";
String EXTRA_CONNECT_FIRST_TIME = "connect_first_time";
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java
index aba4142b1..aa0eb8a07 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java
@@ -56,6 +56,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.FindPhoneActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AbstractAppManagerFragment;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
@@ -637,6 +638,16 @@ public abstract class AbstractDeviceSupport implements DeviceSupport {
}
+ /**
+ * If loyalty cards can be set on the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param cards {@link java.util.ArrayList} containing {@link LoyaltyCard} instances
+ */
+ @Override
+ public void onSetLoyaltyCards(ArrayList cards) {
+
+ }
+
/**
* If world clocks can be configured on the device, this method can be
* overridden and implemented by the device support class.
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java
index 95a069557..e5d9c98bb 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java
@@ -53,6 +53,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.externalevents.AlarmClockReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.AlarmReceiver;
@@ -135,6 +136,7 @@ import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SE
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_HEARTRATE_MEASUREMENT_INTERVAL;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_GPS_LOCATION;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_LED_COLOR;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_LOYALTY_CARDS;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_PHONE_VOLUME;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_REMINDERS;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_SET_WORLD_CLOCKS;
@@ -172,6 +174,7 @@ import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_FM_
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_GPS_LOCATION;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_INTERVAL_SECONDS;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_LED_COLOR;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_LOYALTY_CARDS;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_MUSIC_ALBUM;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_MUSIC_ARTIST;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.EXTRA_MUSIC_DURATION;
@@ -946,6 +949,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
ArrayList extends Reminder> reminders = (ArrayList extends Reminder>) intent.getSerializableExtra(EXTRA_REMINDERS);
deviceSupport.onSetReminders(reminders);
break;
+ case ACTION_SET_LOYALTY_CARDS:
+ final ArrayList loyaltyCards = (ArrayList) intent.getSerializableExtra(EXTRA_LOYALTY_CARDS);
+ deviceSupport.onSetLoyaltyCards(loyaltyCards);
+ break;
case ACTION_SET_WORLD_CLOCKS:
ArrayList extends WorldClock> clocks = (ArrayList extends WorldClock>) intent.getSerializableExtra(EXTRA_WORLD_CLOCKS);
deviceSupport.onSetWorldClocks(clocks);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java
index ab435381c..85b4219e6 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java
@@ -31,6 +31,7 @@ import java.util.Arrays;
import java.util.EnumSet;
import java.util.UUID;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
@@ -373,6 +374,13 @@ public class ServiceDeviceSupport implements DeviceSupport {
delegate.onSetContacts(contacts);
}
+ public void onSetLoyaltyCards(final ArrayList cards) {
+ if (checkBusy("set loyalty cards")) {
+ return;
+ }
+ delegate.onSetLoyaltyCards(cards);
+ }
+
@Override
public void onSetWorldClocks(ArrayList extends WorldClock> clocks) {
if (checkBusy("set world clocks")) {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Support.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Support.java
index d4911f660..c4536eeb4 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Support.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Support.java
@@ -75,6 +75,7 @@ import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventAppInfo;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventScreenshot;
@@ -121,6 +122,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsDisplayItemsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsHttpService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLogsService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLoyaltyCardService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsNotificationService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsRemindersService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsServicesService;
@@ -170,6 +172,7 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil
private final ZeppOsDisplayItemsService displayItemsService = new ZeppOsDisplayItemsService(this);
private final ZeppOsHttpService httpService = new ZeppOsHttpService(this);
private final ZeppOsRemindersService remindersService = new ZeppOsRemindersService(this);
+ private final ZeppOsLoyaltyCardService loyaltyCardService = new ZeppOsLoyaltyCardService(this);
private final Map mServiceMap = new LinkedHashMap() {{
put(servicesService.getEndpoint(), servicesService);
@@ -193,6 +196,7 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil
put(displayItemsService.getEndpoint(), displayItemsService);
put(httpService.getEndpoint(), httpService);
put(remindersService.getEndpoint(), remindersService);
+ put(loyaltyCardService.getEndpoint(), loyaltyCardService);
}};
public Huami2021Support() {
@@ -514,6 +518,11 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil
}
}
+ @Override
+ public void onSetLoyaltyCards(final ArrayList cards) {
+ loyaltyCardService.setCards(cards);
+ }
+
@Override
public void onSetContacts(ArrayList extends Contact> contacts) {
contactsService.setContacts((List) contacts);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsLoyaltyCardService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsLoyaltyCardService.java
new file mode 100644
index 000000000..2f9170750
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsLoyaltyCardService.java
@@ -0,0 +1,266 @@
+/* Copyright (C) 2023 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.BarcodeFormat;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Support;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
+import nodomain.freeyourgadget.gadgetbridge.util.MapUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class ZeppOsLoyaltyCardService extends AbstractZeppOsService {
+ private static final Logger LOG = LoggerFactory.getLogger(ZeppOsLoyaltyCardService.class);
+
+ private static final short ENDPOINT = 0x003c;
+
+ private static final byte CMD_CAPABILITIES_REQUEST = 0x01;
+ private static final byte CMD_CAPABILITIES_RESPONSE = 0x02;
+ private static final byte CMD_REQUEST = 0x05;
+ private static final byte CMD_RESPONSE = 0x06;
+ private static final byte CMD_SET = 0x03;
+ private static final byte CMD_SET_ACK = 0x04;
+ private static final byte CMD_UPDATE = 0x07;
+ private static final byte CMD_UPDATE_ACK = 0x08;
+ private static final byte CMD_ADD = 0x09;
+ private static final byte CMD_ADD_ACK = 0x0a;
+
+ private final List supportedFormats = new ArrayList<>();
+ private final List supportedColors = new ArrayList<>();
+
+ public static final String PREF_VERSION = "zepp_os_loyalty_cards_version";
+
+ public ZeppOsLoyaltyCardService(final Huami2021Support support) {
+ super(support);
+ }
+
+ @Override
+ public short getEndpoint() {
+ return ENDPOINT;
+ }
+
+ @Override
+ public boolean isEncrypted() {
+ return false;
+ }
+
+ @Override
+ public void handlePayload(final byte[] payload) {
+ switch (payload[0]) {
+ case CMD_CAPABILITIES_RESPONSE:
+ LOG.info("Loyalty cards capabilities, version1={}, version2={}", payload[1], payload[2]);
+
+ supportedFormats.clear();
+ supportedColors.clear();
+ int version = payload[1];
+
+ if (version != 1 || payload[2] != 1) {
+ LOG.warn("Unexpected loyalty cards service version");
+ return;
+ }
+
+ int pos = 3;
+
+ final byte numSupportedCardTypes = payload[pos++];
+ final Map barcodeFormatCodes = MapUtils.reverse(BARCODE_FORMAT_CODES);
+ for (int i = 0; i < numSupportedCardTypes; i++, pos++) {
+ final BarcodeFormat barcodeFormat = barcodeFormatCodes.get(payload[pos]);
+ if (barcodeFormat == null) {
+ LOG.warn("Unknown barcode format {}", String.format("0x%02x", payload[pos]));
+ continue;
+ }
+ supportedFormats.add(barcodeFormat);
+ }
+
+ final byte numSupportedColors = payload[pos++];
+ final Map colorCodes = MapUtils.reverse(COLOR_CODES);
+ for (int i = 0; i < numSupportedColors; i++) {
+ final Integer color = colorCodes.get(payload[pos]);
+ if (color == null) {
+ LOG.warn("Unknown color {}", String.format("0x%02x", payload[pos]));
+ continue;
+ }
+ supportedColors.add(color);
+ }
+
+ getSupport().evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(PREF_VERSION, version));
+ return;
+ case CMD_SET_ACK:
+ LOG.info("Loyalty cards set ACK, status = {}", payload[1]);
+ return;
+ }
+
+ LOG.warn("Unexpected loyalty cards byte {}", String.format("0x%02x", payload[0]));
+ }
+
+ @Override
+ public void initialize(final TransactionBuilder builder) {
+ requestCapabilities(builder);
+ }
+
+ public boolean isSupported() {
+ return !supportedFormats.isEmpty() && !supportedColors.isEmpty();
+ }
+
+ public List getSupportedFormats() {
+ return supportedFormats;
+ }
+
+ public void requestCapabilities(final TransactionBuilder builder) {
+ write(builder, CMD_CAPABILITIES_REQUEST);
+ }
+
+ public void setCards(final List cards) {
+ LOG.info("Setting {} loyalty cards", cards.size());
+
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+
+ final List supportedCards = filterSupportedCards(cards);
+
+ baos.write(CMD_SET);
+ baos.write(supportedCards.size());
+
+ for (final LoyaltyCard card : supportedCards) {
+ try {
+ baos.write(encodeCard(card));
+ } catch (final Exception e) {
+ LOG.error("Failed to encode card", e);
+ return;
+ }
+ }
+
+ write("set loyalty cards", baos.toByteArray());
+ }
+
+ private List filterSupportedCards(final List cards) {
+ final List ret = new ArrayList<>();
+
+ for (final LoyaltyCard card : cards) {
+ if (supportedFormats.contains(card.getBarcodeFormat())) {
+ ret.add(card);
+ }
+ }
+
+ return ret;
+ }
+
+ private byte[] encodeCard(final LoyaltyCard card) {
+ final Byte barcodeFormatCode = BARCODE_FORMAT_CODES.get(card.getBarcodeFormat());
+ if (barcodeFormatCode == null) {
+ LOG.error("Unsupported barcode format {}", card.getBarcodeFormat());
+ return null;
+ }
+
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+
+ try {
+ baos.write(card.getName().getBytes(StandardCharsets.UTF_8));
+ baos.write(0);
+
+ // This is optional
+ baos.write(card.getCardId().getBytes(StandardCharsets.UTF_8));
+ baos.write(0);
+
+ if (card.getBarcodeId() != null) {
+ baos.write(card.getBarcodeId().getBytes(StandardCharsets.UTF_8));
+ } else {
+ baos.write(card.getCardId().getBytes(StandardCharsets.UTF_8));
+ }
+ baos.write(0);
+
+ baos.write(barcodeFormatCode);
+ if (card.getColor() != null) {
+ baos.write(findNearestColorCode(card.getColor()));
+ } else {
+ baos.write(0x00);
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to encode card", e);
+ return null;
+ }
+
+ return baos.toByteArray();
+ }
+
+ private byte findNearestColorCode(final int color) {
+ final double r = ((color >> 16) & 0xff) / 255f;
+ final double g = ((color >> 8) & 0xff) / 255f;
+ final double b = (color & 0xff) / 255f;
+
+ int nearestColor = 0x66c6ea;
+ double minDistance = Float.MAX_VALUE;
+
+ // TODO better color distance algorithm?
+ for (final Integer colorPreset : COLOR_CODES.keySet()) {
+ final double rPreset = ((colorPreset >> 16) & 0xff) / 255f;
+ final double gPreset = ((colorPreset >> 8) & 0xff) / 255f;
+ final double bPreset = (colorPreset & 0xff) / 255f;
+
+ final double distance = Math.sqrt(Math.pow(rPreset - r, 2) + Math.pow(gPreset - g, 2) + Math.pow(bPreset - b, 2));
+ if (distance < minDistance) {
+ nearestColor = colorPreset;
+ minDistance = distance;
+ }
+ }
+
+ return Objects.requireNonNull(COLOR_CODES.get(nearestColor));
+ }
+
+ private static final Map BARCODE_FORMAT_CODES = new HashMap() {{
+ put(BarcodeFormat.CODE_128, (byte) 0x00);
+ put(BarcodeFormat.CODE_39, (byte) 0x01);
+ put(BarcodeFormat.QR_CODE, (byte) 0x03);
+ put(BarcodeFormat.UPC_A, (byte) 0x06);
+ put(BarcodeFormat.EAN_13, (byte) 0x07);
+ put(BarcodeFormat.EAN_8, (byte) 0x08);
+ }};
+
+ /**
+ * Map or RGB color to color byte - the watches only support color presets.
+ */
+ private static final Map COLOR_CODES = new HashMap() {{
+ put(0x66c6ea, (byte) 0x00); // Light blue
+ put(0x008fc5, (byte) 0x01); // Blue
+ put(0xc19ffd, (byte) 0x02); // Light purple
+ put(0x8855e2, (byte) 0x03); // Purple
+ put(0xfb8e89, (byte) 0x04); // Light red
+ put(0xdf3b34, (byte) 0x05); // Red
+ put(0xffab03, (byte) 0x06); // Orange
+ put(0xffaa77, (byte) 0x07); // Light Orange
+ put(0xe75800, (byte) 0x08); // Dark Orange
+ put(0x66d0b8, (byte) 0x09); // Light green
+ put(0x009e7a, (byte) 0x0a); // Green
+ put(0xffcd68, (byte) 0x0b); // Yellow-ish
+ }};
+
+ public static boolean isSupported(final Prefs devicePrefs) {
+ return devicePrefs.getInt(PREF_VERSION, 0) == 1;
+ }
+}
diff --git a/app/src/main/res/drawable/ic_archive.xml b/app/src/main/res/drawable/ic_archive.xml
new file mode 100644
index 000000000..ace6dc2ac
--- /dev/null
+++ b/app/src/main/res/drawable/ic_archive.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_loyalty.xml b/app/src/main/res/drawable/ic_loyalty.xml
new file mode 100644
index 000000000..c0e3ff2b5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_loyalty.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_loyalty_cards.xml b/app/src/main/res/layout/activity_loyalty_cards.xml
new file mode 100644
index 000000000..c99658e68
--- /dev/null
+++ b/app/src/main/res/layout/activity_loyalty_cards.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7b6e434ba..da59a68b7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2155,4 +2155,23 @@
More…
OK
What\'s New
+ Catima package name
+ Install Catima
+ Sync only specific groups
+ Groups to Sync
+ Sync archived cards
+ Failed to open app store to install Catima
+ Sync only starred cards
+ Sync Loyalty Cards
+ Tap to sync the cards to the watch
+ Catima is needed to manage the loyalty cards
+ The installed Catima version is not compatible with Gadgetbridge. Please update Catima and Gadgetbridge to the latest versions.
+ Sync Options
+ Sync
+ Catima
+ Open Catima
+ Missing permissions
+ Gadgetbridge needs read permissions on Catima cards to sync them. Tap this button to grant them.
+ Loyalty Cards
+ Syncing %d loyalty cards to device
diff --git a/app/src/main/res/xml/devicesettings_header_apps.xml b/app/src/main/res/xml/devicesettings_header_apps.xml
new file mode 100644
index 000000000..c93fe8491
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_header_apps.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/main/res/xml/devicesettings_loyalty_cards.xml b/app/src/main/res/xml/devicesettings_loyalty_cards.xml
new file mode 100644
index 000000000..e391dddc2
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_loyalty_cards.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/xml/loyalty_cards.xml b/app/src/main/res/xml/loyalty_cards.xml
new file mode 100644
index 000000000..631e9b042
--- /dev/null
+++ b/app/src/main/res/xml/loyalty_cards.xml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+