diff --git a/.gitmodules b/.gitmodules index 19b292ea4..cc9db9855 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ -[submodule "fossil-hr-watchface"] - path = external/fossil-hr-watchface - url = https://codeberg.org/Freeyourgadget/fossil-hr-watchface [submodule "jerryscript"] path = external/jerryscript url = https://github.com/jerryscript-project/jerryscript +[submodule "fossil-hr-gbapps"] + path = external/fossil-hr-gbapps + url = https://codeberg.org/Freeyourgadget/fossil-hr-gbapps diff --git a/app/src/main/assets/fossil_hr/navigationApp.wapp b/app/src/main/assets/fossil_hr/navigationApp.wapp new file mode 100644 index 000000000..4510dd019 Binary files /dev/null and b/app/src/main/assets/fossil_hr/navigationApp.wapp differ diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java index 480a65850..cae5dd197 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java @@ -74,7 +74,6 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.PebbleProtocol; -import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GridAutoFitLayoutManager; @@ -544,6 +543,9 @@ public abstract class AbstractAppManagerFragment extends Fragment { if ((mGBDevice.getType() != DeviceType.FOSSILQHYBRID) || (!selectedApp.isOnDevice()) || ((selectedApp.getType() != GBDeviceApp.Type.WATCHFACE) && (selectedApp.getType() != GBDeviceApp.Type.APP_GENERIC))) { menu.removeItem(R.id.appmanager_app_download); } + if (mGBDevice.getType() == DeviceType.FOSSILQHYBRID && selectedApp.getName().equals("workoutApp")) { + menu.removeItem(R.id.appmanager_app_delete); + } if (mGBDevice.getType() == DeviceType.PEBBLE) { switch (selectedApp.getType()) { 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 99a7a0145..c2c1569bf 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 @@ -310,13 +310,11 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator { return device.getType() == DeviceType.FOSSILQHYBRID; } - @Override public int getDeviceNameResource() { return R.string.devicetype_qhybrid; } - @Override public int getDefaultIconResource() { return R.drawable.ic_device_zetime; @@ -326,4 +324,9 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator { public int getDisabledIconResource() { return R.drawable.ic_device_zetime_disabled; } + + @Override + public boolean supportsNavigation() { + return isHybridHR(); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/QHybridSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/QHybridSupport.java index 3c757b304..7f91178a3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/QHybridSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/QHybridSupport.java @@ -59,6 +59,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; import nodomain.freeyourgadget.gadgetbridge.model.GenericItem; import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; @@ -840,4 +841,9 @@ public class QHybridSupport extends QHybridBaseSupport { } } } + + @Override + public void onSetNavigationInfo(NavigationInfoSpec navigationInfoSpec) { + ((FossilHRWatchAdapter) watchAdapter).onSetNavigationInfo(navigationInfoSpec); + } } 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 e77c9426d..ac491b8f1 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 @@ -23,6 +23,7 @@ import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.reque import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.music.MusicControlRequest.MUSIC_PHONE_REQUEST; import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.music.MusicControlRequest.MUSIC_WATCH_REQUEST; import static nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil.convertDrawableToBitmap; +import static nodomain.freeyourgadget.gadgetbridge.util.GB.NOTIFICATION_CHANNEL_ID; import static nodomain.freeyourgadget.gadgetbridge.util.StringUtils.shortenPackageName; import android.app.Service; @@ -49,6 +50,7 @@ import android.os.Messenger; import android.os.RemoteException; import android.widget.Toast; +import androidx.core.app.NotificationCompat; import androidx.core.content.res.ResourcesCompat; import androidx.localbroadcastmanager.content.LocalBroadcastManager; @@ -96,6 +98,7 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicContr 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.FossilHRInstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.HybridHRActivitySampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.NotificationHRConfiguration; import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample; @@ -106,6 +109,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; import nodomain.freeyourgadget.gadgetbridge.model.GenericItem; import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.Weather; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; @@ -164,6 +168,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fos import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.WidgetsPutRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.workout.WorkoutRequestHandler; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.FactoryResetRequest; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.NotificationUtils; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; @@ -193,6 +198,7 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { } private boolean saveRawActivityFiles = false; + private boolean notifiedAboutMissingNavigationApp = false; HashMap appIconCache = new HashMap<>(); String lastPostedApp = null; @@ -480,6 +486,8 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { // renderWidgets(); // dunno if there is any point in doing this at start since when no watch is connected the QHybridSupport will not receive any intents anyway + updateBuiltinAppsInCache(); + queueWrite(new SetDeviceStateRequest(GBDevice.State.INITIALIZED)); } @@ -2067,4 +2075,51 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { } return null; } + + public void onSetNavigationInfo(NavigationInfoSpec navigationInfoSpec) { + String installedAppsJson = getDeviceSupport().getDevice().getDeviceInfo("INSTALLED_APPS").getDetails(); + if (installedAppsJson == null || !installedAppsJson.contains("navigationApp")) { + if (!notifiedAboutMissingNavigationApp) { + notifiedAboutMissingNavigationApp = true; + NotificationCompat.Builder ncomp = new NotificationCompat.Builder(getContext(), NOTIFICATION_CHANNEL_ID) + .setContentTitle(getContext().getString(R.string.fossil_hr_nav_app_not_installed_notify_title)) + .setContentText(getContext().getString(R.string.fossil_hr_nav_app_not_installed_notify_text)) + .setTicker(getContext().getString(R.string.fossil_hr_nav_app_not_installed_notify_text)) + .setSmallIcon(R.drawable.ic_notification) + .setAutoCancel(true); + GB.notify((int) System.currentTimeMillis(), ncomp.build(), getContext()); + GB.toast(getContext().getString(R.string.fossil_hr_nav_app_not_installed_notify_text), Toast.LENGTH_LONG, GB.WARN); + } + return; + } + try { + JSONObject navJson = new JSONObject() + .put("push", new JSONObject() + .put("set", new JSONObject() + .put("navigationApp._.config.info", new JSONObject() + .put("distance", navigationInfoSpec.distanceToTurn) + .put("eta", navigationInfoSpec.ETA) + .put("instruction", navigationInfoSpec.instruction) + .put("nextAction", navigationInfoSpec.nextAction) + ) + ) + ); + + queueWrite(new JsonPutRequest(navJson, this)); + } catch (JSONException e) { + LOG.error("JSON exception: ", e); + } + } + + private void updateBuiltinAppsInCache() { + FossilFileReader fileReader; + try { + fileReader = new FossilFileReader(FileUtils.getUriForAsset("fossil_hr/navigationApp.wapp", getContext()), getContext()); + if (FossilHRInstallHandler.saveAppInCache(fileReader, fileReader.getBackground(), fileReader.getPreview(), getDeviceSupport().getDevice().getDeviceCoordinator(), getContext())) { + LOG.info("Successfully copied navigationApp for Fossil Hybrids to cache"); + } + } catch (IOException e) { + LOG.warn("Could not copy navigationApp to cache", e); + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FileUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FileUtils.java index 93b05e573..94a9779e6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FileUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FileUtils.java @@ -22,6 +22,8 @@ import android.content.Context; import android.net.Uri; import android.os.Environment; +import androidx.annotation.NonNull; + import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; @@ -39,7 +41,6 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; -import androidx.annotation.NonNull; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.GBEnvironment; @@ -337,6 +338,7 @@ public class FileUtils { public static String makeValidFileName(String name) { return name.replaceAll("[\0/:\\r\\n\\\\]", "_"); } + /** *Returns extension of a file * @param file string filename @@ -349,4 +351,25 @@ public class FileUtils { } return extension; } + + /** + * Returns a Uri referencing a temporary file with the contents of the given asset + * @param assetPath relative path to the assets file + * @param context current context for getting AssetManager + * @return Uri that points to the created temporary file + * @throws IOException thrown when a file could not be created or opened + */ + public static Uri getUriForAsset(String assetPath, Context context) throws IOException { + File tempFile = File.createTempFile("tmpfile" + System.currentTimeMillis(), null); + tempFile.deleteOnExit(); + FileOutputStream fos = new FileOutputStream(tempFile); + InputStream asset = context.getAssets().open(assetPath); + byte[] buffer = new byte[1024]; + int read; + while ((read = asset.read(buffer)) != -1) { + fos.write(buffer, 0, read); + } + fos.close(); + return Uri.fromFile(tempFile); + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 574a65259..de0401877 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2386,4 +2386,6 @@ Select whether device uses Celsius or Fahrenheit scale. Celsius Fahrenheit + Navigation app not installed on watch + Navigation started but navigationApp not installed on watch. Please install it from the App Manager. diff --git a/external/build_fossil_hr_watchface.sh b/external/build_fossil_hr_gbapps.sh similarity index 68% rename from external/build_fossil_hr_watchface.sh rename to external/build_fossil_hr_gbapps.sh index 28a8acf3d..82a341c4e 100755 --- a/external/build_fossil_hr_watchface.sh +++ b/external/build_fossil_hr_gbapps.sh @@ -4,8 +4,8 @@ gcc_version="$(gcc -v 2>&1 | grep -oe '^gcc version [0-9][0-9\.]*[0-9]' | sed 's (( gcc_version > 11 )) && git apply ../patches/jerryscript-gcc-12-build-fix.patch python3 tools/build.py --jerry-cmdline-snapshot ON popd -pushd fossil-hr-watchface -export jerry=../jerryscript/build/bin/jerry-snapshot +pushd fossil-hr-gbapps/watchface +export jerry=../../jerryscript/build/bin/jerry-snapshot $jerry generate -f '' open_source_watchface.js -o openSourceWatchface.bin $jerry generate -f '' widget_date.js -o widgetDate.bin $jerry generate -f '' widget_weather.js -o widgetWeather.bin @@ -19,4 +19,10 @@ $jerry generate -f '' widget_chanceofrain.js -o widgetChanceOfRain.bin $jerry generate -f '' widget_uv.js -o widgetUV.bin $jerry generate -f '' widget_custom.js -o widgetCustom.bin popd -mv fossil-hr-watchface/*.bin ../app/src/main/assets/fossil_hr/ +mv fossil-hr-gbapps/watchface/*.bin ../app/src/main/assets/fossil_hr/ +pushd fossil-hr-gbapps/navigationApp +mkdir -p build/files/{code,config,display_name,icons,layout} +$jerry generate -f '' app.js -o build/files/code/navigationApp +python3 ../../pack.py -i build/ -o navigationApp.wapp +popd +mv fossil-hr-gbapps/navigationApp/navigationApp.wapp ../app/src/main/assets/fossil_hr/ diff --git a/external/fossil-hr-gbapps b/external/fossil-hr-gbapps new file mode 160000 index 000000000..0d8312b39 --- /dev/null +++ b/external/fossil-hr-gbapps @@ -0,0 +1 @@ +Subproject commit 0d8312b39771e08aa7bd1a23c114beaffae0ef11 diff --git a/external/fossil-hr-watchface b/external/fossil-hr-watchface deleted file mode 160000 index 24247ae23..000000000 --- a/external/fossil-hr-watchface +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 24247ae23e1b903ddcc1e4e9cfb4ad7280a77db2 diff --git a/external/pack.py b/external/pack.py new file mode 100644 index 000000000..4326724da --- /dev/null +++ b/external/pack.py @@ -0,0 +1,124 @@ +# File downloaded from https://github.com/dakhnod/Fossil-HR-SDK/ + +import sys +import os +import json +import crc32c +import getopt + +class Packer: + def __init__(self): + self.file_block = bytearray() + + def put_int(self, content, length=4): + self.file_block.extend(content.to_bytes(length, 'little')) + + def pack(self, input_dir_path, output_file_path): + start_path = os.getcwd() + + if not os.path.isdir(input_dir_path): + print('cannot find dir %s' % input_dir_path) + exit() + os.chdir(input_dir_path) + + with open('app.json', 'r') as json_file: + app_meta = json.load(json_file) + + os.chdir('files') + + all_files = [] + dir_sizes = {} + + for files_dir_list in [('code', False), ('icons', False), ('layout', True), ('display_name', True), ('config', True)]: + dir_size = 0 + files_dir = files_dir_list[0] + append_null = files_dir_list[1] + files = os.listdir(files_dir) + os.chdir(files_dir) + for file in sorted(files): + print(f'packing {file}') + with open(file, 'rb')as f: + contents = bytearray(f.read()) + if append_null: + contents.append(0) + file_size = contents.__len__() + all_files.append({ + 'filename': file, + 'contents': contents, + 'size': file_size + }) + dir_size = dir_size + file_size + file.__len__() + 4 # null byte + size bytes + os.chdir(os.pardir) + dir_sizes[files_dir] = dir_size + + offset_code = 88 + offset_icons = offset_code + dir_sizes['code'] + offset_layout = offset_icons + dir_sizes['icons'] + offset_display_name = offset_layout + dir_sizes['layout'] + offset_config = offset_display_name + dir_sizes['display_name'] + offset_file_end = offset_config + dir_sizes['config'] + + self.file_block.extend([int(octet) for octet in app_meta['version'].split('.')]) + + self.put_int(0) + self.put_int(0) + self.put_int(offset_code) + self.put_int(offset_icons) + self.put_int(offset_layout) + self.put_int(offset_display_name) + self.put_int(offset_display_name) + self.put_int(offset_config) + self.put_int(offset_file_end) + self.put_int(0) + self.put_int(0) + self.put_int(0) + self.put_int(0) + self.put_int(0) + self.put_int(0) + self.put_int(0) + self.put_int(0) + self.put_int(0) + + for file in all_files: + filename = file['filename'] + self.put_int(filename.__len__() + 1, 1) + self.file_block.extend(filename.encode('utf-8')) + self.put_int(0, 1) # null byte ending + self.put_int(file['size'], 2) + self.file_block.extend(file['contents']) + + os.chdir(start_path) + + identifier = all_files[0]['filename'] + + full_file = bytearray() + full_file.extend([0xFE, 0x15]) # file handle + full_file.extend([0x03, 0x00]) # file version + full_file.extend(int(0).to_bytes(4, 'little')) # file offset + full_file.extend(self.file_block.__len__().to_bytes(4, 'little')) # file size + full_file.extend(self.file_block) + full_file.extend(crc32c.crc32c(self.file_block).to_bytes(4, 'little')) + + if output_file_path is None: + output_file_path = identifier + + with open(output_file_path, 'wb') as output_file: + output_file.write(full_file) + + + +def main(): + packer = Packer() + input_dir_path = None + output_file_path = None + args, remainder = getopt.getopt(sys.argv[1:], 'i:o:', ['input=', 'output=']) + for key, value in args: + if key in ['-i', '--input']: + input_dir_path = value + elif key in ['-o', '--output']: + output_file_path = value + packer.pack(input_dir_path, output_file_path) + + +if __name__ == '__main__': + main()