/* 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 <https://www.gnu.org/licenses/>. */
|
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<String> filenamesIcons;
|
ArrayList<String> filenamesLayout;
|
ArrayList<String> filenamesDisplayName;
|
ArrayList<String> 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<String> 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<String> parseAppFilenames(ByteBuffer buf, int untilPosition, boolean cutTrailingNull) {
|
ArrayList<String> 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;
|
}
|
}
|