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 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 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); + } + } +}