From 9dac0a80c3fb073bcb3bf154edb2cb62eb993fe0 Mon Sep 17 00:00:00 2001 From: Arjan Schrijver Date: Mon, 24 May 2021 21:49:54 +0200 Subject: [PATCH] Fossil Hybrid HR: Add watch app and watchface support to install handler --- .../devices/qhybrid/FossilFileReader.java | 183 ++++++++++++++++++ .../qhybrid/FossilHRInstallHandler.java | 74 +++---- .../devices/qhybrid/QHybridCoordinator.java | 12 +- .../fossil_hr/FossilHRWatchAdapter.java | 72 ++++--- app/src/main/res/values/strings.xml | 3 +- 5 files changed, 272 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilFileReader.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilFileReader.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilFileReader.java new file mode 100644 index 000000000..374e35b42 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilFileReader.java @@ -0,0 +1,183 @@ +/* Copyright (C) 2021 Arjan Schrijver, Daniel Dakhno + + 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.devices.qhybrid; + +import android.content.Context; +import android.net.Uri; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.Charset; +import java.util.ArrayList; + +import nodomain.freeyourgadget.gadgetbridge.util.UriHelper; + +/** + * Reads and parses files meant to be uploaded to Fossil Hybrid Q & HR watches. + * These can be firmware files, watch apps and watchfaces (HR only). + */ +public class FossilFileReader { + private static final Logger LOG = LoggerFactory.getLogger(FossilFileReader.class); + private final UriHelper uriHelper; + private boolean isValid = false; + private boolean isFirmware = false; + private boolean isApp = false; + private boolean isWatchface = false; + private String foundVersion = "(Unknown version)"; + private String foundName = "(unknown)"; + + public FossilFileReader(Uri uri, Context context) throws IOException { + uriHelper = UriHelper.get(uri, context); + + try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) { + // Read just the first 32 bytes for file type detection + byte[] bytes = new byte[32]; + int read = in.read(bytes); + in.close(); + if (read < 32) { + isValid = false; + return; + } + ByteBuffer buf = ByteBuffer.wrap(bytes); + buf.order(ByteOrder.LITTLE_ENDIAN); + + short handle = buf.getShort(); + short version = buf.getShort(); + if ((handle == 5630) && (version == 3)) { + // This is a watch app or watch face + isValid = true; + isApp = true; + parseApp(); + return; + } + + // Back to byte 0 for firmware detection + buf.rewind(); + int header0 = buf.getInt(); + buf.getInt(); // size + int header2 = buf.getInt(); + int header3 = buf.getInt(); + if (header0 != 1 || header2 != 0x00012000 || header3 != 0x00012000) { + return; + } + + buf.getInt(); // unknown + isValid = true; + isFirmware = true; + parseFirmware(); + } catch (Exception e) { + LOG.warn("Error during Fossil file parsing", e); + } + } + + private void parseFirmware() throws IOException { + InputStream in = new BufferedInputStream(uriHelper.openInputStream()); + byte[] bytes = new byte[in.available()]; + in.read(bytes); + in.close(); + ByteBuffer buf = ByteBuffer.wrap(bytes); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.position(20); + int version1 = buf.get() % 0xff; + int version2 = buf.get() & 0xff; + foundVersion = "DN1.0." + version1 + "." + version2; + foundName = "Fossil Hybrid HR firmware"; + } + + private void parseApp() throws IOException { + InputStream in = new BufferedInputStream(uriHelper.openInputStream()); + byte[] bytes = new byte[in.available()]; + in.read(bytes); + in.close(); + ByteBuffer buf = ByteBuffer.wrap(bytes); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.position(8); // skip file handle and version + int fileSize = buf.getInt(); + foundVersion = (int)buf.get() + "." + (int)buf.get() + "." + (int)buf.get() + "." + (int)buf.get(); + buf.position(buf.position() + 8); // skip null bytes + int jerryStart = buf.getInt(); + int appIconStart = buf.getInt(); + int layout_start = buf.getInt(); + int display_name_start = buf.getInt(); + int display_name_start_2 = buf.getInt(); + int config_start = buf.getInt(); + int file_end = buf.getInt(); + buf.position(jerryStart); + + ArrayList filenamesCode = parseAppFilenames(buf, appIconStart,false); + if (filenamesCode.size() > 0) { + foundName = filenamesCode.get(0); + } + ArrayList filenamesIcons = parseAppFilenames(buf, layout_start,false); + ArrayList filenamesLayout = parseAppFilenames(buf, display_name_start,true); + ArrayList filenamesDisplayName = parseAppFilenames(buf, config_start,true); + if (filenamesDisplayName.contains("theme_class")) { + isApp = false; + isWatchface = true; + } + } + + private ArrayList parseAppFilenames(ByteBuffer buf, int untilPosition, boolean cutTrailingNull) { + ArrayList list = new ArrayList<>(); + while (buf.position() < untilPosition) { + int filenameLength = (int)buf.get(); + byte[] filenameBytes = new byte[filenameLength - 1]; + buf.get(filenameBytes); + buf.get(); + list.add(new String(filenameBytes, Charset.forName("UTF8"))); + int filesize = buf.getShort(); + if (cutTrailingNull) { + filesize -= 1; + } + buf.position(buf.position() + filesize); // skip file data for now + if (cutTrailingNull) { + buf.get(); + } + } + return list; + } + + public boolean isValid() { + return isValid; + } + + public boolean isFirmware() { + return isFirmware; + } + + public boolean isApp() { + return isApp; + } + + public boolean isWatchface() { + return isWatchface; + } + + public String getVersion() { + return foundVersion; + } + + public String getName() { + return foundName; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilHRInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilHRInstallHandler.java index 8b5359747..9445df48f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilHRInstallHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilHRInstallHandler.java @@ -19,11 +19,7 @@ package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid; import android.content.Context; import android.net.Uri; -import java.io.BufferedInputStream; import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity; @@ -31,50 +27,19 @@ import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; import nodomain.freeyourgadget.gadgetbridge.model.GenericItem; -import nodomain.freeyourgadget.gadgetbridge.util.UriHelper; public class FossilHRInstallHandler implements InstallHandler { + private final Uri mUri; private final Context mContext; - private boolean mIsValid; - private String mVersion = "(Unknown version)"; + private FossilFileReader fossilFile; FossilHRInstallHandler(Uri uri, Context context) { + mUri = uri; mContext = context; - UriHelper uriHelper; try { - uriHelper = UriHelper.get(uri, mContext); - } catch (IOException e) { - mIsValid = false; - return; + fossilFile = new FossilFileReader(uri, mContext); + } catch (IOException ignored) { } - try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) { - byte[] bytes = new byte[32]; - int read = in.read(bytes); - if (read < 32) { - mIsValid = false; - return; - } - - ByteBuffer buf = ByteBuffer.wrap(bytes); - buf.order(ByteOrder.LITTLE_ENDIAN); - int header0 = buf.getInt(); - buf.getInt(); // size - int header2 = buf.getInt(); - int header3 = buf.getInt(); - if (header0 != 1 || header2 != 0x00012000 || header3 != 0x00012000) { - mIsValid = false; - return; - } - - buf.getInt(); // unknown - int version1 = buf.get() % 0xff; - int version2 = buf.get() & 0xff; - mVersion = "DN1.0." + version1 + "." + version2; - } catch (Exception e) { - mIsValid = false; - return; - } - mIsValid = true; } @Override @@ -84,17 +49,32 @@ public class FossilHRInstallHandler implements InstallHandler { installActivity.setInstallEnabled(false); return; } - if (device.getType() != DeviceType.FOSSILQHYBRID || !device.isConnected()) { + if (device.getType() != DeviceType.FOSSILQHYBRID || !device.isConnected() || !fossilFile.isValid()) { installActivity.setInfoText("Element cannot be installed"); installActivity.setInstallEnabled(false); return; } GenericItem installItem = new GenericItem(); - installItem.setIcon(R.drawable.ic_firmware); - installItem.setName("Fossil Hybrid HR Firmware"); - installItem.setDetails(mVersion); - - installActivity.setInfoText(mContext.getString(R.string.firmware_install_warning, "(unknown)")); + if (fossilFile.isFirmware()) { + installItem.setIcon(R.drawable.ic_firmware); + installItem.setName(fossilFile.getName()); + installItem.setDetails(fossilFile.getVersion()); + installActivity.setInfoText(mContext.getString(R.string.firmware_install_warning, "(unknown)")); + } else if (fossilFile.isApp()) { + installItem.setName(fossilFile.getName()); + installItem.setDetails(fossilFile.getVersion()); + installItem.setIcon(R.drawable.ic_watchapp); + installActivity.setInfoText(mContext.getString(R.string.app_install_info, installItem.getName(), fossilFile.getVersion(), "(unknown)")); + } else if (fossilFile.isWatchface()) { + installItem.setName(fossilFile.getName()); + installItem.setDetails(fossilFile.getVersion()); + installItem.setIcon(R.drawable.ic_watchface); + installActivity.setInfoText(mContext.getString(R.string.watchface_install_info, installItem.getName(), fossilFile.getVersion(), "(unknown)")); + } else { + installActivity.setInfoText("Element cannot be installed"); + installActivity.setInstallEnabled(false); + return; + } installActivity.setInstallEnabled(true); installActivity.setInstallItem(installItem); } @@ -106,6 +86,6 @@ public class FossilHRInstallHandler implements InstallHandler { @Override public boolean isValid() { - return mIsValid; + return fossilFile.isValid(); } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java index b071bce9a..de98296a0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java @@ -28,6 +28,9 @@ import android.os.ParcelUuid; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.Collection; import java.util.Collections; import java.util.regex.Matcher; @@ -48,6 +51,8 @@ import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; import nodomain.freeyourgadget.gadgetbridge.util.Version; public class QHybridCoordinator extends AbstractDeviceCoordinator { + private static final Logger LOG = LoggerFactory.getLogger(QHybridCoordinator.class); + @NonNull @Override public DeviceType getSupportedType(GBDeviceCandidate candidate) { @@ -103,7 +108,12 @@ public class QHybridCoordinator extends AbstractDeviceCoordinator { public InstallHandler findInstallHandler(Uri uri, Context context) { if (isHybridHR()) { FossilHRInstallHandler installHandler = new FossilHRInstallHandler(uri, context); - return installHandler.isValid() ? installHandler : null; + if (!installHandler.isValid()) { + LOG.warn("Not a Fossil Hybrid firmware or app!"); + return null; + } else { + return installHandler; + } } FossilInstallHandler installHandler = new FossilInstallHandler(uri, context); return installHandler.isValid() ? installHandler : null; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java index 0aa001121..784532bba 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java @@ -27,6 +27,7 @@ import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Build; import android.os.Handler; import android.os.Looper; @@ -38,11 +39,13 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.BufferOverflowException; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -66,6 +69,7 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.CommuteActionsActivity; +import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilFileReader; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.HybridHRActivitySampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.NotificationHRConfiguration; import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample; @@ -127,6 +131,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.mis import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; +import nodomain.freeyourgadget.gadgetbridge.util.UriHelper; import nodomain.freeyourgadget.gadgetbridge.util.Version; import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.music.MusicControlRequest.MUSIC_PHONE_REQUEST; @@ -692,32 +697,10 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { @Override public void uploadFileIncludesHeader(String filePath) { final Intent resultIntent = new Intent(QHybridSupport.QHYBRID_ACTION_UPLOADED_FILE); - byte[] fileData; - try { FileInputStream fis = new FileInputStream(filePath); - fileData = new byte[fis.available()]; - fis.read(fileData); + uploadFileIncludesHeader(fis); fis.close(); - - short handleBytes = (short) (fileData[0] & 0xFF | ((fileData[1] & 0xFF) << 8)); - FileHandle handle = FileHandle.fromHandle(handleBytes); - - if (handle == null) { - throw new RuntimeException("unknown handle"); - } - - queueWrite(new FilePutRawRequest(handle, fileData, this) { - @Override - public void onFilePut(boolean success) { - resultIntent.putExtra("EXTRA_SUCCESS", success); - LocalBroadcastManager.getInstance(getContext()).sendBroadcast(resultIntent); - } - }); - - if (handle == FileHandle.APP_CODE) { - listApplications(); - } } catch (Exception e) { LOG.error("Error while uploading file", e); resultIntent.putExtra("EXTRA_SUCCESS", false); @@ -725,6 +708,31 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { } } + private void uploadFileIncludesHeader(InputStream fis) throws IOException { + final Intent resultIntent = new Intent(QHybridSupport.QHYBRID_ACTION_UPLOADED_FILE); + byte[] fileData = new byte[fis.available()]; + fis.read(fileData); + + short handleBytes = (short) (fileData[0] & 0xFF | ((fileData[1] & 0xFF) << 8)); + FileHandle handle = FileHandle.fromHandle(handleBytes); + + if (handle == null) { + throw new RuntimeException("unknown handle"); + } + + queueWrite(new FilePutRawRequest(handle, fileData, this) { + @Override + public void onFilePut(boolean success) { + resultIntent.putExtra("EXTRA_SUCCESS", success); + LocalBroadcastManager.getInstance(getContext()).sendBroadcast(resultIntent); + } + }); + + if (handle == FileHandle.APP_CODE) { + listApplications(); + } + } + @Override public void downloadFile(final FileHandle handle, boolean fileIsEncrypted) { if (fileIsEncrypted) { @@ -776,6 +784,24 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { } } + @Override + public void onInstallApp(Uri uri) { + final Intent resultIntent = new Intent(QHybridSupport.QHYBRID_ACTION_UPLOADED_FILE); + FossilFileReader fossilFile; + try { + fossilFile = new FossilFileReader(uri, getContext()); + if (fossilFile.isFirmware()) { + super.onInstallApp(uri); + } else if (fossilFile.isApp() || fossilFile.isWatchface()) { + UriHelper uriHelper = UriHelper.get(uri, getContext()); + InputStream in = new BufferedInputStream(uriHelper.openInputStream()); + uploadFileIncludesHeader(in); + in.close(); + } + } catch (Exception ignored) { + } + } + private void negotiateSymmetricKey() { try { queueWrite(new VerifyPrivateKeyRequest( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e4d82a961..9f7c87cc6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -344,7 +344,8 @@ Installation failed Installed YOU ARE TRYING TO INSTALL A FIRMWARE, PROCEED AT YOUR OWN RISK.\n\n\n This firmware is for HW Revision: %s - You are about to install the following app:\n\n\n%1$s Version %2$s by %3$s\n + You are about to install the following app:\n\n%1$s\nVersion %2$s by %3$s\n + You are about to install the following watchface:\n\n%1$s\nVersion %2$s by %3$s\n N/A initialized %1$s by %2$s