1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-11-29 05:16:51 +01:00

Zepp OS: Add loyalty cards integration with Catima

This commit is contained in:
José Rebelo 2023-07-03 23:19:19 +01:00
parent e95c8a3775
commit fea3bf50a4
27 changed files with 1490 additions and 2 deletions

View File

@ -26,6 +26,9 @@
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!-- Read loyalty cards from Catima -->
<uses-permission android:name="me.hackerchick.catima.READ_CARDS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission
@ -107,6 +110,10 @@
android:name=".devices.miband.MiBandPreferencesActivity"
android:label="@string/preferences_miband_settings"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.loyaltycards.LoyaltyCardsSettingsActivity"
android:label="@string/loyalty_cards"
android:parentActivityName=".activities.devicesettings.DeviceSettingsActivity" />
<activity
android:name=".devices.zetime.ZeTimePreferenceActivity"
android:label="@string/zetime_title_settings"

View File

@ -17,6 +17,7 @@
package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings;
public class DeviceSettingsPreferenceConst {
public static final String PREF_HEADER_APPS = "pref_header_apps";
public static final String PREF_HEADER_TIME = "pref_header_time";
public static final String PREF_HEADER_DISPLAY = "pref_header_display";
public static final String PREF_HEADER_HEALTH = "pref_header_health";

View File

@ -52,6 +52,8 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.CalBlacklistActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureContacts;
import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureWorldClocks;
import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst;
import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
@ -919,6 +921,16 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp
}
}
final Preference loyaltyCards = findPreference(LoyaltyCardsSettingsConst.PREF_KEY_LOYALTY_CARDS);
if (loyaltyCards != null) {
loyaltyCards.setOnPreferenceClickListener(preference -> {
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);
}

View File

@ -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 <http://www.gnu.org/licenses/>. */
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);
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>. */
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";
}

View File

@ -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 <http://www.gnu.org/licenses/>. */
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<CharSequence> 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<String> 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<String> values = new HashSet<>(syncGroups.getValues());
final Set<String> 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);
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>. */
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,
}

View File

@ -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 <http://www.gnu.org/licenses/>. */
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<String> KNOWN_PACKAGES = new ArrayList<String>() {{
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<LoyaltyCard> getCards() {
final List<LoyaltyCard> 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<String> getGroups() {
final List<String> 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<String, List<Integer>> getGroupCards() {
final Map<String, List<Integer>> 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<Integer> 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";
}
}

View File

@ -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 <http://www.gnu.org/licenses/>. */
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<CharSequence> 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<String> 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<LoyaltyCard> cards = catima.getCards();
final Map<String, List<Integer>> groupCards = catima.getGroupCards();
final Set<Integer> cardsInGroupsToSync = new HashSet<>();
if (syncGroupsOnly) {
for (final Map.Entry<String, List<Integer>> groupCardsEntry : groupCards.entrySet()) {
if (syncGroups.contains(groupCardsEntry.getKey())) {
cardsInGroupsToSync.addAll(groupCardsEntry.getValue());
}
}
}
final ArrayList<LoyaltyCard> 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<CharSequence> findInstalledCatimaPackages() {
final List<CharSequence> 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;
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>. */
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<LoyaltyCard> {
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();
}
}

View File

@ -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<LoyaltyCard> cards);
void onSetWorldClocks(ArrayList<? extends WorldClock> clocks);
void onSetContacts(ArrayList<? extends Contact> contacts);

View File

@ -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<Integer> 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
//

View File

@ -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,

View File

@ -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<LoyaltyCard> 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)

View File

@ -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";

View File

@ -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<LoyaltyCard> cards) {
}
/**
* If world clocks can be configured on the device, this method can be
* overridden and implemented by the device support class.

View File

@ -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<LoyaltyCard> loyaltyCards = (ArrayList<LoyaltyCard>) 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);

View File

@ -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<LoyaltyCard> cards) {
if (checkBusy("set loyalty cards")) {
return;
}
delegate.onSetLoyaltyCards(cards);
}
@Override
public void onSetWorldClocks(ArrayList<? extends WorldClock> clocks) {
if (checkBusy("set world clocks")) {

View File

@ -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<Short, AbstractZeppOsService> mServiceMap = new LinkedHashMap<Short, AbstractZeppOsService>() {{
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<LoyaltyCard> cards) {
loyaltyCardService.setCards(cards);
}
@Override
public void onSetContacts(ArrayList<? extends Contact> contacts) {
contactsService.setContacts((List<Contact>) contacts);

View File

@ -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 <http://www.gnu.org/licenses/>. */
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<BarcodeFormat> supportedFormats = new ArrayList<>();
private final List<Integer> 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<Byte, BarcodeFormat> 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<Byte, Integer> 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<BarcodeFormat> getSupportedFormats() {
return supportedFormats;
}
public void requestCapabilities(final TransactionBuilder builder) {
write(builder, CMD_CAPABILITIES_REQUEST);
}
public void setCards(final List<LoyaltyCard> cards) {
LOG.info("Setting {} loyalty cards", cards.size());
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
final List<LoyaltyCard> 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<LoyaltyCard> filterSupportedCards(final List<LoyaltyCard> cards) {
final List<LoyaltyCard> 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<BarcodeFormat, Byte> BARCODE_FORMAT_CODES = new HashMap<BarcodeFormat, Byte>() {{
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<Integer, Byte> COLOR_CODES = new HashMap<Integer, Byte>() {{
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;
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#7E7E7E"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20.54,5.23l-1.39,-1.68C18.88,3.21 18.47,3 18,3H6c-0.47,0 -0.88,0.21 -1.16,0.55L3.46,5.23C3.17,5.57 3,6.02 3,6.5V19c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V6.5c0,-0.48 -0.17,-0.93 -0.46,-1.27zM12,17.5L6.5,12H10v-2h4v2h3.5L12,17.5zM5.12,5l0.81,-1h12l0.94,1H5.12z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#7E7E7E"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21.41,11.58l-9,-9C12.05,2.22 11.55,2 11,2L4,2c-1.1,0 -2,0.9 -2,2v7c0,0.55 0.22,1.05 0.59,1.42l9,9c0.36,0.36 0.86,0.58 1.41,0.58 0.55,0 1.05,-0.22 1.41,-0.59l7,-7c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-0.55 -0.23,-1.06 -0.59,-1.42zM5.5,7C4.67,7 4,6.33 4,5.5S4.67,4 5.5,4 7,4.67 7,5.5 6.33,7 5.5,7zM17.27,15.27L13,19.54l-4.27,-4.27C8.28,14.81 8,14.19 8,13.5c0,-1.38 1.12,-2.5 2.5,-2.5 0.69,0 1.32,0.28 1.77,0.74l0.73,0.72 0.73,-0.73c0.45,-0.45 1.08,-0.73 1.77,-0.73 1.38,0 2.5,1.12 2.5,2.5 0,0.69 -0.28,1.32 -0.73,1.77z" />
</vector>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="10dp"
android:orientation="vertical">
<FrameLayout
android:id="@+id/settings_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View File

@ -2155,4 +2155,23 @@
<string name="changelog_show_full">More…</string>
<string name="changelog_ok_button">OK</string>
<string name="changelog_title">What\'s New</string>
<string name="loyalty_cards_catima_package">Catima package name</string>
<string name="loyalty_cards_install">Install Catima</string>
<string name="loyalty_cards_sync_groups_only">Sync only specific groups</string>
<string name="loyalty_cards_sync_groups">Groups to Sync</string>
<string name="loyalty_cards_sync_archived">Sync archived cards</string>
<string name="loyalty_cards_install_catima_fail">Failed to open app store to install Catima</string>
<string name="loyalty_cards_sync_starred">Sync only starred cards</string>
<string name="loyalty_cards_sync_title">Sync Loyalty Cards</string>
<string name="loyalty_cards_sync_summary">Tap to sync the cards to the watch</string>
<string name="loyalty_cards_catima_not_installed">Catima is needed to manage the loyalty cards</string>
<string name="loyalty_cards_catima_not_compatible">The installed Catima version is not compatible with Gadgetbridge. Please update Catima and Gadgetbridge to the latest versions.</string>
<string name="loyalty_cards_sync_options">Sync Options</string>
<string name="loyalty_cards_sync">Sync</string>
<string name="loyalty_cards_catima">Catima</string>
<string name="loyalty_cards_open_catima">Open Catima</string>
<string name="loyalty_cards_catima_permissions_title">Missing permissions</string>
<string name="loyalty_cards_catima_permissions_summary">Gadgetbridge needs read permissions on Catima cards to sync them. Tap this button to grant them.</string>
<string name="loyalty_cards">Loyalty Cards</string>
<string name="loyalty_cards_syncing">Syncing %d loyalty cards to device</string>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:key="pref_header_apps"
android:title="@string/qhybrid_title_apps" />
</androidx.preference.PreferenceScreen>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:icon="@drawable/ic_loyalty"
android:key="pref_key_loyalty_cards"
android:title="@string/loyalty_cards" />
</androidx.preference.PreferenceScreen>

View File

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:key="pref_key_header_loyalty_cards_catima"
android:title="@string/loyalty_cards_catima">
<ListPreference
android:defaultValue="me.hackerchick.catima"
android:entries="@array/pref_huami2021_empty_array"
android:entryValues="@array/pref_huami2021_empty_array"
android:key="loyalty_cards_catima_package"
android:summary="%s"
android:title="@string/loyalty_cards_catima_package" />
<Preference
android:icon="@drawable/ic_loyalty"
android:key="loyalty_cards_open_catima"
android:title="@string/loyalty_cards_open_catima" />
<Preference
android:icon="@drawable/ic_warning_gray"
android:key="loyalty_cards_catima_not_installed"
android:summary="@string/loyalty_cards_catima_not_installed" />
<Preference
android:icon="@drawable/ic_warning_gray"
android:key="loyalty_cards_catima_not_compatible"
android:summary="@string/loyalty_cards_catima_not_compatible" />
<Preference
android:icon="@drawable/ic_loyalty"
android:key="loyalty_cards_install_catima"
android:title="@string/loyalty_cards_install" />
<Preference
android:icon="@drawable/ic_warning_gray"
android:key="loyalty_cards_catima_permissions"
android:summary="@string/loyalty_cards_catima_permissions_summary"
android:title="@string/loyalty_cards_catima_permissions_title" />
</PreferenceCategory>
<PreferenceCategory
android:key="pref_key_header_loyalty_cards_sync"
android:title="@string/loyalty_cards_sync">
<Preference
android:icon="@drawable/ic_refresh"
android:key="loyalty_cards_sync"
android:summary="@string/loyalty_cards_sync_summary"
android:title="@string/loyalty_cards_sync_title" />
</PreferenceCategory>
<PreferenceCategory
android:key="pref_key_header_loyalty_cards_sync_options"
android:title="@string/loyalty_cards_sync_options">
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/ic_list_numbered"
android:key="loyalty_cards_sync_groups_only"
android:title="@string/loyalty_cards_sync_groups_only" />
<MultiSelectListPreference
android:defaultValue="@array/pref_huami2021_empty_array"
android:dependency="loyalty_cards_sync_groups_only"
android:dialogTitle="@string/loyalty_cards_sync_groups"
android:entries="@array/pref_huami2021_empty_array"
android:entryValues="@array/pref_huami2021_empty_array"
android:icon="@drawable/ic_checklist"
android:key="loyalty_cards_sync_groups"
android:summary=""
android:title="@string/loyalty_cards_sync_groups" />
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/ic_archive"
android:key="loyalty_cards_sync_archived"
android:title="@string/loyalty_cards_sync_archived" />
<SwitchPreference
android:defaultValue="false"
android:icon="@drawable/ic_star_gray"
android:key="loyalty_cards_sync_starred"
android:title="@string/loyalty_cards_sync_starred" />
</PreferenceCategory>
</androidx.preference.PreferenceScreen>