mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Zepp OS: Maps upload
This commit is contained in:
+11
-2
@@ -117,13 +117,18 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
final ZeppOsFwInstallHandler handler = new ZeppOsFwInstallHandler(
|
||||
final ZeppOsMapsInstallHandler mapsInstallHandler = new ZeppOsMapsInstallHandler(uri, context);
|
||||
if (mapsInstallHandler.isValid()) {
|
||||
return mapsInstallHandler;
|
||||
}
|
||||
|
||||
final ZeppOsFwInstallHandler fwInstallHandler = new ZeppOsFwInstallHandler(
|
||||
uri,
|
||||
context,
|
||||
getDeviceBluetoothName(),
|
||||
getDeviceSources()
|
||||
);
|
||||
return handler.isValid() ? handler : null;
|
||||
return fwInstallHandler.isValid() ? fwInstallHandler : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -591,6 +596,10 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
|
||||
return experimentalFeatures(device) && ZeppOsAssistantService.isSupported(getPrefs(device));
|
||||
}
|
||||
|
||||
public boolean supportsMaps(final GBDevice device) {
|
||||
return supportsDisplayItem(device, "map");
|
||||
}
|
||||
|
||||
private boolean supportsConfig(final GBDevice device, final ZeppOsConfigService.ConfigArg config) {
|
||||
return ZeppOsConfigService.deviceHasConfig(getPrefs(device), config);
|
||||
}
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsMapsFile;
|
||||
|
||||
public class ZeppOsMapsInstallHandler implements InstallHandler {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsMapsInstallHandler.class);
|
||||
|
||||
protected final Context mContext;
|
||||
private final ZeppOsMapsFile file;
|
||||
|
||||
public ZeppOsMapsInstallHandler(final Uri uri, final Context context) {
|
||||
this.mContext = context;
|
||||
|
||||
file = new ZeppOsMapsFile(uri, context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
return file.isValid();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
|
||||
if (device.isBusy()) {
|
||||
installActivity.setInfoText(device.getBusyTask());
|
||||
installActivity.setInstallEnabled(false);
|
||||
installActivity.setCloseEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
if (!(coordinator instanceof ZeppOsCoordinator)) {
|
||||
LOG.warn("Coordinator is not a ZeppOsCoordinator: {}", coordinator.getClass());
|
||||
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
|
||||
installActivity.setInstallEnabled(false);
|
||||
installActivity.setCloseEnabled(true);
|
||||
return;
|
||||
}
|
||||
final ZeppOsCoordinator zeppOsCoordinator = (ZeppOsCoordinator) coordinator;
|
||||
if (!zeppOsCoordinator.supportsMaps(device) || zeppOsCoordinator.supportsWifiHotspot(device)) {
|
||||
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
|
||||
installActivity.setInstallEnabled(false);
|
||||
installActivity.setCloseEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!device.isInitialized()) {
|
||||
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
|
||||
installActivity.setInstallEnabled(false);
|
||||
installActivity.setCloseEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
final GenericItem fwItem = createInstallItem(device);
|
||||
fwItem.setIcon(coordinator.getDefaultIconResource());
|
||||
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
final String map = mContext.getString(R.string.menuitem_map);
|
||||
builder.append(mContext.getString(R.string.fw_upgrade_notice, map));
|
||||
builder.append("\n\n").append(mContext.getString(R.string.zepp_os_install_map_instructions));
|
||||
installActivity.setInfoText(builder.toString());
|
||||
installActivity.setInstallItem(fwItem);
|
||||
installActivity.setInstallEnabled(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartInstall(final GBDevice device) {
|
||||
}
|
||||
|
||||
public ZeppOsMapsFile getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
private GenericItem createInstallItem(final GBDevice device) {
|
||||
DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
final String firmwareName = mContext.getString(
|
||||
R.string.installhandler_firmware_name,
|
||||
mContext.getString(coordinator.getDeviceNameResource()),
|
||||
mContext.getString(R.string.menuitem_map),
|
||||
""
|
||||
);
|
||||
return new GenericItem(firmwareName);
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -89,6 +89,7 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventScreenshot;
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventSilentMode;
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsMapsInstallHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
|
||||
@@ -138,6 +139,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service
|
||||
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.ZeppOsMapsService;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsMusicService;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsNotificationService;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsRemindersService;
|
||||
@@ -190,10 +192,11 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
|
||||
private final ZeppOsAppsService appsService = new ZeppOsAppsService(this);
|
||||
private final ZeppOsLogsService logsService = new ZeppOsLogsService(this);
|
||||
private final ZeppOsDisplayItemsService displayItemsService = new ZeppOsDisplayItemsService(this);
|
||||
private final ZeppOsHttpService httpService = new ZeppOsHttpService(this);
|
||||
private final ZeppOsHttpService httpService = new ZeppOsHttpService(this, fileTransferService);
|
||||
private final ZeppOsRemindersService remindersService = new ZeppOsRemindersService(this);
|
||||
private final ZeppOsLoyaltyCardService loyaltyCardService = new ZeppOsLoyaltyCardService(this);
|
||||
private final ZeppOsVoiceMemosService voiceMemosService = new ZeppOsVoiceMemosService(this);
|
||||
private final ZeppOsMapsService mapsService = new ZeppOsMapsService(this, httpService);
|
||||
private final ZeppOsMusicService musicService = new ZeppOsMusicService(this);
|
||||
|
||||
private final Set<Short> mSupportedServices = new HashSet<>();
|
||||
@@ -225,6 +228,7 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
|
||||
put(loyaltyCardService.getEndpoint(), loyaltyCardService);
|
||||
put(musicService.getEndpoint(), musicService);
|
||||
put(voiceMemosService.getEndpoint(), voiceMemosService);
|
||||
put(mapsService.getEndpoint(), mapsService);
|
||||
}};
|
||||
|
||||
public ZeppOsSupport() {
|
||||
@@ -735,6 +739,12 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
|
||||
return;
|
||||
}
|
||||
|
||||
final ZeppOsMapsInstallHandler mapsHandler = new ZeppOsMapsInstallHandler(uri, getContext());
|
||||
if (mapsHandler.isValid()) {
|
||||
mapsService.upload(mapsHandler.getFile());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new ZeppOsFirmwareUpdateOperation(uri, this).perform();
|
||||
} catch (final IOException ex) {
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
|
||||
|
||||
public class ZeppOsMapsFile {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsMapsFile.class);
|
||||
|
||||
private final Pattern imgPathPattern = Pattern.compile("^11/([0-9]+)/([0-9]+).img$");
|
||||
|
||||
private static final byte[] SIGNATURE = {'D', 'S', 'K', 'I', 'M', 'G', '\0'};
|
||||
|
||||
private final Uri uri;
|
||||
private final Context context;
|
||||
|
||||
private long fileSize = 0;
|
||||
private long uncompressedSize = 0;
|
||||
|
||||
public ZeppOsMapsFile(final Uri uri, final Context context) {
|
||||
this.uri = uri;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
final UriHelper uriHelper;
|
||||
try {
|
||||
uriHelper = UriHelper.get(uri, context);
|
||||
} catch (final IOException e) {
|
||||
LOG.error("Failed to create urihelper", e);
|
||||
return false;
|
||||
}
|
||||
fileSize = uriHelper.getFileSize();
|
||||
|
||||
try (InputStream is = context.getContentResolver().openInputStream(uri); ZipInputStream zis = new ZipInputStream(is)) {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zis.getNextEntry()) != null) {
|
||||
if (zipEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final Matcher matcher = imgPathPattern.matcher(zipEntry.getName());
|
||||
if (!matcher.find()) {
|
||||
LOG.error("Unknown file {}", zipEntry.getName());
|
||||
}
|
||||
|
||||
final int num1 = Integer.parseInt(Objects.requireNonNull(matcher.group(1)), 10);
|
||||
final int num2 = Integer.parseInt(Objects.requireNonNull(matcher.group(2)), 10);
|
||||
if (num1 > 2048 || num2 > 2048) {
|
||||
LOG.error("Invalid path {}", zipEntry.getName());
|
||||
return false;
|
||||
}
|
||||
|
||||
final byte[] header = new byte[512];
|
||||
final int read = zis.read(header, 0, 512);
|
||||
|
||||
if (read < 512 || !ArrayUtils.equals(header, SIGNATURE, 0x10)) {
|
||||
LOG.error("DSKIMG signature not found in {}", zipEntry.getName());
|
||||
return false;
|
||||
}
|
||||
|
||||
uncompressedSize += zipEntry.getSize();
|
||||
}
|
||||
} catch (final IOException e) {
|
||||
LOG.error("Failed to read {}", uri, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public Uri getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
public long getFileSize() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public long getUncompressedSize() {
|
||||
return uncompressedSize;
|
||||
}
|
||||
}
|
||||
+160
-23
@@ -1,4 +1,4 @@
|
||||
/* Copyright (C) 2023-2024 José Rebelo
|
||||
/* Copyright (C) 2023-2025 José Rebelo
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
@@ -16,21 +16,28 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsWeather;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.HttpUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
|
||||
|
||||
@@ -39,14 +46,26 @@ public class ZeppOsHttpService extends AbstractZeppOsService {
|
||||
|
||||
private static final short ENDPOINT = 0x0001;
|
||||
|
||||
public static final byte CMD_REQUEST = 0x01;
|
||||
public static final byte CMD_RESPONSE = 0x02;
|
||||
public static final byte CMD_SIMPLE_REQUEST = 0x01;
|
||||
public static final byte CMD_SIMPLE_RESPONSE = 0x02;
|
||||
public static final byte CMD_RAW_DOWNLOAD_REQUEST = 0x03;
|
||||
public static final byte CMD_RAW_DOWNLOAD_START = 0x04;
|
||||
public static final byte CMD_RAW_DOWNLOAD_FINISH = 0x05;
|
||||
|
||||
public static final byte RESPONSE_SUCCESS = 0x01;
|
||||
public static final byte RESPONSE_NO_INTERNET = 0x02;
|
||||
|
||||
public ZeppOsHttpService(final ZeppOsSupport support) {
|
||||
/**
|
||||
* A map from url to local file URI that will be served to the watch.
|
||||
*/
|
||||
private final Map<String, Uri> urlToLocalFile = new HashMap<>();
|
||||
private final Map<String, Callback> urlDownloadCallbacks = new HashMap<>();
|
||||
|
||||
private final ZeppOsFileTransferService fileTransferService;
|
||||
|
||||
public ZeppOsHttpService(final ZeppOsSupport support, final ZeppOsFileTransferService fileTransferService) {
|
||||
super(support, true);
|
||||
this.fileTransferService = fileTransferService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -56,33 +75,57 @@ public class ZeppOsHttpService extends AbstractZeppOsService {
|
||||
|
||||
@Override
|
||||
public void handlePayload(final byte[] payload) {
|
||||
final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
|
||||
buf.get(); // discard command byte
|
||||
|
||||
switch (payload[0]) {
|
||||
case CMD_REQUEST:
|
||||
int pos = 1;
|
||||
final byte requestId = payload[pos++];
|
||||
final String method = StringUtils.untilNullTerminator(payload, pos);
|
||||
case CMD_SIMPLE_REQUEST: {
|
||||
final int requestId = buf.get() & 0xff;
|
||||
final String method = StringUtils.untilNullTerminator(buf);
|
||||
if (method == null) {
|
||||
LOG.error("Failed to decode method from payload");
|
||||
replyHttpNoInternet(requestId);
|
||||
return;
|
||||
}
|
||||
pos += method.length() + 1;
|
||||
final String url = StringUtils.untilNullTerminator(payload, pos);
|
||||
final String url = StringUtils.untilNullTerminator(buf);
|
||||
if (url == null) {
|
||||
LOG.error("Failed to decode method from payload");
|
||||
LOG.error("Failed to decode url from payload");
|
||||
return;
|
||||
}
|
||||
// headers after pos += url.length() + 1;
|
||||
// headers after, but we ignore them
|
||||
|
||||
LOG.info("Got HTTP {} request: {}", method, url);
|
||||
LOG.info("Got simple HTTP {} request: {}", method, url);
|
||||
|
||||
handleUrlRequest(requestId, method, url);
|
||||
return;
|
||||
}
|
||||
|
||||
case CMD_RAW_DOWNLOAD_REQUEST: {
|
||||
final int requestId = buf.get() & 0xff;
|
||||
final String url = StringUtils.untilNullTerminator(buf);
|
||||
if (url == null) {
|
||||
LOG.error("Failed to decode raw download url from payload");
|
||||
return;
|
||||
}
|
||||
// headers after, but we ignore them
|
||||
|
||||
LOG.info("Got raw download HTTP request: {}", url);
|
||||
|
||||
handleRawDownloadRequest(requestId, url);
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
LOG.warn("Unexpected HTTP payload byte {}", String.format("0x%02x", payload[0]));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleUrlRequest(final byte requestId, final String method, final String urlString) {
|
||||
public void registerForDownload(final String url, final Uri uri, @Nullable final Callback callback) {
|
||||
this.urlToLocalFile.put(url, uri);
|
||||
this.urlDownloadCallbacks.put(url, callback);
|
||||
}
|
||||
|
||||
private void handleUrlRequest(final int requestId, final String method, final String urlString) {
|
||||
if (!"GET".equals(method)) {
|
||||
LOG.error("Unable to handle HTTP method {}", method);
|
||||
// TODO: There's probably a "BAD REQUEST" response or similar
|
||||
@@ -94,7 +137,7 @@ public class ZeppOsHttpService extends AbstractZeppOsService {
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
} catch (final MalformedURLException e) {
|
||||
LOG.error("Failed to parse url", e);
|
||||
LOG.error("Failed to parse simple request url", e);
|
||||
replyHttpNoInternet(requestId);
|
||||
return;
|
||||
}
|
||||
@@ -104,31 +147,119 @@ public class ZeppOsHttpService extends AbstractZeppOsService {
|
||||
|
||||
if (path.startsWith("/weather/")) {
|
||||
final ZeppOsWeather.Response response = ZeppOsWeather.handleHttpRequest(path, query);
|
||||
replyHttpSuccess(requestId, response.getHttpStatusCode(), response.toJson());
|
||||
replySimpleHttpSuccess(requestId, response.getHttpStatusCode(), response.toJson());
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.error("Unhandled URL {}", url);
|
||||
LOG.error("Unhandled simple request URL {}", url);
|
||||
replyHttpNoInternet(requestId);
|
||||
}
|
||||
|
||||
private void replyHttpNoInternet(final byte requestId) {
|
||||
private void handleRawDownloadRequest(final int requestId, final String urlString) {
|
||||
final Uri uri = urlToLocalFile.remove(urlString);
|
||||
if (uri == null) {
|
||||
LOG.error("Unhandled raw download URL {}", urlString);
|
||||
replyHttpNoInternet(requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
final Callback downloadCallback = urlDownloadCallbacks.remove(urlString);
|
||||
|
||||
final URL url;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
} catch (final MalformedURLException e) {
|
||||
LOG.error("Failed to parse raw download url", e);
|
||||
replyHttpNoInternet(requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
final String filename = url.getPath().substring(url.getPath().lastIndexOf('/') + 1);
|
||||
|
||||
final byte[] rawBytes;
|
||||
try (InputStream is = getContext().getContentResolver().openInputStream(uri)) {
|
||||
// FIXME we probably should stream this
|
||||
// with the current implementation, anything over ~2MB will overflow the chunk index
|
||||
rawBytes = FileUtils.readAll(Objects.requireNonNull(is), 100 * 1024 * 1024); // 10MB
|
||||
} catch (final IOException e) {
|
||||
LOG.error("Failed to read local file for {}", urlString, e);
|
||||
replyHttpNoInternet(requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.debug("Starting raw download request {} with {} bytes", requestId, rawBytes.length);
|
||||
|
||||
fileTransferService.sendFile(
|
||||
"httpproxy://download?sessionid=" + requestId,
|
||||
filename,
|
||||
rawBytes,
|
||||
false,
|
||||
new ZeppOsFileTransferService.Callback() {
|
||||
@Override
|
||||
public void onFileUploadFinish(final boolean success) {
|
||||
LOG.info("Finished sending '{}' to http request id '{}', success={}", filename, requestId, success);
|
||||
onRawDownloadFinish(requestId, success);
|
||||
if (downloadCallback != null) {
|
||||
downloadCallback.onFileDownloadFinish(success);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFileUploadProgress(final int progress) {
|
||||
LOG.trace("HTTP send progress: {}", progress);
|
||||
if (downloadCallback != null) {
|
||||
downloadCallback.onFileDownloadProgress(progress);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
|
||||
LOG.warn("Receiver unexpected file: url={} filename={} length={}", url, filename, data.length);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
final ByteBuffer buf = ByteBuffer.allocate(10);
|
||||
buf.order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
buf.put(CMD_RAW_DOWNLOAD_START);
|
||||
buf.put((byte) requestId);
|
||||
buf.putInt(rawBytes.length);
|
||||
buf.putInt(0); // ?
|
||||
|
||||
write("http raw download start", buf.array());
|
||||
}
|
||||
|
||||
private void onRawDownloadFinish(final int requestId, final boolean success) {
|
||||
LOG.debug("Download {} finished, success = {}", requestId, success);
|
||||
final ByteBuffer buf = ByteBuffer.allocate(5);
|
||||
buf.order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
buf.put(CMD_RAW_DOWNLOAD_FINISH);
|
||||
buf.put((byte) requestId);
|
||||
buf.put((byte) 1); // success?
|
||||
buf.putShort((short) 200);
|
||||
|
||||
write("http raw download finish", buf.array());
|
||||
}
|
||||
|
||||
private void replyHttpNoInternet(final int requestId) {
|
||||
LOG.info("Replying with no internet to http request {}", requestId);
|
||||
|
||||
final byte[] cmd = new byte[]{CMD_RESPONSE, requestId, RESPONSE_NO_INTERNET, 0x00, 0x00, 0x00, 0x00};
|
||||
final byte[] cmd = new byte[]{CMD_SIMPLE_RESPONSE, (byte) requestId, RESPONSE_NO_INTERNET, 0x00, 0x00, 0x00, 0x00};
|
||||
|
||||
write("http reply no internet", cmd);
|
||||
}
|
||||
|
||||
private void replyHttpSuccess(final byte requestId, final int status, final String content) {
|
||||
private void replySimpleHttpSuccess(final int requestId, final int status, final String content) {
|
||||
LOG.debug("Replying with http {} request {} with {}", status, requestId, content);
|
||||
|
||||
final byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8);
|
||||
final ByteBuffer buf = ByteBuffer.allocate(8 + contentBytes.length);
|
||||
buf.order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
buf.put(CMD_RESPONSE);
|
||||
buf.put(requestId);
|
||||
buf.put(CMD_SIMPLE_RESPONSE);
|
||||
buf.put((byte) requestId);
|
||||
buf.put(RESPONSE_SUCCESS);
|
||||
buf.put((byte) status);
|
||||
buf.putInt(contentBytes.length);
|
||||
@@ -136,4 +267,10 @@ public class ZeppOsHttpService extends AbstractZeppOsService {
|
||||
|
||||
write("http reply success", buf.array());
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
void onFileDownloadFinish(boolean success);
|
||||
|
||||
void onFileDownloadProgress(int progress);
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsMapsFile;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
|
||||
public class ZeppOsMapsService extends AbstractZeppOsService {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsMapsService.class);
|
||||
|
||||
private static final short ENDPOINT = 0x0046;
|
||||
|
||||
private static final byte CMD_CAPABILITIES_REQUEST = 0x01;
|
||||
private static final byte CMD_CAPABILITIES_RESPONSE = 0x02;
|
||||
private static final byte CMD_DOWNLOAD_START_REQUEST = 0x05;
|
||||
private static final byte CMD_DOWNLOAD_START_RESPONSE = 0x06;
|
||||
|
||||
private final ZeppOsHttpService httpService;
|
||||
|
||||
public ZeppOsMapsService(final ZeppOsSupport support, final ZeppOsHttpService httpService) {
|
||||
super(support, true);
|
||||
this.httpService = httpService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public short getEndpoint() {
|
||||
return ENDPOINT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(final TransactionBuilder builder) {
|
||||
write(builder, CMD_CAPABILITIES_REQUEST);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlePayload(final byte[] payload) {
|
||||
final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
switch (buf.get()) {
|
||||
case CMD_CAPABILITIES_RESPONSE:
|
||||
final int version = payload[1] & 0xff; // 1 supported
|
||||
final int unk1 = payload[2] & 0xff; // 1?
|
||||
LOG.info("Maps version={}, unk1={}", version, unk1);
|
||||
return;
|
||||
case CMD_DOWNLOAD_START_RESPONSE:
|
||||
LOG.info("Download start response, status = {}", payload[1]); // 1 = success
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.warn("Unexpected maps byte {}", String.format("0x%02x", payload[0]));
|
||||
}
|
||||
|
||||
public void upload(final ZeppOsMapsFile mapsFile) {
|
||||
final List<String> urls = new ArrayList<>(1);
|
||||
final int i = 0;
|
||||
final long uncompressedSize = mapsFile.getUncompressedSize();
|
||||
final String fakeUrl = String.format(
|
||||
Locale.ROOT,
|
||||
"https://gadgetbridge.freeyourgadget.nodomain/map/%d.zip?type=1&zipsize=%d",
|
||||
i,
|
||||
mapsFile.getFileSize()
|
||||
);
|
||||
urls.add(fakeUrl);
|
||||
httpService.registerForDownload(fakeUrl, mapsFile.getUri(), new ZeppOsHttpService.Callback() {
|
||||
@Override
|
||||
public void onFileDownloadFinish(final boolean success) {
|
||||
final String notificationMessage = success ?
|
||||
getContext().getString(R.string.map_upload_complete) :
|
||||
getContext().getString(R.string.map_upload_failed);
|
||||
|
||||
GB.updateInstallNotification(notificationMessage, false, 100, getContext());
|
||||
final LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(getContext());
|
||||
|
||||
broadcastManager.sendBroadcast(new Intent(GB.ACTION_SET_INFO_TEXT).putExtra(GB.DISPLAY_MESSAGE_MESSAGE, ""));
|
||||
broadcastManager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_TEXT).putExtra(GB.DISPLAY_MESSAGE_MESSAGE, notificationMessage));
|
||||
broadcastManager.sendBroadcast(new Intent(GB.ACTION_SET_FINISHED));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFileDownloadProgress(final int progress) {
|
||||
final int progressPercent = (int) ((((float) (progress)) / mapsFile.getFileSize()) * 100);
|
||||
updateProgress(progressPercent);
|
||||
}
|
||||
});
|
||||
|
||||
LOG.debug("Url for map file {}: {}", mapsFile.getUri(), fakeUrl);
|
||||
|
||||
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try {
|
||||
baos.write(CMD_DOWNLOAD_START_REQUEST);
|
||||
baos.write(BLETypeConversions.fromUint32((int) uncompressedSize));
|
||||
baos.write(BLETypeConversions.fromUint16(urls.size()));
|
||||
for (final String url : urls) {
|
||||
baos.write(url.getBytes(StandardCharsets.UTF_8));
|
||||
baos.write(0);
|
||||
}
|
||||
baos.write(1); // ?
|
||||
} catch (final IOException e) {
|
||||
LOG.error("Failed to build command", e);
|
||||
return;
|
||||
}
|
||||
|
||||
write("upload maps", baos.toByteArray());
|
||||
}
|
||||
|
||||
private void updateProgress(final int progressPercent) {
|
||||
GB.updateInstallNotification(
|
||||
getContext().getString(R.string.map_upload_in_progress),
|
||||
true,
|
||||
progressPercent,
|
||||
getContext()
|
||||
);
|
||||
|
||||
final LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(getContext());
|
||||
broadcastManager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR).putExtra(GB.PROGRESS_BAR_PROGRESS, progressPercent));
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,7 @@
|
||||
<string name="open_fw_installer_connect_minimum_one_device">Please connect AT LEAST ONE device you want to send the file to.</string>
|
||||
<string name="open_fw_installer_connect_maximum_one_device">Please connect ONLY ONE device you want to send the file to.</string>
|
||||
<string name="open_fw_installer_ensure_device_connected">Make sure that the device %s is connected</string>
|
||||
<string name="zepp_os_install_map_instructions">After the installation starts on the phone, you need to accept on the watch as well.</string>
|
||||
<!-- Strings related to MusicManager -->
|
||||
<string name="title_activity_musicmanager">Music Manager</string>
|
||||
<!-- Strings related to Settings -->
|
||||
@@ -944,6 +945,9 @@
|
||||
<string name="gpx_route_upload_failed">Gpx route upload failed</string>
|
||||
<string name="gpx_route_upload_complete">Gpx route upload complete</string>
|
||||
<string name="gpx_route_upload_in_progress">Uploading gpx route</string>
|
||||
<string name="map_upload_failed">Map upload failed</string>
|
||||
<string name="map_upload_complete">Map upload complete</string>
|
||||
<string name="map_upload_in_progress">Uploading map</string>
|
||||
<string name="updatefirmwareoperation_failed_low_mtu">Current MTU of %1$d is too low, please enable high MTU in the device settings and disconnect/re-connect the device.</string>
|
||||
<string name="chart_steps">Steps</string>
|
||||
<string name="calories">Calories</string>
|
||||
|
||||
Reference in New Issue
Block a user