/* Copyright (C) 2021-2024 Andreas Shimokawa, Arjan Schrijver 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.graphics.Bitmap; import android.net.Uri; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.ImageConverter; import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil; 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 Uri uri; 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)"; private GBDeviceApp app; private JSONObject mAppKeys; private int jerryStart; private int appIconStart; private int layout_start; private int display_name_start; private int display_name_start_2; private int config_start; private int file_end; ArrayList filenamesIcons; ArrayList filenamesLayout; ArrayList filenamesDisplayName; ArrayList filenamesConfig; public FossilFileReader(Uri uri, Context context) throws IOException { this.uri = uri; 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(); byte version = buf.get(); 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 && header2 != 0x00020000) || (header3 != 0x00012000 && header3 != 0x00020000)) { 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 = version1 + "." + version2; foundName = "Fossil/Skagen Hybrid HR firmware"; } private void parseApp() throws IOException, JSONException { mAppKeys = new JSONObject(); mAppKeys.put("creator", "(unknown)"); 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(); buf.get(); // 1 = watchface, 2 = app foundVersion = (int)buf.get() + "." + (int)buf.get(); buf.get(); // unknown mAppKeys.put("version", foundVersion); buf.position(buf.position() + 8); // skip null bytes jerryStart = buf.getInt(); appIconStart = buf.getInt(); layout_start = buf.getInt(); display_name_start = buf.getInt(); display_name_start_2 = buf.getInt(); config_start = buf.getInt(); file_end = buf.getInt(); buf.position(jerryStart); ArrayList filenamesCode = parseAppFilenames(buf, appIconStart,false); if (filenamesCode.size() > 0) { foundName = filenamesCode.get(0); mAppKeys.put("name", foundName); mAppKeys.put("uuid", UUID.nameUUIDFromBytes(foundName.getBytes(StandardCharsets.UTF_8))); } filenamesIcons = parseAppFilenames(buf, layout_start,false); filenamesLayout = parseAppFilenames(buf, display_name_start,true); filenamesDisplayName = parseAppFilenames(buf, config_start,true); filenamesConfig = parseAppFilenames(buf, file_end,true); if (filenamesDisplayName.contains("theme_class")) { isApp = false; isWatchface = true; mAppKeys.put("type", "WATCHFACE"); } else { mAppKeys.put("type", "APP_GENERIC"); } app = new GBDeviceApp(mAppKeys, false, null); } 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 JSONObject getConfigJSON(String filename) throws IOException, JSONException { byte[] fileBytes = getFileContentsByName(filename, config_start, file_end, true); String fileString = new String(fileBytes, StandardCharsets.UTF_8); JSONTokener jsonTokener = new JSONTokener(fileString); return new JSONObject(jsonTokener); } public Bitmap getPreview() { try { if ((filenamesIcons != null) && (filenamesIcons.contains("!preview.rle"))) { return BitmapUtil.getCircularBitmap(ImageConverter.decodeFromRLEImage(getImageFileContents("!preview.rle"))); } if ((filenamesIcons != null) && (filenamesIcons.contains("!preview"))) { return BitmapUtil.getCircularBitmap(ImageConverter.decodeFromRLEImage(getImageFileContents("!preview"))); } } catch (IOException e) { LOG.warn("Couldn't read preview image from wapp file: ", e); return null; } LOG.warn("No preview image found in wapp file"); return null; } public Bitmap getBackground() { try { if (filenamesIcons != null) { String filename = null; if (filenamesIcons.contains("background.raw")) { filename = "background.raw"; } else if (filenamesIcons.contains("background")) { filename = "background"; } else { JSONObject config = getConfigJSON("customWatchFace"); JSONArray layout = config.getJSONArray("layout"); JSONObject firstLayoutItem = layout.getJSONObject(0); if (firstLayoutItem.getString("type").equals("image")) { filename = firstLayoutItem.getString("name"); } } if (filename != null) { byte[] rawImage = getImageFileContents(filename); Bitmap decodedImage = ImageConverter.decodeFromRAWImage(rawImage, 240, 240); return BitmapUtil.getCircularBitmap(decodedImage); } } } catch (Exception e) { LOG.warn("Couldn't read background image from wapp file: ", e); return null; } LOG.warn("No background image found in wapp file"); return null; } private byte[] getImageFileContents(String filename) throws IOException { return getFileContentsByName(filename, appIconStart, layout_start, false); } private byte[] getFileContentsByName(String filename, int startPos, int endPos, boolean cutTrailingNull) 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(startPos); while (buf.position() < endPos) { int filenameLength = (int)buf.get(); byte[] filenameBytes = new byte[filenameLength - 1]; buf.get(filenameBytes); buf.get(); String foundFilename = new String(filenameBytes, StandardCharsets.UTF_8); int filesize = buf.getShort(); if (cutTrailingNull) { filesize -= 1; } if (foundFilename.equals(filename)) { byte[] fileBytes = new byte[filesize]; buf.get(fileBytes); return fileBytes; } else { buf.position(buf.position() + filesize); } if (cutTrailingNull) { buf.get(); } } throw new FileNotFoundException(); } public boolean isValid() { return isValid; } public boolean isFirmware() { return isFirmware; } public boolean isApp() { return isApp; } public boolean isWatchface() { return isWatchface; } public Uri getUri() { return uri; } public String getVersion() { return foundVersion; } public String getName() { return foundName; } public GBDeviceApp getGBDeviceApp() { return app; } public JSONObject getAppKeysJSON() { return mAppKeys; } }