From fea3bf50a4747e4be21daf7deaa5f1ea8f9c8936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Mon, 3 Jul 2023 23:19:19 +0100 Subject: [PATCH] Zepp OS: Add loyalty cards integration with Catima --- app/src/main/AndroidManifest.xml | 7 + .../DeviceSettingsPreferenceConst.java | 1 + .../DeviceSpecificSettingsFragment.java | 12 + .../LoyaltyCardsSettingsActivity.java | 118 ++++++++ .../LoyaltyCardsSettingsConst.java | 37 +++ .../LoyaltyCardsSettingsFragment.java | 260 +++++++++++++++++ .../loyaltycards/BarcodeFormat.java | 36 +++ .../loyaltycards/CatimaContentProvider.java | 260 +++++++++++++++++ .../loyaltycards/CatimaManager.java | 128 +++++++++ .../loyaltycards/LoyaltyCard.java | 153 ++++++++++ .../gadgetbridge/devices/EventHandler.java | 3 + .../devices/huami/Huami2021Coordinator.java | 10 + .../huami/Huami2021SettingsCustomizer.java | 4 + .../gadgetbridge/impl/GBDeviceService.java | 10 +- .../gadgetbridge/model/DeviceService.java | 2 + .../service/AbstractDeviceSupport.java | 11 + .../service/DeviceCommunicationService.java | 7 + .../service/ServiceDeviceSupport.java | 8 + .../devices/huami/Huami2021Support.java | 9 + .../services/ZeppOsLoyaltyCardService.java | 266 ++++++++++++++++++ app/src/main/res/drawable/ic_archive.xml | 10 + app/src/main/res/drawable/ic_loyalty.xml | 10 + .../res/layout/activity_loyalty_cards.xml | 12 + app/src/main/res/values/strings.xml | 19 ++ .../res/xml/devicesettings_header_apps.xml | 6 + .../res/xml/devicesettings_loyalty_cards.xml | 7 + app/src/main/res/xml/loyalty_cards.xml | 86 ++++++ 27 files changed, 1490 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsActivity.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsConst.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/BarcodeFormat.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaContentProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaManager.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/LoyaltyCard.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsLoyaltyCardService.java create mode 100644 app/src/main/res/drawable/ic_archive.xml create mode 100644 app/src/main/res/drawable/ic_loyalty.xml create mode 100644 app/src/main/res/layout/activity_loyalty_cards.xml create mode 100644 app/src/main/res/xml/devicesettings_header_apps.xml create mode 100644 app/src/main/res/xml/devicesettings_loyalty_cards.xml create mode 100644 app/src/main/res/xml/loyalty_cards.xml 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 reminders); + void onSetLoyaltyCards(ArrayList cards); + void onSetWorldClocks(ArrayList clocks); void onSetContacts(ArrayList 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 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 reminders = (ArrayList) 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 clocks = (ArrayList) 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 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 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +