diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java
index aebed10cde..c22974ebeb 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java
@@ -877,6 +877,12 @@ public class HuaweiCoordinator {
return false;
}
+ public boolean supportsContactsSync() {
+ if (supportsExpandCapability())
+ return supportsExpandCapability(271);
+ return false;
+ }
+
public boolean supportsPromptPushMessage () {
// do not ask for capabilities under specific condition
// if (deviceType == 10 && deviceVersion == 73617766697368 && deviceSoftVersion == 372E312E31) -> leo device
@@ -943,6 +949,11 @@ public class HuaweiCoordinator {
}
public int getContactsSlotCount(GBDevice device) {
+ if(supportsContactsSync()) {
+ // TODO: Currently I don't know how to obtain contacts limit in runtime, and I don't know is the limit exists,
+ // set limit to 20 because more items not comfortable to use in the current GB's UI. Can be increased in the future.
+ return 20;
+ }
return supportsContacts()?maxContactsCount:0;
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTLV.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTLV.java
index 1d0132f28b..c94d0dcfc8 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTLV.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTLV.java
@@ -292,6 +292,18 @@ public class HuaweiTLV {
return ByteBuffer.wrap(getBytes(tag)).getInt();
}
+ public Integer getAsInteger(int tag, int def) {
+ byte[] bytes = getBytes(tag, null);
+ if (bytes == null || bytes.length == 0 || bytes.length > 4) {
+ return def;
+ }
+ int res = 0;
+ for (int i = 0; i < bytes.length; i++) {
+ res |= (bytes[i] & 255) << (((bytes.length - i) - 1) * 8);
+ }
+ return res;
+ }
+
public Long getAsLong(int tag) throws HuaweiPacket.MissingTagException {
byte[] bytes = getBytes(tag);
if(bytes.length == 1) {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java
index 2a38fe3655..544de5b26e 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java
@@ -141,6 +141,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.datasync.Huaw
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.p2p.HuaweiP2PAppIcon;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.p2p.HuaweiP2PCalendarService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.p2p.HuaweiP2PCannedRepliesService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.p2p.HuaweiP2PContactsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.p2p.HuaweiP2PTrackService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.p2p.HuaweiP2PDataDictionarySyncService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.AcceptAgreementsRequest;
@@ -943,6 +944,12 @@ public class HuaweiSupportProvider {
appIconService.register();
}
}
+ if(getHuaweiCoordinator().supportsContactsSync()) {
+ if (HuaweiP2PContactsService.getRegisteredInstance(huaweiP2PManager) == null) {
+ HuaweiP2PContactsService contactsService = new HuaweiP2PContactsService(huaweiP2PManager);
+ contactsService.register();
+ }
+ }
}
if (getHuaweiCoordinator().supportsThreeCircle() || getHuaweiCoordinator().supportsThreeCircleLite()) {
@@ -2706,18 +2713,27 @@ public class HuaweiSupportProvider {
}
public void onSetContacts(ArrayList extends Contact> contacts) {
- SendSetContactsRequest sendSetContactsRequest = new SendSetContactsRequest(
- this,
- contacts,
- this.getHuaweiCoordinator().getContactsSlotCount(getDevice())
- );
- try {
- sendSetContactsRequest.doPerform();
- } catch (IOException e) {
- GB.toast(context, "Failed to set contacts", Toast.LENGTH_SHORT, GB.ERROR, e);
- LOG.error("Failed to send set contacts request", e);
- }
+ if(getHuaweiCoordinator().supportsContactsSync()) {
+ HuaweiP2PContactsService P2PContactsService = HuaweiP2PContactsService.getRegisteredInstance(huaweiP2PManager);
+ if (P2PContactsService != null) {
+ P2PContactsService.startSync();
+ }
+ } else if(getHuaweiCoordinator().supportsContacts()) {
+ SendSetContactsRequest sendSetContactsRequest = new SendSetContactsRequest(
+ this,
+ contacts,
+ this.getHuaweiCoordinator().getContactsSlotCount(getDevice())
+ );
+ try {
+ sendSetContactsRequest.doPerform();
+ } catch (IOException e) {
+ GB.toast(context, "Failed to set contacts", Toast.LENGTH_SHORT, GB.ERROR, e);
+ LOG.error("Failed to send set contacts request", e);
+ }
+ } else {
+ LOG.error("Contacts not supported");
+ }
}
public void onAddCalendarEvent(final CalendarEventSpec calendarEventSpec) {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/p2p/HuaweiP2PContactsService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/p2p/HuaweiP2PContactsService.java
new file mode 100644
index 0000000000..755cda1d84
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/p2p/HuaweiP2PContactsService.java
@@ -0,0 +1,350 @@
+/* Copyright (C) 2025 Me7c7
+
+ 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.huawei.p2p;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+import nodomain.freeyourgadget.gadgetbridge.entities.Contact;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiP2PManager;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiUploadManager;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.SendFileUploadInfo;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class HuaweiP2PContactsService extends HuaweiBaseP2PService {
+ private final Logger LOG = LoggerFactory.getLogger(HuaweiP2PContactsService.class);
+ private final AtomicBoolean isRegistered = new AtomicBoolean(false);
+
+ public static final String MODULE = "hw.unitedevice.contactsapp";
+
+ public HuaweiP2PContactsService(HuaweiP2PManager manager) {
+ super(manager);
+ LOG.info("HuaweiP2PContactsService");
+ }
+
+ public static HuaweiP2PContactsService getRegisteredInstance(HuaweiP2PManager manager) {
+ return (HuaweiP2PContactsService) manager.getRegisteredService(MODULE);
+ }
+
+ @Override
+ public String getModule() {
+ return MODULE;
+ }
+
+ @Override
+ public String getPackage() {
+ return "in.huawei.contacts";
+ }
+
+ @Override
+ public String getFingerprint() {
+ return "SystemApp";
+ }
+
+ @Override
+ public void registered() {
+ isRegistered.set(true);
+ }
+
+ @Override
+ public void unregister() {
+ isRegistered.set(false);
+
+ }
+
+ private int getUtf8ByteLength(char c) {
+ if (c <= 127) return 1; // ASCII (1 byte)
+ if (c <= 2047) return 2; // Latin-1, extended ASCII (2 bytes)
+ return 3; // unicode characters (3 bytes)
+ }
+
+ private String truncateByLen(String str, int maxBytes) {
+ if (str == null || str.isEmpty()) {
+ return str;
+ }
+ StringBuilder sb = new StringBuilder();
+ int byteCount = 0;
+ for (int i = 0; i < str.length(); i++) {
+ char currentChar = str.charAt(i);
+ byteCount += getUtf8ByteLength(currentChar);
+ if (byteCount > maxBytes) {
+ break;
+ }
+ sb.append(currentChar);
+ }
+ return sb.toString();
+ }
+
+ public static List> partitionList(List list, int chunkSize) {
+ if (list == null) {
+ return new ArrayList<>();
+ }
+ ArrayList> chunks = new ArrayList<>();
+ for (int start = 0; start < list.size(); start += chunkSize) {
+ chunks.add(list.subList(start, Math.min(start + chunkSize, list.size())));
+ }
+ return chunks;
+ }
+
+ private List prepareDataWithHeader(int operation, String majorVersion, int minorVersion, TreeMap> map) {
+ List ret = new ArrayList<>();
+ for (Map.Entry> next : map.entrySet()) {
+ for (List list : partitionList(next.getValue(), 20)) {
+ JsonArray contactsList = new JsonArray();
+ for (JsonObject obj : list) {
+ contactsList.add(obj);
+ }
+ JsonObject syncData = new JsonObject();
+ syncData.addProperty("operation", operation);
+ syncData.addProperty("minor", minorVersion + 1);
+ syncData.addProperty("major", truncateByLen(majorVersion, 32));
+ syncData.add("contactsList", contactsList); // max 20
+ syncData.addProperty("sortLetter", next.getKey());
+
+ String json = new Gson().toJson(syncData);
+ LOG.info(json);
+ ret.add(json);
+ }
+ }
+ return ret;
+ }
+
+ // NOTE: currently watch is properly works only with ASCII symbols.
+ public static String getLetter(final String str) {
+ final String tmp = str.substring(0, 1).toUpperCase(Locale.ENGLISH);
+ return tmp.matches("[A-Z]") ? tmp : "#";
+ }
+
+ public static String removeNonAscii(String str) {
+ return str.replaceAll("[^a-zA-Z0-9]", "");
+ }
+
+ private JsonObject prepareContact(final Contact con, final String sortLetter) {
+ JsonArray phoneList = new JsonArray();
+
+ JsonObject phone = new JsonObject();
+ phone.addProperty("label", "Mobile"); // TODO:
+ phone.addProperty("number", con.getNumber());
+ phoneList.add(phone);
+
+ if (phoneList.size() > 7) {
+ for (int i = 6; i < phoneList.size(); i++) {
+ phoneList.remove(i);
+ }
+ }
+
+ JsonObject contact = new JsonObject();
+ contact.addProperty("uuid", truncateByLen(con.getContactId(), 32));
+ contact.addProperty("sortLetter", sortLetter);
+ contact.addProperty("name", truncateByLen(con.getName(), 28));
+ contact.add("phoneList", phoneList);
+ contact.addProperty("sortKey", truncateByLen(removeNonAscii(con.getName()), 56));
+
+ return contact;
+ }
+
+ private List prepareContactsByLetters(final List extends Contact> contacts, int operation, final String majorVersion, int minorVersion) {
+ TreeMap> map = new TreeMap<>();
+ for (Contact c : contacts) {
+ String sortLetter = getLetter(c.getName());
+ if (!map.containsKey(sortLetter)) {
+ map.put(sortLetter, new ArrayList<>());
+ }
+ map.get(sortLetter).add(prepareContact(c, sortLetter));
+ }
+ return prepareDataWithHeader(operation, majorVersion, minorVersion, map);
+ }
+
+ private byte[] getContactsFileContent(String majorVersion, int minorVersion) {
+
+ final List contacts = DBHelper.getContacts(manager.getSupportProvider().getDevice());
+ List res = prepareContactsByLetters(contacts, 1, majorVersion, minorVersion);
+
+ int len = 1; // flag len, 1 byte
+ if(res.isEmpty()) {
+ len += 1;
+ } else {
+ for (String s : res) {
+ len += 4 + s.getBytes(StandardCharsets.UTF_8).length; // length 4 bytes + data length
+ }
+ }
+
+ ByteBuffer sendData = ByteBuffer.allocate(len);
+ sendData.put((byte) 0); // sync flag ??
+ if(res.isEmpty()) {
+ sendData.put((byte) 0); // no data
+ } else {
+ for (String s : res) {
+ byte[] dataBytes = s.getBytes(StandardCharsets.UTF_8);
+ sendData.putInt(dataBytes.length);
+ sendData.put(dataBytes);
+ }
+ }
+ return sendData.array();
+ }
+
+ private void sendContactsFile(String majorVersion, int minorVersion) {
+ LOG.info("Send contacts file upload info");
+
+ if (majorVersion == null || majorVersion.isEmpty()) {
+ majorVersion = new String(this.manager.getSupportProvider().getAndroidId(), StandardCharsets.UTF_8).toLowerCase();
+ }
+
+ byte[] data = getContactsFileContent(majorVersion, minorVersion);
+
+ LOG.info(GB.hexdump(data));
+
+ HuaweiUploadManager.FileUploadInfo fileInfo = new HuaweiUploadManager.FileUploadInfo();
+
+ fileInfo.setFileType((byte) 7);
+ fileInfo.setFileName("contacts.json");
+ fileInfo.setBytes(data);
+ fileInfo.setSrcPackage(this.getModule());
+ fileInfo.setDstPackage(this.getPackage());
+ fileInfo.setSrcFingerprint(this.getLocalFingerprint());
+ fileInfo.setDstFingerprint(this.getFingerprint());
+
+ fileInfo.setFileUploadCallback(new HuaweiUploadManager.FileUploadCallback() {
+ @Override
+ public void onUploadStart() {
+ // TODO: set device as busy in this case. But maybe exists another way to do this. Currently user see text on device card.
+ // Also text should be changed
+ manager.getSupportProvider().getDevice().setBusyTask(R.string.updating_firmware, manager.getSupportProvider().getContext());
+ manager.getSupportProvider().getDevice().sendDeviceUpdateIntent(manager.getSupportProvider().getContext());
+ }
+
+ @Override
+ public void onUploadProgress(int progress) {
+ }
+
+ @Override
+ public void onUploadComplete() {
+ if (manager.getSupportProvider().getDevice().isBusy()) {
+ manager.getSupportProvider().getDevice().unsetBusyTask();
+ manager.getSupportProvider().getDevice().sendDeviceUpdateIntent(manager.getSupportProvider().getContext());
+ }
+ }
+
+ @Override
+ public void onError(int code) {
+ if (manager.getSupportProvider().getDevice().isBusy()) {
+ manager.getSupportProvider().getDevice().unsetBusyTask();
+ manager.getSupportProvider().getDevice().sendDeviceUpdateIntent(manager.getSupportProvider().getContext());
+ }
+ }
+ });
+ HuaweiUploadManager huaweiUploadManager = this.manager.getSupportProvider().getUploadManager();
+
+ huaweiUploadManager.setFileUploadInfo(fileInfo);
+
+ try {
+ SendFileUploadInfo sendFileUploadInfo = new SendFileUploadInfo(this.manager.getSupportProvider(), huaweiUploadManager);
+ sendFileUploadInfo.doPerform();
+ } catch (IOException e) {
+ LOG.error("Failed to send file upload info", e);
+ }
+ }
+
+ public void startSync() {
+ HuaweiTLV tlv = new HuaweiTLV();
+ tlv.put(0x01, (byte) 0x01);
+ sendCommand(tlv.serialize(), null);
+ }
+
+ @Override
+ public void handleData(byte[] data) {
+ LOG.info("HuaweiP2PContactsService handleData");
+ try {
+ HuaweiTLV tlv = new HuaweiTLV();
+ tlv.parse(data);
+ LOG.error(tlv.toString());
+ int operateMode = tlv.getAsInteger(0x01, -1);
+ if (operateMode == 1) {
+ String majorVersion = null;
+ int minorVersion = 0;
+ int count = -1;
+ if (tlv.contains(0x2))
+ majorVersion = tlv.getString(0x2).trim();
+ if (tlv.contains(0x3))
+ minorVersion = tlv.getAsInteger(0x3, 0);
+ if (tlv.contains(0x4))
+ count = tlv.getAsInteger(0x4, -1);
+
+ LOG.info("HuaweiP2PContactsService operateMode: 1, majorVersion: {}, minorVersion: {}, count: {}", majorVersion, minorVersion, count);
+ sendContactsFile(majorVersion, minorVersion);
+ } else if (operateMode == 2) {
+ String majorVersion = null;
+ int minorVersion = 0;
+ int count = -1;
+ if (tlv.contains(0x2))
+ majorVersion = tlv.getString(0x2).trim();
+ if (tlv.contains(0x3))
+ minorVersion = tlv.getAsInteger(0x3, 0);
+ if (tlv.contains(0x4))
+ count = tlv.getAsInteger(0x4, -1);
+
+ LOG.info("HuaweiP2PContactsService operateMode: 2, majorVersion: {}, minorVersion: {}, count: {}", majorVersion, minorVersion, count);
+ //status: 0 - ok, 1 - no contacts, 2 - sync in progress, 3 - sync turned off, 4 - no permissions, 5 - not supported
+ byte status = 3;
+
+ final List contacts = DBHelper.getContacts(manager.getSupportProvider().getDevice());
+ if (!contacts.isEmpty()) {
+ status = 0;
+ }
+
+ HuaweiTLV tlv2 = new HuaweiTLV();
+ tlv2.put(0x01, (byte) 0x02);
+ tlv2.put(0x08, status);
+ sendCommand(tlv2.serialize(), null);
+
+ if(status == 0) {
+ sendContactsFile(majorVersion, minorVersion);
+ }
+
+ } else if (operateMode == 3) {
+ int status = -1;
+ if (tlv.contains(0x8))
+ status = tlv.getAsInteger(0x8);
+ if (status != 0) {
+ LOG.error("send contacts fail status: {}", status);
+ }
+ } else {
+ LOG.error("unknown operateMode : {}", operateMode);
+ }
+ } catch (HuaweiPacket.MissingTagException e) {
+ LOG.error("Failed to handle p2p contacts", e);
+ }
+ }
+}