From b259425361d11bf1de3f1a4797488a73ea494d2d Mon Sep 17 00:00:00 2001 From: Philoul Date: Fri, 4 Aug 2023 14:59:08 +0200 Subject: [PATCH] Add Custom Watchface --- _docs/icons/HourHand.svg | 9 + _docs/icons/MinuteHand.svg | 9 + _docs/icons/SecondHand.svg | 11 + _docs/icons/SimplifiedDial.svg | 18 + _docs/icons/background.svg | 8 + _docs/icons/detailed_dial.svg | 70 +++ _docs/icons/export_custom.svg | 38 ++ _docs/icons/load_custom.svg | 38 ++ _docs/icons/send_custom.svg | 16 + _docs/icons/set_default.svg | 20 + app-wear-shared/shared/build.gradle | 1 + .../rx/events/EventMobileDataToWear.kt | 5 + .../rx/events/EventWearCwfExported.kt | 5 + .../rx/events/EventWearDataToMobile.kt | 5 + .../rx/events/EventWearUpdateGui.kt | 3 + .../rx/weardata/CustomWatchfaceFormat.kt | 140 ++++++ .../info/nightscout/rx/weardata/EventData.kt | 26 ++ .../info/nightscout/shared/utils/DateUtil.kt | 4 + .../src/main/res/drawable/background.xml | 9 + .../src/main/res/drawable/detailed_dial.xml | 183 ++++++++ .../src/main/res/drawable/hour_hand.xml | 4 + .../src/main/res/drawable/minute_hand.xml | 4 + .../src/main/res/drawable/second_hand.xml | 4 + .../src/main/res/drawable/simplified_dial.xml | 39 ++ .../main/res/drawable/watchface_custom.png | Bin 0 -> 41646 bytes .../shared/src/main/res/values/strings.xml | 8 + .../shared/src/main/res/values/wear_paths.xml | 1 + .../maintenance/CustomWatchfaceFile.kt | 21 + .../maintenance/ImportExportPrefs.kt | 4 + .../maintenance/PrefFileListProvider.kt | 2 + .../src/main/AndroidManifest.xml | 4 + .../DaggerAppCompatActivityWithResult.kt | 3 + .../configuration/di/ConfigurationModule.kt | 4 + .../CustomWatchfaceFileContract.kt | 24 ++ .../maintenance/ImportExportPrefsImpl.kt | 26 +- .../maintenance/PrefFileListProviderImpl.kt | 21 + .../CustomWatchfaceImportListActivity.kt | 112 +++++ .../formats/ZipCustomWatchfaceFormat.kt | 128 ++++++ .../custom_watchface_import_list_activity.xml | 19 + .../custom_watchface_import_list_item.xml | 142 +++++++ .../src/main/res/values/strings.xml | 5 + plugins/main/src/main/AndroidManifest.xml | 9 + .../plugins/general/wear/WearFragment.kt | 75 +++- .../plugins/general/wear/WearPlugin.kt | 2 + .../wear/wearintegration/DataHandlerMobile.kt | 28 +- .../DataLayerListenerServiceMobile.kt | 16 +- .../src/main/res/drawable/export_custom.xml | 80 ++++ .../src/main/res/drawable/load_custom.xml | 42 ++ .../src/main/res/drawable/send_custom.xml | 7 + .../src/main/res/drawable/set_default.xml | 7 + .../src/main/res/layout/wear_fragment.xml | 213 ++++++++-- plugins/main/src/main/res/values/strings.xml | 5 + wear/src/main/AndroidManifest.xml | 35 ++ .../androidaps/comm/DataHandlerWear.kt | 24 ++ .../comm/DataLayerListenerServiceWear.kt | 16 + .../androidaps/di/WearServicesModule.kt | 1 + .../interaction/utils/Persistence.kt | 29 ++ .../androidaps/watchfaces/CustomWatchface.kt | 398 ++++++++++++++++++ .../watchfaces/utils/BaseWatchFace.kt | 31 +- .../watchfaces/utils/WatchfaceViewAdapter.kt | 58 +-- wear/src/main/res/layout/activity_custom.xml | 379 +++++++++++++++++ wear/src/main/res/values/strings.xml | 10 + .../xml/watch_face_configuration_custom.xml | 27 ++ 63 files changed, 2619 insertions(+), 66 deletions(-) create mode 100644 _docs/icons/HourHand.svg create mode 100644 _docs/icons/MinuteHand.svg create mode 100644 _docs/icons/SecondHand.svg create mode 100644 _docs/icons/SimplifiedDial.svg create mode 100644 _docs/icons/background.svg create mode 100644 _docs/icons/detailed_dial.svg create mode 100644 _docs/icons/export_custom.svg create mode 100644 _docs/icons/load_custom.svg create mode 100644 _docs/icons/send_custom.svg create mode 100644 _docs/icons/set_default.svg create mode 100644 app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventMobileDataToWear.kt create mode 100644 app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearCwfExported.kt create mode 100644 app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearDataToMobile.kt create mode 100644 app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearUpdateGui.kt create mode 100644 app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/CustomWatchfaceFormat.kt create mode 100644 app-wear-shared/shared/src/main/res/drawable/background.xml create mode 100644 app-wear-shared/shared/src/main/res/drawable/detailed_dial.xml create mode 100644 app-wear-shared/shared/src/main/res/drawable/hour_hand.xml create mode 100644 app-wear-shared/shared/src/main/res/drawable/minute_hand.xml create mode 100644 app-wear-shared/shared/src/main/res/drawable/second_hand.xml create mode 100644 app-wear-shared/shared/src/main/res/drawable/simplified_dial.xml create mode 100644 app-wear-shared/shared/src/main/res/drawable/watchface_custom.png create mode 100644 core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/CustomWatchfaceFile.kt create mode 100644 plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/CustomWatchfaceFileContract.kt create mode 100644 plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/activities/CustomWatchfaceImportListActivity.kt create mode 100644 plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/formats/ZipCustomWatchfaceFormat.kt create mode 100644 plugins/configuration/src/main/res/layout/custom_watchface_import_list_activity.xml create mode 100644 plugins/configuration/src/main/res/layout/custom_watchface_import_list_item.xml create mode 100644 plugins/main/src/main/res/drawable/export_custom.xml create mode 100644 plugins/main/src/main/res/drawable/load_custom.xml create mode 100644 plugins/main/src/main/res/drawable/send_custom.xml create mode 100644 plugins/main/src/main/res/drawable/set_default.xml create mode 100644 wear/src/main/java/info/nightscout/androidaps/watchfaces/CustomWatchface.kt create mode 100644 wear/src/main/res/layout/activity_custom.xml create mode 100644 wear/src/main/res/xml/watch_face_configuration_custom.xml diff --git a/_docs/icons/HourHand.svg b/_docs/icons/HourHand.svg new file mode 100644 index 0000000000..421b2d37a7 --- /dev/null +++ b/_docs/icons/HourHand.svg @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/_docs/icons/MinuteHand.svg b/_docs/icons/MinuteHand.svg new file mode 100644 index 0000000000..07e4f81fb1 --- /dev/null +++ b/_docs/icons/MinuteHand.svg @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/_docs/icons/SecondHand.svg b/_docs/icons/SecondHand.svg new file mode 100644 index 0000000000..0d66040491 --- /dev/null +++ b/_docs/icons/SecondHand.svg @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/_docs/icons/SimplifiedDial.svg b/_docs/icons/SimplifiedDial.svg new file mode 100644 index 0000000000..fbce895e01 --- /dev/null +++ b/_docs/icons/SimplifiedDial.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/_docs/icons/background.svg b/_docs/icons/background.svg new file mode 100644 index 0000000000..ea30c0c8a5 --- /dev/null +++ b/_docs/icons/background.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/_docs/icons/detailed_dial.svg b/_docs/icons/detailed_dial.svg new file mode 100644 index 0000000000..3c079740f0 --- /dev/null +++ b/_docs/icons/detailed_dial.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/_docs/icons/export_custom.svg b/_docs/icons/export_custom.svg new file mode 100644 index 0000000000..b4c4d12ae8 --- /dev/null +++ b/_docs/icons/export_custom.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_docs/icons/load_custom.svg b/_docs/icons/load_custom.svg new file mode 100644 index 0000000000..90fe7bfe37 --- /dev/null +++ b/_docs/icons/load_custom.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_docs/icons/send_custom.svg b/_docs/icons/send_custom.svg new file mode 100644 index 0000000000..70168f1472 --- /dev/null +++ b/_docs/icons/send_custom.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/_docs/icons/set_default.svg b/_docs/icons/set_default.svg new file mode 100644 index 0000000000..90e36585a2 --- /dev/null +++ b/_docs/icons/set_default.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/app-wear-shared/shared/build.gradle b/app-wear-shared/shared/build.gradle index 5e24d768e0..e69dd02310 100644 --- a/app-wear-shared/shared/build.gradle +++ b/app-wear-shared/shared/build.gradle @@ -34,6 +34,7 @@ dependencies { api 'com.github.tony19:logback-android:2.0.0' api "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version" + api "org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinx_serialization_version" api "org.apache.commons:commons-lang3:$commonslang3_version" //RxBus diff --git a/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventMobileDataToWear.kt b/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventMobileDataToWear.kt new file mode 100644 index 0000000000..9cc327ae92 --- /dev/null +++ b/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventMobileDataToWear.kt @@ -0,0 +1,5 @@ +package info.nightscout.rx.events + +import info.nightscout.rx.weardata.EventData + +class EventMobileDataToWear(val payload: EventData.ActionSetCustomWatchface) : Event() \ No newline at end of file diff --git a/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearCwfExported.kt b/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearCwfExported.kt new file mode 100644 index 0000000000..1d95780241 --- /dev/null +++ b/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearCwfExported.kt @@ -0,0 +1,5 @@ +package info.nightscout.rx.events + +import info.nightscout.rx.weardata.EventData + +class EventWearCwfExported(val payload: EventData.ActionSetCustomWatchface): Event() \ No newline at end of file diff --git a/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearDataToMobile.kt b/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearDataToMobile.kt new file mode 100644 index 0000000000..c26c8fd8b6 --- /dev/null +++ b/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearDataToMobile.kt @@ -0,0 +1,5 @@ +package info.nightscout.rx.events + +import info.nightscout.rx.weardata.EventData + +class EventWearDataToMobile(val payload: EventData) : Event() \ No newline at end of file diff --git a/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearUpdateGui.kt b/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearUpdateGui.kt new file mode 100644 index 0000000000..7aae63204a --- /dev/null +++ b/app-wear-shared/shared/src/main/java/info/nightscout/rx/events/EventWearUpdateGui.kt @@ -0,0 +1,3 @@ +package info.nightscout.rx.events + +class EventWearUpdateGui : Event() \ No newline at end of file diff --git a/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/CustomWatchfaceFormat.kt b/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/CustomWatchfaceFormat.kt new file mode 100644 index 0000000000..cef6aa181a --- /dev/null +++ b/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/CustomWatchfaceFormat.kt @@ -0,0 +1,140 @@ +package info.nightscout.rx.weardata + +import android.content.Context +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.Parcelable +import android.util.Xml +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import info.nightscout.shared.R +import kotlinx.serialization.Serializable +import org.json.JSONObject +import java.io.File + +enum class CustomWatchfaceDrawableDataKey(val key: String, @DrawableRes val icon: Int?, val fileName: String) { + UNKNOWN("unknown", null,"Unknown"), + CUSTOM_WATCHFACE("customWatchface", R.drawable.watchface_custom, "CustomWatchface"), + BACKGROUND("background", R.drawable.background, "Background"), + COVERCHART("cover_chart", null,"CoverChart"), + COVERPLATE("cover_plate", R.drawable.simplified_dial, "CoverPlate"), + HOURHAND("hour_hand", R.drawable.hour_hand,"HourHand"), + MINUTEHAND("minute_hand", R.drawable.minute_hand,"MinuteHand"), + SECONDHAND("second_hand", R.drawable.second_hand, "SecondHand"); + + companion object { + + private val keyToEnumMap = HashMap() + private val fileNameToEnumMap = HashMap() + + init { + for (value in values()) keyToEnumMap[value.key] = value + for (value in values()) fileNameToEnumMap[value.fileName] = value + } + + fun fromKey(key: String): CustomWatchfaceDrawableDataKey = + if (keyToEnumMap.containsKey(key)) { + keyToEnumMap[key] ?:UNKNOWN + } else { + UNKNOWN + } + + fun fromFileName(file: String): CustomWatchfaceDrawableDataKey = + if (fileNameToEnumMap.containsKey(file.substringBeforeLast("."))) { + fileNameToEnumMap[file.substringBeforeLast(".")] ?:UNKNOWN + } else { + UNKNOWN + } + } + +} + +enum class DrawableFormat(val extension: String) { + UNKNOWN(""), + //XML("xml"), + //svg("svg"), + JPG("jpg"), + PNG("png"); + + companion object { + + private val extensionToEnumMap = HashMap() + + init { + for (value in values()) extensionToEnumMap[value.extension] = value + } + + fun fromFileName(fileName: String): DrawableFormat = + if (extensionToEnumMap.containsKey(fileName.substringAfterLast("."))) { + extensionToEnumMap[fileName.substringAfterLast(".")] ?:UNKNOWN + } else { + UNKNOWN + } + } +} + +@Serializable +data class DrawableData(val value: ByteArray, val format: DrawableFormat) { + + fun toDrawable(resources: Resources): Drawable? { + try { + return when (format) { + DrawableFormat.PNG, DrawableFormat.JPG -> { + val bitmap = BitmapFactory.decodeByteArray(value, 0, value.size) + BitmapDrawable(resources, bitmap) + } +/* + DrawableFormat.XML -> { + val xmlInputStream = ByteArrayInputStream(value) + val xmlPullParser = Xml.newPullParser() + xmlPullParser.setInput(xmlInputStream, null) + Drawable.createFromXml(resources, xmlPullParser) + } +*/ + else -> null + } + } catch (e: Exception) { + return null + } + } +} + +typealias CustomWatchfaceDrawableDataMap = MutableMap +typealias CustomWatchfaceMetadataMap = MutableMap + +data class CustomWatchface(val json: String, var metadata: CustomWatchfaceMetadataMap, val drawableDatas: CustomWatchfaceDrawableDataMap) + +interface CustomWatchfaceFormat { + + fun saveCustomWatchface(file: File, customWatchface: EventData.ActionSetCustomWatchface) + fun loadCustomWatchface(cwfFile: File): CustomWatchface? + fun loadMetadata(contents: JSONObject): CustomWatchfaceMetadataMap +} + +enum class CustomWatchfaceMetadataKey(val key: String, @StringRes val label: Int) { + + CWF_NAME("name", R.string.metadata_label_watchface_name), + CWF_AUTHOR("author", R.string.metadata_label_watchface_author), + CWF_CREATED_AT("created_at", R.string.metadata_label_watchface_created_at), + CWF_VERSION("cwf_version", R.string.metadata_label_watchface_version); + + companion object { + + private val keyToEnumMap = HashMap() + + init { + for (value in values()) keyToEnumMap[value.key] = value + } + + fun fromKey(key: String): CustomWatchfaceMetadataKey? = + if (keyToEnumMap.containsKey(key)) { + keyToEnumMap[key] + } else { + null + } + + } + +} \ No newline at end of file diff --git a/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/EventData.kt b/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/EventData.kt index 59c8bc6fa7..88bf4fa1bc 100644 --- a/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/EventData.kt +++ b/app-wear-shared/shared/src/main/java/info/nightscout/rx/weardata/EventData.kt @@ -3,6 +3,7 @@ package info.nightscout.rx.weardata import info.nightscout.rx.events.Event import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.protobuf.ProtoBuf import org.joda.time.DateTime import java.util.Objects @@ -13,6 +14,7 @@ sealed class EventData : Event() { fun serialize() = Json.encodeToString(serializer(), this) + fun serializeByte() = ProtoBuf.encodeToByteArray(serializer(), this) companion object { fun deserialize(json: String) = try { @@ -20,6 +22,12 @@ sealed class EventData : Event() { } catch (ignored: Exception) { Error(System.currentTimeMillis()) } + + fun deserializeByte(byteArray: ByteArray) = try { + ProtoBuf.decodeFromByteArray(serializer(), byteArray) + } catch (ignored: Exception) { + Error(System.currentTimeMillis()) + } } // Mobile <- Wear @@ -142,6 +150,12 @@ sealed class EventData : Event() { @Serializable data class CancelNotification(val timeStamp: Long) : EventData() + @Serializable + data class ActionGetCustomWatchface( + val customWatchface: ActionSetCustomWatchface, + val exportFile: Boolean + ) : EventData() + @Serializable data class ActionPing(val timeStamp: Long) : EventData() @@ -267,6 +281,18 @@ sealed class EventData : Event() { val validTo: Int ) : EventData() } + @Serializable + data class ActionSetCustomWatchface( + val name: String, + val json: String, + val drawableDataMap: CustomWatchfaceDrawableDataMap + ) : EventData() + + @Serializable + data class ActionrequestCustomWatchface(val exportFile: Boolean) : EventData() + + @Serializable + data class ActionrequestSetDefaultWatchface(val timeStamp: Long) : EventData() @Serializable data class ActionProfileSwitchOpenActivity(val timeShift: Int, val percentage: Int) : EventData() diff --git a/app-wear-shared/shared/src/main/java/info/nightscout/shared/utils/DateUtil.kt b/app-wear-shared/shared/src/main/java/info/nightscout/shared/utils/DateUtil.kt index 1ed083888d..7958f7403a 100644 --- a/app-wear-shared/shared/src/main/java/info/nightscout/shared/utils/DateUtil.kt +++ b/app-wear-shared/shared/src/main/java/info/nightscout/shared/utils/DateUtil.kt @@ -150,6 +150,10 @@ class DateUtil @Inject constructor(private val context: Context) { return DateTime(mills).toString(DateTimeFormat.forPattern(format)) } + fun secondString(): String = secondString(now()) + fun secondString(mills: Long): String = + DateTime(mills).toString(DateTimeFormat.forPattern("ss")) + fun minuteString(): String = minuteString(now()) fun minuteString(mills: Long): String = DateTime(mills).toString(DateTimeFormat.forPattern("mm")) diff --git a/app-wear-shared/shared/src/main/res/drawable/background.xml b/app-wear-shared/shared/src/main/res/drawable/background.xml new file mode 100644 index 0000000000..76226923e6 --- /dev/null +++ b/app-wear-shared/shared/src/main/res/drawable/background.xml @@ -0,0 +1,9 @@ + + + diff --git a/app-wear-shared/shared/src/main/res/drawable/detailed_dial.xml b/app-wear-shared/shared/src/main/res/drawable/detailed_dial.xml new file mode 100644 index 0000000000..fa5c92bde2 --- /dev/null +++ b/app-wear-shared/shared/src/main/res/drawable/detailed_dial.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-wear-shared/shared/src/main/res/drawable/hour_hand.xml b/app-wear-shared/shared/src/main/res/drawable/hour_hand.xml new file mode 100644 index 0000000000..c29364421b --- /dev/null +++ b/app-wear-shared/shared/src/main/res/drawable/hour_hand.xml @@ -0,0 +1,4 @@ + + + diff --git a/app-wear-shared/shared/src/main/res/drawable/minute_hand.xml b/app-wear-shared/shared/src/main/res/drawable/minute_hand.xml new file mode 100644 index 0000000000..ae37534d34 --- /dev/null +++ b/app-wear-shared/shared/src/main/res/drawable/minute_hand.xml @@ -0,0 +1,4 @@ + + + diff --git a/app-wear-shared/shared/src/main/res/drawable/second_hand.xml b/app-wear-shared/shared/src/main/res/drawable/second_hand.xml new file mode 100644 index 0000000000..96464ba7a8 --- /dev/null +++ b/app-wear-shared/shared/src/main/res/drawable/second_hand.xml @@ -0,0 +1,4 @@ + + + diff --git a/app-wear-shared/shared/src/main/res/drawable/simplified_dial.xml b/app-wear-shared/shared/src/main/res/drawable/simplified_dial.xml new file mode 100644 index 0000000000..63cca01bfb --- /dev/null +++ b/app-wear-shared/shared/src/main/res/drawable/simplified_dial.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + diff --git a/app-wear-shared/shared/src/main/res/drawable/watchface_custom.png b/app-wear-shared/shared/src/main/res/drawable/watchface_custom.png new file mode 100644 index 0000000000000000000000000000000000000000..9678177c64ab2fbd0001fc2e4743df1eb3199c24 GIT binary patch literal 41646 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 Waiting for disconnection + + key_custom_watchface + Created at: %1$s + Author: %1$s + Name: %1$s + Watchface version: %1$s + Default Watchface + \ No newline at end of file diff --git a/app-wear-shared/shared/src/main/res/values/wear_paths.xml b/app-wear-shared/shared/src/main/res/values/wear_paths.xml index 36d7325849..377785cc8e 100644 --- a/app-wear-shared/shared/src/main/res/values/wear_paths.xml +++ b/app-wear-shared/shared/src/main/res/values/wear_paths.xml @@ -1,4 +1,5 @@ /rx_bridge + /rx_data_bridge \ No newline at end of file diff --git a/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/CustomWatchfaceFile.kt b/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/CustomWatchfaceFile.kt new file mode 100644 index 0000000000..5019ae87e4 --- /dev/null +++ b/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/CustomWatchfaceFile.kt @@ -0,0 +1,21 @@ +package info.nightscout.interfaces.maintenance + +import android.os.Parcelable +import info.nightscout.rx.weardata.CustomWatchfaceDrawableDataKey +import info.nightscout.rx.weardata.CustomWatchfaceDrawableDataMap +import info.nightscout.rx.weardata.CustomWatchfaceMetadataMap +import info.nightscout.rx.weardata.DrawableData +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue +import java.io.File + +data class CustomWatchfaceFile( + val name: String, + val file: File, + val baseDir: File, + val json: String, + + val metadata: @RawValue CustomWatchfaceMetadataMap, + val drawableFiles: @RawValue CustomWatchfaceDrawableDataMap + +) \ No newline at end of file diff --git a/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/ImportExportPrefs.kt b/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/ImportExportPrefs.kt index 18ea0f32d3..99657fe7c3 100644 --- a/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/ImportExportPrefs.kt +++ b/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/ImportExportPrefs.kt @@ -2,12 +2,16 @@ package info.nightscout.interfaces.maintenance import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import info.nightscout.rx.weardata.EventData interface ImportExportPrefs { fun importSharedPreferences(activity: FragmentActivity, importFile: PrefsFile) fun importSharedPreferences(activity: FragmentActivity) fun importSharedPreferences(fragment: Fragment) + fun importCustomWatchface(activity: FragmentActivity) + fun importCustomWatchface(fragment: Fragment) + fun exportCustomWatchface(customWatchface: EventData.ActionSetCustomWatchface) fun prefsFileExists(): Boolean fun verifyStoragePermissions(fragment: Fragment, onGranted: Runnable) fun exportSharedPreferences(f: Fragment) diff --git a/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/PrefFileListProvider.kt b/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/PrefFileListProvider.kt index 8bf0d497dd..9d131e9fdb 100644 --- a/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/PrefFileListProvider.kt +++ b/core/interfaces/src/main/java/info/nightscout/interfaces/maintenance/PrefFileListProvider.kt @@ -10,7 +10,9 @@ interface PrefFileListProvider { fun ensureExtraDirExists(): File fun newExportFile(): File fun newExportCsvFile(): File + fun newCwfFile(filename: String): File fun listPreferenceFiles(loadMetadata: Boolean = false): MutableList + fun listCustomWatchfaceFiles(): MutableList fun checkMetadata(metadata: Map): Map fun formatExportedAgo(utcTime: String): String } \ No newline at end of file diff --git a/plugins/configuration/src/main/AndroidManifest.xml b/plugins/configuration/src/main/AndroidManifest.xml index 2d2811718b..700ebdafae 100644 --- a/plugins/configuration/src/main/AndroidManifest.xml +++ b/plugins/configuration/src/main/AndroidManifest.xml @@ -18,6 +18,10 @@ android:name=".maintenance.activities.PrefImportListActivity" android:exported="false" android:theme="@style/AppTheme" /> + () { + + companion object { + const val OUTPUT_PARAM = "custom_file" + } + + override fun parseResult(resultCode: Int, intent: Intent?): Unit? { + return when (resultCode) { + FragmentActivity.RESULT_OK -> Unit + else -> null + } + } + + override fun createIntent(context: Context, input: Void?): Intent { + return Intent(context, info.nightscout.configuration.maintenance.activities.CustomWatchfaceImportListActivity::class.java) + } +} \ No newline at end of file diff --git a/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/ImportExportPrefsImpl.kt b/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/ImportExportPrefsImpl.kt index 941a5b9cb3..567e61fca7 100644 --- a/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/ImportExportPrefsImpl.kt +++ b/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/ImportExportPrefsImpl.kt @@ -22,6 +22,7 @@ import dagger.android.HasAndroidInjector import info.nightscout.configuration.R import info.nightscout.configuration.activities.DaggerAppCompatActivityWithResult import info.nightscout.configuration.maintenance.dialogs.PrefImportSummaryDialog +import info.nightscout.configuration.maintenance.formats.ZipCustomWatchfaceFormat import info.nightscout.configuration.maintenance.formats.EncryptedPrefsFormat import info.nightscout.core.ui.dialogs.OKDialog import info.nightscout.core.ui.dialogs.TwoMessagesAlertDialog @@ -55,6 +56,7 @@ import info.nightscout.rx.events.EventAppExit import info.nightscout.rx.events.EventDiaconnG8PumpLogReset import info.nightscout.rx.logging.AAPSLogger import info.nightscout.rx.logging.LTag +import info.nightscout.rx.weardata.EventData import info.nightscout.shared.interfaces.ResourceHelper import info.nightscout.shared.sharedPreferences.SP import info.nightscout.shared.utils.DateUtil @@ -84,7 +86,8 @@ class ImportExportPrefsImpl @Inject constructor( private val prefFileList: PrefFileListProvider, private val uel: UserEntryLogger, private val dateUtil: DateUtil, - private val uiInteraction: UiInteraction + private val uiInteraction: UiInteraction, + private val customWatchfaceCWFFormat: ZipCustomWatchfaceFormat ) : ImportExportPrefs { override fun prefsFileExists(): Boolean { @@ -297,6 +300,27 @@ class ImportExportPrefsImpl @Inject constructor( } } + override fun importCustomWatchface(fragment: Fragment) { + fragment.activity?.let { importCustomWatchface(it) } + } + override fun importCustomWatchface(activity: FragmentActivity) { + try { + if (activity is DaggerAppCompatActivityWithResult) + activity.callForCustomWatchfaceFile.launch(null) + } catch (e: IllegalArgumentException) { + // this exception happens on some early implementations of ActivityResult contracts + // when registered and called for the second time + ToastUtils.errorToast(activity, rh.gs(R.string.goto_main_try_again)) + log.error(LTag.CORE, "Internal android framework exception", e) + } + } + + override fun exportCustomWatchface(customWatchface: EventData.ActionSetCustomWatchface) { + prefFileList.ensureExportDirExists() + val newFile = prefFileList.newCwfFile(customWatchface.name) + customWatchfaceCWFFormat.saveCustomWatchface(newFile,customWatchface) + } + override fun importSharedPreferences(activity: FragmentActivity, importFile: PrefsFile) { askToConfirmImport(activity, importFile) { password -> diff --git a/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/PrefFileListProviderImpl.kt b/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/PrefFileListProviderImpl.kt index d060dc1304..b8d66e3bd5 100644 --- a/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/PrefFileListProviderImpl.kt +++ b/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/PrefFileListProviderImpl.kt @@ -6,8 +6,10 @@ import dagger.Lazy import dagger.Reusable import info.nightscout.androidaps.annotations.OpenForTesting import info.nightscout.configuration.R +import info.nightscout.configuration.maintenance.formats.ZipCustomWatchfaceFormat import info.nightscout.configuration.maintenance.formats.EncryptedPrefsFormat import info.nightscout.interfaces.Config +import info.nightscout.interfaces.maintenance.CustomWatchfaceFile import info.nightscout.interfaces.maintenance.PrefFileListProvider import info.nightscout.interfaces.maintenance.PrefMetadata import info.nightscout.interfaces.maintenance.PrefMetadataMap @@ -34,6 +36,7 @@ class PrefFileListProviderImpl @Inject constructor( private val rh: ResourceHelper, private val config: Lazy, private val encryptedPrefsFormat: EncryptedPrefsFormat, + private val customWatchfaceCWFFormat: ZipCustomWatchfaceFormat, private val storage: Storage, private val versionCheckerUtils: VersionCheckerUtils, context: Context @@ -88,6 +91,20 @@ class PrefFileListProviderImpl @Inject constructor( return prefFiles } + override fun listCustomWatchfaceFiles(): MutableList { + val customWatchfaceFiles = mutableListOf() + + // searching dedicated dir, only for new CWF format + exportsPath.walk().filter { it.isFile && it.name.endsWith(ZipCustomWatchfaceFormat.CUSTOM_WF_EXTENTION) }.forEach { file -> + // Here loadCustomWatchface will unzip, check and load CustomWatchface + customWatchfaceCWFFormat.loadCustomWatchface(file)?.also { customWatchface -> + customWatchfaceFiles.add(CustomWatchfaceFile(file.name, file, exportsPath, customWatchface.json, customWatchface.metadata, customWatchface.drawableDatas)) + } + } + + return customWatchfaceFiles + } + private fun metadataFor(loadMetadata: Boolean, contents: String): PrefMetadataMap { if (!loadMetadata) { return mapOf() @@ -128,6 +145,10 @@ class PrefFileListProviderImpl @Inject constructor( val timeLocal = LocalDateTime.now().toString(DateTimeFormat.forPattern("yyyy-MM-dd'_'HHmmss")) return File(exportsPath, timeLocal + "_UserEntry.csv") } + override fun newCwfFile(filename: String): File { + val timeLocal = LocalDateTime.now().toString(DateTimeFormat.forPattern("yyyy-MM-dd'_'HHmmss")) + return File(exportsPath, "${filename}_$timeLocal${ZipCustomWatchfaceFormat.CUSTOM_WF_EXTENTION}") + } // check metadata for known issues, change their status and add info with explanations override fun checkMetadata(metadata: Map): Map { diff --git a/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/activities/CustomWatchfaceImportListActivity.kt b/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/activities/CustomWatchfaceImportListActivity.kt new file mode 100644 index 0000000000..d225ae790d --- /dev/null +++ b/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/activities/CustomWatchfaceImportListActivity.kt @@ -0,0 +1,112 @@ +package info.nightscout.configuration.maintenance.activities + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.ViewGroup + +import androidx.fragment.app.FragmentActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import info.nightscout.core.ui.activities.TranslatedDaggerAppCompatActivity +import info.nightscout.interfaces.maintenance.CustomWatchfaceFile +import info.nightscout.interfaces.maintenance.PrefFileListProvider +import info.nightscout.configuration.databinding.CustomWatchfaceImportListActivityBinding +import info.nightscout.configuration.R +import info.nightscout.configuration.databinding.CustomWatchfaceImportListItemBinding +import info.nightscout.rx.bus.RxBus +import info.nightscout.rx.events.EventMobileDataToWear +import info.nightscout.rx.logging.AAPSLogger +import info.nightscout.rx.weardata.CustomWatchfaceDrawableDataKey +import info.nightscout.rx.weardata.CustomWatchfaceMetadataKey.* +import info.nightscout.rx.weardata.EventData +import info.nightscout.shared.interfaces.ResourceHelper +import info.nightscout.shared.sharedPreferences.SP +import javax.inject.Inject + +class CustomWatchfaceImportListActivity: TranslatedDaggerAppCompatActivity() { + + @Inject lateinit var rh: ResourceHelper + @Inject lateinit var sp: SP + @Inject lateinit var prefFileListProvider: PrefFileListProvider + @Inject lateinit var rxBus: RxBus + @Inject lateinit var aapsLogger: AAPSLogger + + private lateinit var binding: CustomWatchfaceImportListActivityBinding + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = CustomWatchfaceImportListActivityBinding.inflate(layoutInflater) + val view = binding.root + setContentView(view) + + title = rh.gs(R.string.wear_import_custom_watchface_title) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.setDisplayShowTitleEnabled(true) + + binding.recyclerview.layoutManager = LinearLayoutManager(this) + binding.recyclerview.adapter = RecyclerViewAdapter(prefFileListProvider.listCustomWatchfaceFiles()) + } + + inner class RecyclerViewAdapter internal constructor(private var customWatchfaceFileList: List) : RecyclerView.Adapter() { + + inner class PrefFileViewHolder(val customWatchfaceImportListItemBinding: CustomWatchfaceImportListItemBinding) : RecyclerView.ViewHolder(customWatchfaceImportListItemBinding.root) { + + init { + with(customWatchfaceImportListItemBinding) { + root.isClickable = true + customWatchfaceImportListItemBinding.root.setOnClickListener { + val customWatchfaceFile = filelistName.tag as CustomWatchfaceFile + val customWF = EventData.ActionSetCustomWatchface(customWatchfaceFile.metadata[CWF_NAME] ?:"", customWatchfaceFile.json, customWatchfaceFile.drawableFiles) + sp.putString(info.nightscout.shared.R.string.key_custom_watchface, customWF.serialize()) + val i = Intent() + setResult(FragmentActivity.RESULT_OK, i) + rxBus.send(EventMobileDataToWear(customWF)) + aapsLogger.debug("XXXXX EventMobileDataToWear sent") + + finish() + } + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PrefFileViewHolder { + val binding = CustomWatchfaceImportListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return PrefFileViewHolder(binding) + } + + override fun getItemCount(): Int { + return customWatchfaceFileList.size + } + + override fun onBindViewHolder(holder: PrefFileViewHolder, position: Int) { + val customWatchfaceFile = customWatchfaceFileList[position] + val metadata = customWatchfaceFile.metadata + val drawable = customWatchfaceFile.drawableFiles[CustomWatchfaceDrawableDataKey + .CUSTOM_WATCHFACE]?.toDrawable(resources) + with(holder.customWatchfaceImportListItemBinding) { + filelistName.text = rh.gs(R.string.wear_import_filename, customWatchfaceFile.file.name) + filelistName.tag = customWatchfaceFile + customWatchface.setImageDrawable(drawable) + filelistDir.text = rh.gs(R.string.wear_import_directory, customWatchfaceFile.file.parentFile?.absolutePath) + customName.text = rh.gs(CWF_NAME.label, metadata[CWF_NAME]) + author.text = rh.gs(CWF_AUTHOR.label, metadata[CWF_AUTHOR] ?:"") + createdAt.text = rh.gs(CWF_CREATED_AT.label, metadata[CWF_CREATED_AT] ?:"") + cwfVersion.text = rh.gs(CWF_VERSION.label, metadata[CWF_VERSION] ?:"") + + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return super.onOptionsItemSelected(item) + } + +} \ No newline at end of file diff --git a/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/formats/ZipCustomWatchfaceFormat.kt b/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/formats/ZipCustomWatchfaceFormat.kt new file mode 100644 index 0000000000..6414187d1a --- /dev/null +++ b/plugins/configuration/src/main/java/info/nightscout/configuration/maintenance/formats/ZipCustomWatchfaceFormat.kt @@ -0,0 +1,128 @@ +package info.nightscout.configuration.maintenance.formats + +import info.nightscout.core.utils.CryptoUtil +import info.nightscout.interfaces.storage.Storage +import info.nightscout.rx.weardata.CustomWatchface +import info.nightscout.rx.weardata.CustomWatchfaceDrawableDataKey +import info.nightscout.rx.weardata.CustomWatchfaceDrawableDataMap +import info.nightscout.rx.weardata.CustomWatchfaceFormat +import info.nightscout.rx.weardata.CustomWatchfaceMetadataKey +import info.nightscout.rx.weardata.CustomWatchfaceMetadataMap +import info.nightscout.rx.weardata.DrawableData +import info.nightscout.rx.weardata.DrawableFormat +import info.nightscout.rx.weardata.EventData +import info.nightscout.shared.interfaces.ResourceHelper +import org.json.JSONObject +import java.io.BufferedOutputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ZipCustomWatchfaceFormat @Inject constructor( + private var rh: ResourceHelper, + private var cryptoUtil: CryptoUtil, + private var storage: Storage +) : CustomWatchfaceFormat { + + companion object { + + const val CUSTOM_WF_EXTENTION = ".zip" + const val CUSTOM_JSON_FILE = "CustomWatchface.json" + } + + override fun loadCustomWatchface(cwfFile: File): CustomWatchface? { + var json = JSONObject() + var metadata: CustomWatchfaceMetadataMap = mutableMapOf() + val drawableDatas: CustomWatchfaceDrawableDataMap = mutableMapOf() + + try { + val zipInputStream = ZipInputStream(cwfFile.inputStream()) + var zipEntry: ZipEntry? = zipInputStream.nextEntry + while (zipEntry != null) { + val entryName = zipEntry.name + + val buffer = ByteArray(2048) + val byteArrayOutputStream = ByteArrayOutputStream() + var count = zipInputStream.read(buffer) + while (count != -1) { + byteArrayOutputStream.write(buffer, 0, count) + count = zipInputStream.read(buffer) + } + zipInputStream.closeEntry() + + if (entryName == CUSTOM_JSON_FILE) { + val jsonString = byteArrayOutputStream.toByteArray().toString(Charsets.UTF_8) + json = JSONObject(jsonString) + metadata = loadMetadata(json) + } else { + val customWatchfaceDrawableDataKey = CustomWatchfaceDrawableDataKey.fromFileName(entryName) + val drawableFormat = DrawableFormat.fromFileName(entryName) + if (customWatchfaceDrawableDataKey != CustomWatchfaceDrawableDataKey.UNKNOWN && drawableFormat != DrawableFormat.UNKNOWN) { + drawableDatas[customWatchfaceDrawableDataKey] = DrawableData(byteArrayOutputStream.toByteArray(),drawableFormat) + } + } + zipEntry = zipInputStream.nextEntry + } + + // Valid CWF file must contains a valid json file with a name within metadata and a custom watchface image + if (metadata.containsKey(CustomWatchfaceMetadataKey.CWF_NAME) && drawableDatas.containsKey(CustomWatchfaceDrawableDataKey.CUSTOM_WATCHFACE)) + return CustomWatchface(json.toString(4), metadata, drawableDatas) + else + return null + + } catch (e: Exception) { + return null + } + } + + + override fun saveCustomWatchface(file: File, customWatchface: EventData.ActionSetCustomWatchface) { + + try { + val outputStream = FileOutputStream(file) + val zipOutputStream = ZipOutputStream(BufferedOutputStream(outputStream)) + + // Ajouter le fichier JSON au ZIP + val jsonEntry = ZipEntry(CUSTOM_JSON_FILE) + zipOutputStream.putNextEntry(jsonEntry) + zipOutputStream.write(customWatchface.json.toByteArray()) + zipOutputStream.closeEntry() + + // Ajouter les fichiers divers au ZIP + for (drawableData in customWatchface.drawableDataMap) { + val fileEntry = ZipEntry("${drawableData.key.fileName}.${drawableData.value.format.extension}") + zipOutputStream.putNextEntry(fileEntry) + zipOutputStream.write(drawableData.value.value) + zipOutputStream.closeEntry() + } + zipOutputStream.close() + outputStream.close() + } catch (e: Exception) { + + } + + } + + + override fun loadMetadata(contents: JSONObject): CustomWatchfaceMetadataMap { + val metadata: CustomWatchfaceMetadataMap = mutableMapOf() + + if (contents.has("metadata")) { + val meta = contents.getJSONObject("metadata") + for (key in meta.keys()) { + val metaKey = CustomWatchfaceMetadataKey.fromKey(key) + if (metaKey != null) { + metadata[metaKey] = meta.getString(key) + } + } + } + return metadata + } + +} \ No newline at end of file diff --git a/plugins/configuration/src/main/res/layout/custom_watchface_import_list_activity.xml b/plugins/configuration/src/main/res/layout/custom_watchface_import_list_activity.xml new file mode 100644 index 0000000000..5095328ce9 --- /dev/null +++ b/plugins/configuration/src/main/res/layout/custom_watchface_import_list_activity.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/plugins/configuration/src/main/res/layout/custom_watchface_import_list_item.xml b/plugins/configuration/src/main/res/layout/custom_watchface_import_list_item.xml new file mode 100644 index 0000000000..dfd624b429 --- /dev/null +++ b/plugins/configuration/src/main/res/layout/custom_watchface_import_list_item.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/configuration/src/main/res/values/strings.xml b/plugins/configuration/src/main/res/values/strings.xml index 1cec48dfba..15e68dacd6 100644 --- a/plugins/configuration/src/main/res/values/strings.xml +++ b/plugins/configuration/src/main/res/values/strings.xml @@ -164,6 +164,11 @@ Missing encryption configuration, settings format is invalid! Unsupported or not specified encryption algorithm! + + Select Custom Watchface + Directory: %1$s + File name: %1$s + Please reboot your phone or restart AAPS from the System Settings \notherwise Android APS will not have logging (important to track and verify that the algorithms are working correctly)! diff --git a/plugins/main/src/main/AndroidManifest.xml b/plugins/main/src/main/AndroidManifest.xml index 6d24217b2e..75ecc0da85 100644 --- a/plugins/main/src/main/AndroidManifest.xml +++ b/plugins/main/src/main/AndroidManifest.xml @@ -51,6 +51,15 @@ android:pathPrefix="@string/path_rx_bridge" android:scheme="wear" /> + + + + + + diff --git a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/WearFragment.kt b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/WearFragment.kt index 57cd89ec9d..848facfbec 100644 --- a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/WearFragment.kt +++ b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/WearFragment.kt @@ -5,13 +5,21 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import dagger.android.support.DaggerFragment +import info.nightscout.core.ui.toast.ToastUtils import info.nightscout.core.utils.fabric.FabricPrivacy +import info.nightscout.interfaces.maintenance.ImportExportPrefs +import info.nightscout.plugins.R import info.nightscout.plugins.databinding.WearFragmentBinding -import info.nightscout.plugins.general.wear.events.EventWearUpdateGui import info.nightscout.rx.AapsSchedulers import info.nightscout.rx.bus.RxBus +import info.nightscout.rx.events.EventMobileDataToWear import info.nightscout.rx.events.EventMobileToWear +import info.nightscout.rx.events.EventWearUpdateGui +import info.nightscout.rx.logging.AAPSLogger +import info.nightscout.rx.weardata.CustomWatchfaceDrawableDataKey import info.nightscout.rx.weardata.EventData +import info.nightscout.shared.interfaces.ResourceHelper +import info.nightscout.shared.sharedPreferences.SP import info.nightscout.shared.utils.DateUtil import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign @@ -24,9 +32,12 @@ class WearFragment : DaggerFragment() { @Inject lateinit var aapsSchedulers: AapsSchedulers @Inject lateinit var fabricPrivacy: FabricPrivacy @Inject lateinit var dateUtil: DateUtil + @Inject lateinit var importExportPrefs: ImportExportPrefs + @Inject lateinit var sp:SP + @Inject lateinit var rh: ResourceHelper + @Inject lateinit var aapsLogger: AAPSLogger private var _binding: WearFragmentBinding? = null - private val disposable = CompositeDisposable() // This property is only valid between onCreateView and @@ -43,6 +54,25 @@ class WearFragment : DaggerFragment() { super.onViewCreated(view, savedInstanceState) binding.resend.setOnClickListener { rxBus.send(EventData.ActionResendData("WearFragment")) } binding.openSettings.setOnClickListener { rxBus.send(EventMobileToWear(EventData.OpenSettings(dateUtil.now()))) } + + binding.loadCustom.setOnClickListener { + importExportPrefs.verifyStoragePermissions(this) { + importExportPrefs.importCustomWatchface(this) + } + } + binding.defaultCustom.setOnClickListener { + sp.remove(info.nightscout.shared.R.string.key_custom_watchface) + wearPlugin.savedCustomWatchface = null + rxBus.send(EventMobileToWear(EventData.ActionrequestSetDefaultWatchface(dateUtil.now()))) + updateGui() + } + binding.sendCustom.setOnClickListener { + wearPlugin.savedCustomWatchface?.let { cwf -> rxBus.send(EventMobileDataToWear(cwf)) } + } + binding.exportCustom.setOnClickListener { + wearPlugin.savedCustomWatchface?.let { importExportPrefs.exportCustomWatchface(it) } + ?: apply { rxBus.send(EventMobileToWear(EventData.ActionrequestCustomWatchface(true)))} + } } override fun onResume() { @@ -51,6 +81,19 @@ class WearFragment : DaggerFragment() { .toObservable(EventWearUpdateGui::class.java) .observeOn(aapsSchedulers.main) .subscribe({ updateGui() }, fabricPrivacy::logException) + disposable += rxBus + .toObservable(EventMobileDataToWear::class.java) + .observeOn(aapsSchedulers.main) + .subscribe({ + loadCustom(it.payload) + wearPlugin.customWatchfaceSerialized = "" + wearPlugin.savedCustomWatchface = null + updateGui() + ToastUtils.okToast(context,rh.gs(R.string.wear_new_custom_watchface_received)) + }, fabricPrivacy::logException) + if (wearPlugin.savedCustomWatchface == null) + rxBus.send(EventMobileToWear(EventData.ActionrequestCustomWatchface(false))) + //EventMobileDataToWear updateGui() } @@ -67,6 +110,34 @@ class WearFragment : DaggerFragment() { private fun updateGui() { _binding ?: return + sp.getString(info.nightscout.shared.R.string.key_custom_watchface, "").let { + if (it != wearPlugin.customWatchfaceSerialized && it != "") { + aapsLogger.debug("XXXXX Serialisation: ${it.length}") + try { + wearPlugin.savedCustomWatchface = (EventData.deserialize(it) as EventData.ActionSetCustomWatchface) + wearPlugin.customWatchfaceSerialized = it + } + catch(e: Exception) { + wearPlugin.customWatchfaceSerialized = "" + wearPlugin.savedCustomWatchface = null + } + } + sp.remove(info.nightscout.shared.R.string.key_custom_watchface) + } + wearPlugin.savedCustomWatchface?.let { + binding.customName.text = rh.gs(R.string.wear_custom_watchface, it.name) + binding.sendCustom.visibility = View.VISIBLE + binding.coverChart.setImageDrawable(it.drawableDataMap[CustomWatchfaceDrawableDataKey.CUSTOM_WATCHFACE]?.toDrawable(resources)) + } ?:apply { + binding.customName.text = rh.gs(R.string.wear_custom_watchface, rh.gs(info.nightscout.shared.R.string.wear_default_watchface)) + binding.sendCustom.visibility = View.INVISIBLE + binding.coverChart.setImageDrawable(null) + } binding.connectedDevice.text = wearPlugin.connectedDevice } + + private fun loadCustom(cwf: EventData.ActionSetCustomWatchface) { + aapsLogger.debug("XXXXX EventWearCwfExported received") + wearPlugin.savedCustomWatchface = cwf + } } \ No newline at end of file diff --git a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/WearPlugin.kt b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/WearPlugin.kt index a17e63134e..21516d6d1e 100644 --- a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/WearPlugin.kt +++ b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/WearPlugin.kt @@ -54,6 +54,8 @@ class WearPlugin @Inject constructor( private val disposable = CompositeDisposable() var connectedDevice = "---" + var customWatchfaceSerialized = "" + var savedCustomWatchface: EventData.ActionSetCustomWatchface? = null override fun onStart() { super.onStart() diff --git a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt index 066e49bf2b..a1fe993f56 100644 --- a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt +++ b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataHandlerMobile.kt @@ -41,6 +41,7 @@ import info.nightscout.interfaces.iob.GlucoseStatusProvider import info.nightscout.interfaces.iob.InMemoryGlucoseValue import info.nightscout.interfaces.iob.IobCobCalculator import info.nightscout.interfaces.logging.UserEntryLogger +import info.nightscout.interfaces.maintenance.ImportExportPrefs import info.nightscout.interfaces.nsclient.ProcessedDeviceStatusData import info.nightscout.interfaces.plugin.ActivePlugin import info.nightscout.interfaces.plugin.PluginBase @@ -59,6 +60,8 @@ import info.nightscout.plugins.R import info.nightscout.rx.AapsSchedulers import info.nightscout.rx.bus.RxBus import info.nightscout.rx.events.EventMobileToWear +import info.nightscout.rx.events.EventWearCwfExported +import info.nightscout.rx.events.EventWearUpdateGui import info.nightscout.rx.logging.AAPSLogger import info.nightscout.rx.logging.LTag import info.nightscout.rx.weardata.EventData @@ -107,7 +110,8 @@ class DataHandlerMobile @Inject constructor( private val commandQueue: CommandQueue, private val fabricPrivacy: FabricPrivacy, private val uiInteraction: UiInteraction, - private val persistenceLayer: PersistenceLayer + private val persistenceLayer: PersistenceLayer, + private val importExportPrefs: ImportExportPrefs ) { private val disposable = CompositeDisposable() @@ -314,6 +318,13 @@ class DataHandlerMobile @Inject constructor( .toObservable(EventData.ActionHeartRate::class.java) .observeOn(aapsSchedulers.io) .subscribe({ handleHeartRate(it) }, fabricPrivacy::logException) + disposable += rxBus + .toObservable(EventData.ActionGetCustomWatchface::class.java) + .observeOn(aapsSchedulers.io) + .subscribe({ + aapsLogger.debug(LTag.WEAR, "Custom Watch face ${it.customWatchface} received from ${it.sourceNodeId}") + handleGetCustomWatchface(it) + }, fabricPrivacy::logException) } private fun handleTddStatus() { @@ -1247,4 +1258,19 @@ class DataHandlerMobile @Inject constructor( device = actionHeartRate.device) repository.runTransaction(InsertOrUpdateHeartRateTransaction(hr)).blockingAwait() } + + + private fun handleGetCustomWatchface(command: EventData.ActionGetCustomWatchface) { + val customWatchface = command.customWatchface + aapsLogger.debug(LTag.WEAR, "Custom Watchface received from ${command.sourceNodeId}: ${customWatchface.json}") + //Update Wear Fragment + sp.putString(info.nightscout.shared.R.string.key_custom_watchface, customWatchface.serialize()) + //rxBus.send(EventWearCwfExported(customWatchface)) + rxBus.send(EventWearUpdateGui()) + if (command.exportFile) + importExportPrefs.exportCustomWatchface(customWatchface) + //Implement here a record within SP and a save within exports subfolder as zipFile + + } + } diff --git a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataLayerListenerServiceMobile.kt b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataLayerListenerServiceMobile.kt index 13a8ff47f9..dac0274681 100644 --- a/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataLayerListenerServiceMobile.kt +++ b/plugins/main/src/main/java/info/nightscout/plugins/general/wear/wearintegration/DataLayerListenerServiceMobile.kt @@ -26,9 +26,10 @@ import info.nightscout.interfaces.profile.ProfileFunction import info.nightscout.interfaces.receivers.ReceiverStatusStore import info.nightscout.plugins.R import info.nightscout.plugins.general.wear.WearPlugin -import info.nightscout.plugins.general.wear.events.EventWearUpdateGui +import info.nightscout.rx.events.EventWearUpdateGui import info.nightscout.rx.AapsSchedulers import info.nightscout.rx.bus.RxBus +import info.nightscout.rx.events.EventMobileDataToWear import info.nightscout.rx.events.EventMobileToWear import info.nightscout.rx.logging.AAPSLogger import info.nightscout.rx.logging.LTag @@ -80,6 +81,7 @@ class DataLayerListenerServiceMobile : WearableListenerService() { private val disposable = CompositeDisposable() private val rxPath get() = getString(info.nightscout.shared.R.string.path_rx_bridge) + private val rxDataPath get() = getString(info.nightscout.shared.R.string.path_rx_data_bridge) override fun onCreate() { AndroidInjection.inject(this) @@ -90,6 +92,10 @@ class DataLayerListenerServiceMobile : WearableListenerService() { .toObservable(EventMobileToWear::class.java) .observeOn(aapsSchedulers.io) .subscribe { sendMessage(rxPath, it.payload.serialize()) } + disposable += rxBus + .toObservable(EventMobileDataToWear::class.java) + .observeOn(aapsSchedulers.io) + .subscribe { sendMessage(rxDataPath, it.payload.serializeByte()) } } override fun onCapabilityChanged(p0: CapabilityInfo) { @@ -136,6 +142,11 @@ class DataLayerListenerServiceMobile : WearableListenerService() { val command = EventData.deserialize(String(messageEvent.data)) rxBus.send(command.also { it.sourceNodeId = messageEvent.sourceNodeId }) } + rxDataPath -> { + aapsLogger.debug(LTag.WEAR, "onMessageReceived rxDataPath: ${String(messageEvent.data)}") + val command = EventData.deserializeByte(messageEvent.data) + rxBus.send(command.also { it.sourceNodeId = messageEvent.sourceNodeId }) + } } } } @@ -164,7 +175,7 @@ class DataLayerListenerServiceMobile : WearableListenerService() { private fun pickBestNodeId(nodes: Set): Node? = nodes.firstOrNull { it.isNearby } ?: nodes.firstOrNull() - @Suppress("unused") + //@Suppress("unused") private fun sendData(path: String, vararg params: DataMap) { if (wearPlugin.isEnabled()) { scope.launch { @@ -201,7 +212,6 @@ class DataLayerListenerServiceMobile : WearableListenerService() { } } - @Suppress("unused") private fun sendMessage(path: String, data: ByteArray) { aapsLogger.debug(LTag.WEAR, "sendMessage: $path") transcriptionNodeId?.also { nodeId -> diff --git a/plugins/main/src/main/res/drawable/export_custom.xml b/plugins/main/src/main/res/drawable/export_custom.xml new file mode 100644 index 0000000000..3779b637df --- /dev/null +++ b/plugins/main/src/main/res/drawable/export_custom.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + diff --git a/plugins/main/src/main/res/drawable/load_custom.xml b/plugins/main/src/main/res/drawable/load_custom.xml new file mode 100644 index 0000000000..26a7dda859 --- /dev/null +++ b/plugins/main/src/main/res/drawable/load_custom.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + diff --git a/plugins/main/src/main/res/drawable/send_custom.xml b/plugins/main/src/main/res/drawable/send_custom.xml new file mode 100644 index 0000000000..2602e2953d --- /dev/null +++ b/plugins/main/src/main/res/drawable/send_custom.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/plugins/main/src/main/res/drawable/set_default.xml b/plugins/main/src/main/res/drawable/set_default.xml new file mode 100644 index 0000000000..ff43d0d255 --- /dev/null +++ b/plugins/main/src/main/res/drawable/set_default.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/plugins/main/src/main/res/layout/wear_fragment.xml b/plugins/main/src/main/res/layout/wear_fragment.xml index cfd22a1f4f..1eb374529d 100644 --- a/plugins/main/src/main/res/layout/wear_fragment.xml +++ b/plugins/main/src/main/res/layout/wear_fragment.xml @@ -1,42 +1,201 @@ - + android:layout_marginStart="4dp" + android:layout_marginEnd="4dp" + android:layout_marginTop="4dp" + app:cardCornerRadius="4dp" + app:contentPadding="2dp" + app:cardElevation="2dp" + app:cardUseCompatPadding="false" + android:layout_gravity="center"> - + - + + + + + + + + + + + + android:layout_marginStart="4dp" + android:layout_marginEnd="4dp" + android:layout_marginTop="4dp" + app:cardCornerRadius="4dp" + app:contentPadding="2dp" + app:cardElevation="2dp" + app:cardUseCompatPadding="false" + android:layout_gravity="center"> + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/main/src/main/res/values/strings.xml b/plugins/main/src/main/res/values/strings.xml index c7c4789b6f..b8d2b4eccf 100644 --- a/plugins/main/src/main/res/values/strings.xml +++ b/plugins/main/src/main/res/values/strings.xml @@ -361,6 +361,11 @@ Show SMB on the watch like a standard bolus. Show the predictions on the watchface. Predictions + Custom Watchface: %1$s + Load Watchface + Send Watchface + Export Watchface + New watchface received from watch Resend All Data Open Settings on Wear diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 2a08a7f623..ab50e950d1 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -241,6 +241,31 @@ + + + + + + + + + + + + + + @@ -267,6 +292,15 @@ android:pathPrefix="@string/path_rx_bridge" android:scheme="wear" /> + + + + + + + diff --git a/wear/src/main/java/info/nightscout/androidaps/comm/DataHandlerWear.kt b/wear/src/main/java/info/nightscout/androidaps/comm/DataHandlerWear.kt index 389ee261d9..5d5e197670 100644 --- a/wear/src/main/java/info/nightscout/androidaps/comm/DataHandlerWear.kt +++ b/wear/src/main/java/info/nightscout/androidaps/comm/DataHandlerWear.kt @@ -25,6 +25,7 @@ import info.nightscout.androidaps.tile.QuickWizardTileService import info.nightscout.androidaps.tile.TempTargetTileService import info.nightscout.rx.AapsSchedulers import info.nightscout.rx.bus.RxBus +import info.nightscout.rx.events.EventWearDataToMobile import info.nightscout.rx.events.EventWearToMobile import info.nightscout.rx.logging.AAPSLogger import info.nightscout.rx.logging.LTag @@ -179,6 +180,29 @@ class DataHandlerWear @Inject constructor( TileService.getUpdater(context).requestUpdate(QuickWizardTileService::class.java) } } + disposable += rxBus + .toObservable(EventData.ActionSetCustomWatchface::class.java) + .observeOn(aapsSchedulers.io) + .subscribe { + aapsLogger.debug(LTag.WEAR, "Custom Watchface received from ${it.sourceNodeId}") + persistence.store(it) + } + disposable += rxBus + .toObservable(EventData.ActionrequestSetDefaultWatchface::class.java) + .observeOn(aapsSchedulers.io) + .subscribe { + aapsLogger.debug(LTag.WEAR, "Set Default Watchface received from ${it.sourceNodeId}") + persistence.setDefaultWatchface() + } + disposable += rxBus + .toObservable(EventData.ActionrequestCustomWatchface::class.java) + .observeOn(aapsSchedulers.io) + .subscribe { eventData -> + aapsLogger.debug(LTag.WEAR, "Custom Watchface requested from ${eventData.sourceNodeId}") + persistence.readCustomWatchface()?.let { + rxBus.send(EventWearDataToMobile(EventData.ActionGetCustomWatchface(it, eventData.exportFile))) + } + } } private fun handleBolusProgress(bolusProgress: EventData.BolusProgress) { diff --git a/wear/src/main/java/info/nightscout/androidaps/comm/DataLayerListenerServiceWear.kt b/wear/src/main/java/info/nightscout/androidaps/comm/DataLayerListenerServiceWear.kt index fabc65b8fb..e4c62dd83d 100644 --- a/wear/src/main/java/info/nightscout/androidaps/comm/DataLayerListenerServiceWear.kt +++ b/wear/src/main/java/info/nightscout/androidaps/comm/DataLayerListenerServiceWear.kt @@ -12,6 +12,7 @@ import info.nightscout.androidaps.interaction.utils.Persistence import info.nightscout.androidaps.interaction.utils.WearUtil import info.nightscout.rx.AapsSchedulers import info.nightscout.rx.bus.RxBus +import info.nightscout.rx.events.EventWearDataToMobile import info.nightscout.rx.events.EventWearToMobile import info.nightscout.rx.logging.AAPSLogger import info.nightscout.rx.logging.LTag @@ -43,6 +44,7 @@ class DataLayerListenerServiceWear : WearableListenerService() { private val disposable = CompositeDisposable() private val rxPath get() = getString(info.nightscout.shared.R.string.path_rx_bridge) + private val rxDataPath get() = getString(info.nightscout.shared.R.string.path_rx_data_bridge) override fun onCreate() { AndroidInjection.inject(this) @@ -54,6 +56,12 @@ class DataLayerListenerServiceWear : WearableListenerService() { .subscribe { sendMessage(rxPath, it.payload.serialize()) } + disposable += rxBus + .toObservable(EventWearDataToMobile::class.java) + .observeOn(aapsSchedulers.io) + .subscribe { + sendMessage(rxDataPath, it.payload.serializeByte()) + } } override fun onCapabilityChanged(p0: CapabilityInfo) { @@ -100,6 +108,14 @@ class DataLayerListenerServiceWear : WearableListenerService() { transcriptionNodeId = messageEvent.sourceNodeId aapsLogger.debug(LTag.WEAR, "Updated node: $transcriptionNodeId") } + rxDataPath -> { + aapsLogger.debug(LTag.WEAR, "onMessageReceived: ${messageEvent.data}") + val command = EventData.deserializeByte(messageEvent.data) + rxBus.send(command.also { it.sourceNodeId = messageEvent.sourceNodeId }) + // Use this sender + transcriptionNodeId = messageEvent.sourceNodeId + aapsLogger.debug(LTag.WEAR, "Updated node: $transcriptionNodeId") + } } } diff --git a/wear/src/main/java/info/nightscout/androidaps/di/WearServicesModule.kt b/wear/src/main/java/info/nightscout/androidaps/di/WearServicesModule.kt index 909c0aa21a..3cc8baa387 100644 --- a/wear/src/main/java/info/nightscout/androidaps/di/WearServicesModule.kt +++ b/wear/src/main/java/info/nightscout/androidaps/di/WearServicesModule.kt @@ -40,6 +40,7 @@ abstract class WearServicesModule { @ContributesAndroidInjector abstract fun contributesBIGChart(): BigChartWatchface @ContributesAndroidInjector abstract fun contributesNOChart(): NoChartWatchface @ContributesAndroidInjector abstract fun contributesCircleWatchface(): CircleWatchface + @ContributesAndroidInjector abstract fun contributesCustomWatchface(): CustomWatchface @ContributesAndroidInjector abstract fun contributesTileBase(): TileBase @ContributesAndroidInjector abstract fun contributesQuickWizardTileService(): QuickWizardTileService diff --git a/wear/src/main/java/info/nightscout/androidaps/interaction/utils/Persistence.kt b/wear/src/main/java/info/nightscout/androidaps/interaction/utils/Persistence.kt index 557300ca6d..ef97aa4270 100644 --- a/wear/src/main/java/info/nightscout/androidaps/interaction/utils/Persistence.kt +++ b/wear/src/main/java/info/nightscout/androidaps/interaction/utils/Persistence.kt @@ -36,6 +36,8 @@ open class Persistence @Inject constructor( const val KEY_STALE_REPORTED = "staleReported" const val KEY_DATA_UPDATED = "data_updated_at" + const val CUSTOM_WATCHFACE = "custom_watchface" + const val CUSTOM_DEFAULT_WATCHFACE = "custom_default_watchface" } fun getString(key: String, defaultValue: String): String { @@ -130,6 +132,23 @@ open class Persistence @Inject constructor( return null } + fun readCustomWatchface(isDefault: Boolean = false): EventData.ActionSetCustomWatchface? { + try { + var s = sp.getStringOrNull(if (isDefault) CUSTOM_DEFAULT_WATCHFACE else CUSTOM_WATCHFACE, null) + if (s != null) { + return deserialize(s) as EventData.ActionSetCustomWatchface + } else { + s = sp.getStringOrNull(CUSTOM_DEFAULT_WATCHFACE, null) + if (s != null) { + return deserialize(s) as EventData.ActionSetCustomWatchface + } + } + } catch (exception: Exception) { + aapsLogger.error(LTag.WEAR, exception.toString()) + } + return null + } + fun store(singleBg: SingleBg) { putString(BG_DATA_PERSISTENCE_KEY, singleBg.serialize()) aapsLogger.debug(LTag.WEAR, "Stored BG data: $singleBg") @@ -151,6 +170,16 @@ open class Persistence @Inject constructor( aapsLogger.debug(LTag.WEAR, "Stored Status data: $status") } + fun store(customWatchface: EventData.ActionSetCustomWatchface, isdefault: Boolean = false) { + putString(if (isdefault) CUSTOM_DEFAULT_WATCHFACE else CUSTOM_WATCHFACE, customWatchface.serialize()) + aapsLogger.debug(LTag.WEAR, "Stored Custom Watchface ${customWatchface.name} ${isdefault}: $customWatchface") + } + + fun setDefaultWatchface() { + readCustomWatchface(true)?.let {store(it)} + aapsLogger.debug(LTag.WEAR, "Custom Watchface reset to default") + } + fun joinSet(set: Set, separator: String?): String { val sb = StringBuilder() var i = 0 diff --git a/wear/src/main/java/info/nightscout/androidaps/watchfaces/CustomWatchface.kt b/wear/src/main/java/info/nightscout/androidaps/watchfaces/CustomWatchface.kt new file mode 100644 index 0000000000..f01f5de785 --- /dev/null +++ b/wear/src/main/java/info/nightscout/androidaps/watchfaces/CustomWatchface.kt @@ -0,0 +1,398 @@ +@file:Suppress("DEPRECATION") + +package info.nightscout.androidaps.watchfaces + +import android.app.ActionBar.LayoutParams +import android.content.Context +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Point +import android.graphics.Typeface +import android.support.wearable.watchface.WatchFaceStyle +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.forEach +import androidx.viewbinding.ViewBinding +import info.nightscout.androidaps.R +import info.nightscout.androidaps.databinding.ActivityCustomBinding +import info.nightscout.androidaps.watchfaces.utils.BaseWatchFace +import info.nightscout.rx.weardata.CustomWatchfaceDrawableDataKey +import info.nightscout.rx.weardata.CustomWatchfaceDrawableDataMap +import info.nightscout.rx.weardata.CustomWatchfaceMetadataKey +import info.nightscout.rx.weardata.DrawableData +import info.nightscout.rx.weardata.DrawableFormat +import info.nightscout.rx.weardata.EventData +import info.nightscout.shared.extensions.toVisibility +import org.joda.time.TimeOfDay +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +class CustomWatchface : BaseWatchFace() { + + @Inject lateinit var context: Context + private lateinit var binding: ActivityCustomBinding + private var zoomFactor = 1.0 + private val displaySize = Point() + private val TEMPLATE_RESOLUTION = 400 + private var lowBatColor = Color.RED + private var bgColor = Color.WHITE + + val CUSTOM_VERSION = "v0.1" + + @Suppress("DEPRECATION") + override fun inflateLayout(inflater: LayoutInflater): ViewBinding { + binding = ActivityCustomBinding.inflate(inflater) + setDefaultColors() + persistence.store(defaultWatchface(), true) + (context.getSystemService(WINDOW_SERVICE) as WindowManager).defaultDisplay.getSize(displaySize) + zoomFactor = (displaySize.x).toDouble() / TEMPLATE_RESOLUTION.toDouble() + return binding + } + + override fun getWatchFaceStyle(): WatchFaceStyle { + return WatchFaceStyle.Builder(this) + .setAcceptsTapEvents(true) + .setHideNotificationIndicator(false) + .setShowUnreadCountIndicator(true) + .build() + } + + override fun setColorDark() { + setWatchfaceStyle() + //@ColorInt val batteryOkColor = ContextCompat.getColor(this, R.color.dark_midColor) + binding.mainLayout.setBackgroundColor(ContextCompat.getColor(this, R.color.dark_background)) + binding.sgv.setTextColor(bgColor) + binding.direction.setTextColor(bgColor) + + if (ageLevel != 1) + binding.timestamp.setTextColor(ContextCompat.getColor(this, R.color.dark_TimestampOld)) + if (status.batteryLevel != 1) + binding.uploaderBattery.setTextColor(lowBatColor) + when (loopLevel) { + -1 -> binding.loop.setBackgroundResource(R.drawable.loop_grey_25) + 1 -> binding.loop.setBackgroundResource(R.drawable.loop_green_25) + else -> binding.loop.setBackgroundResource(R.drawable.loop_red_25) + } + + basalBackgroundColor = ContextCompat.getColor(this, R.color.basal_dark) + basalCenterColor = ContextCompat.getColor(this, R.color.basal_light) + + // rotate the second hand. + binding.secondHand.rotation = TimeOfDay().secondOfMinute * 6f + // rotate the minute hand. + binding.minuteHand.rotation = TimeOfDay().minuteOfHour * 6f + // rotate the hour hand. + binding.hourHand.rotation = TimeOfDay().hourOfDay * 30f + TimeOfDay().minuteOfHour * 0.5f + + setupCharts() + } + + override fun setColorBright() { + setColorDark() + } + + override fun setColorLowRes() { + setColorDark() + } + + override fun setSecond() { + binding.time.text = "${dateUtil.hourString()}:${dateUtil.minuteString()}" + if (enableSecond) ":${dateUtil.secondString()}" else "" + binding.second.text = dateUtil.secondString() + // rotate the second hand. + binding.secondHand.rotation = TimeOfDay().secondOfMinute * 6f + //aapsLogger.debug("XXXXXX Setsecond calles:") + } + + private fun setWatchfaceStyle() { + bgColor = when (singleBg.sgvLevel) { + 1L -> highColor + 0L -> midColor + -1L -> lowColor + else -> midColor + } + val customWatchface = persistence.readCustomWatchface() ?: persistence.readCustomWatchface(true) + //aapsLogger.debug("XXXXX + setWatchfaceStyle Json ${customWatchface?.json}") + customWatchface?.let { customWatchface -> + val json = JSONObject(customWatchface.json) + val drawableDataMap = customWatchface.drawableDataMap + enableSecond = (if (json.has("enableSecond")) json.getBoolean("enableSecond") else false) && sp.getBoolean(R.string.key_show_seconds, true) + //aapsLogger.debug("XXXXXX json File (beginning):" + customWatchface.json) + + highColor = if (json.has("highColor")) Color.parseColor(json.getString("highColor")) else ContextCompat.getColor(this, R.color.dark_highColor) + midColor = if (json.has("midColor")) Color.parseColor(json.getString("midColor")) else ContextCompat.getColor(this, R.color.inrange) + lowColor = if (json.has("lowColor")) Color.parseColor(json.getString("lowColor")) else ContextCompat.getColor(this, R.color.low) + lowBatColor = if (json.has("lowBatColor")) Color.parseColor(json.getString("lowBatColor")) else ContextCompat.getColor(this, R.color.dark_uploaderBatteryEmpty) + carbColor = if (json.has("carbColor")) Color.parseColor(json.getString("carbColor")) else ContextCompat.getColor(this, R.color.carbs) + gridColor = if (json.has("gridColor")) Color.parseColor(json.getString("gridColor")) else ContextCompat.getColor(this, R.color.carbs) + pointSize = if (json.has("pointSize")) json.getInt("pointSize") else 2 + aapsLogger.debug("XXXXXX enableSecond $enableSecond ${sp.getBoolean(R.string.key_show_seconds, false)} pointSize $pointSize") + binding.mainLayout.forEach { view -> + //aapsLogger.debug("XXXXXX view:" + view.tag.toString()) + view.tag?.let { tag -> + if (json.has(tag.toString())) { + var viewjson = json.getJSONObject(tag.toString()) + //aapsLogger.debug("XXXXXX \"" + tag.toString() + "\": " + viewjson.toString(4)) + var wrapContent = LayoutParams.WRAP_CONTENT + val width = if (viewjson.has("width")) (viewjson.getInt("width") * zoomFactor).toInt() else wrapContent + val height = if (viewjson.has("height")) (viewjson.getInt("height") * zoomFactor).toInt() else wrapContent + var params = FrameLayout.LayoutParams(width, height) + params.topMargin = if (viewjson.has("topmargin")) (viewjson.getInt("topmargin") * zoomFactor).toInt() else 0 + params.leftMargin = if (viewjson.has("leftmargin")) (viewjson.getInt("leftmargin") * zoomFactor).toInt() else 0 + view.setLayoutParams(params) + view.visibility = if (viewjson.has("visibility")) setVisibility(viewjson.getString("visibility")) else View.GONE + + if (view is TextView) { + view.rotation = if (viewjson.has("rotation")) viewjson.getInt("rotation").toFloat() else 0F + view.setTextSize(TypedValue.COMPLEX_UNIT_PX, ((if (viewjson.has("textsize")) viewjson.getInt("textsize") else 22) * zoomFactor).toFloat()) + view.gravity = setGravity(if (viewjson.has("gravity")) viewjson.getString("gravity") else "center") + view.setTypeface( + setFont(if (viewjson.has("font")) viewjson.getString("font") else "sans-serif"), + Style.fromKey( viewjson.getString("fontStyle")).typeface + ) + if (viewjson.has("fontColor")) + view.setTextColor(getColor(viewjson.getString("fontColor"))) + } + + if (view is ImageView) { + view.clearColorFilter() + drawableDataMap[CustomWatchfaceDrawableDataKey.fromKey(tag.toString())]?.toDrawable(resources)?.also { + if (viewjson.has("color")) + it.colorFilter = changeDrawableColor(getColor(viewjson.getString("color"))) + else + it.clearColorFilter() + view.setImageDrawable(it) + } ?: apply { + view.setImageDrawable(CustomWatchfaceDrawableDataKey.fromKey(tag.toString()).icon?.let { context.getDrawable(it) }) + if (viewjson.has("color")) + view.setColorFilter(getColor(viewjson.getString("color"))) + else + view.clearColorFilter() + } + } + } + } + } + binding.second.visibility= ((binding.second.visibility==View.VISIBLE) && enableSecond).toVisibility() + binding.secondHand.visibility= ((binding.secondHand.visibility==View.VISIBLE) && enableSecond).toVisibility() + } + } + + private fun defaultWatchface(): EventData.ActionSetCustomWatchface { + val metadata = JSONObject() + .put(CustomWatchfaceMetadataKey.CWF_NAME.key, getString(info.nightscout.shared.R.string.wear_default_watchface)) + .put(CustomWatchfaceMetadataKey.CWF_AUTHOR.key, "Philoul") + .put(CustomWatchfaceMetadataKey.CWF_CREATED_AT.key, dateUtil.dateString(dateUtil.now())) + .put(CustomWatchfaceMetadataKey.CWF_VERSION.key, CUSTOM_VERSION) + val json = JSONObject() + .put("metadata", metadata) + .put("highColor", String.format("#%06X", 0xFFFFFF and highColor)) + .put("midColor", String.format("#%06X", 0xFFFFFF and midColor)) + .put("lowColor", String.format("#%06X", 0xFFFFFF and lowColor)) + .put("lowBatColor", String.format("#%06X", 0xFFFFFF and lowBatColor)) + .put("carbColor", String.format("#%06X", 0xFFFFFF and carbColor)) + .put("gridColor", String.format("#%06X", 0xFFFFFF and Color.WHITE)) + .put("pointSize",2) + .put("enableSecond", true) + + binding.mainLayout.forEach { view -> + val params = view.layoutParams as FrameLayout.LayoutParams + if (view is TextView) { + json.put( + view.tag.toString(), + JSONObject() + .put("width", (params.width / zoomFactor).toInt()) + .put("height", (params.height / zoomFactor).toInt()) + .put("topmargin", (params.topMargin / zoomFactor).toInt()) + .put("leftmargin", (params.leftMargin / zoomFactor).toInt()) + .put("rotation", view.rotation.toInt()) + .put("visibility", getVisibility(view.visibility)) + .put("textsize", view.textSize.toInt()) + .put("gravity", getGravity(view.gravity)) + .put("font", getFont(view.typeface)) + .put("fontStyle", Style.fromTypeface(view.typeface.style).key) + .put("fontColor", String.format("#%06X", 0xFFFFFF and view.currentTextColor)) + ) + } + if (view is ImageView) { + //view.backgroundTintList = + json.put( + view.tag.toString(), + JSONObject() + .put("width", (params.width / zoomFactor).toInt()) + .put("height", (params.height / zoomFactor).toInt()) + .put("topmargin", (params.topMargin / zoomFactor).toInt()) + .put("leftmargin", (params.leftMargin / zoomFactor).toInt()) + .put("visibility", getVisibility(view.visibility)) + ) + } + if (view is lecho.lib.hellocharts.view.LineChartView) { + json.put( + view.tag.toString(), + JSONObject() + .put("width", (params.width / zoomFactor).toInt()) + .put("height", (params.height / zoomFactor).toInt()) + .put("topmargin", (params.topMargin / zoomFactor).toInt()) + .put("leftmargin", (params.leftMargin / zoomFactor).toInt()) + .put("visibility", getVisibility(view.visibility)) + ) + } + } + val drawableDatas: CustomWatchfaceDrawableDataMap = mutableMapOf() + getResourceByteArray(info.nightscout.shared.R.drawable.watchface_custom)?.let { + val drawableDataMap = DrawableData(it,DrawableFormat.PNG) + drawableDatas[CustomWatchfaceDrawableDataKey.CUSTOM_WATCHFACE] = drawableDataMap + } + return EventData.ActionSetCustomWatchface(getString(info.nightscout.shared.R.string.wear_default_watchface),json.toString(4),drawableDatas) + } + + private fun setDefaultColors() { + highColor = Color.parseColor("#FFFF00") + midColor = Color.parseColor("#00FF00") + lowColor = Color.parseColor("#FF0000") + carbColor = ContextCompat.getColor(this, R.color.carbs) + lowBatColor = ContextCompat.getColor(this, R.color.dark_uploaderBatteryEmpty) + gridColor = Color.WHITE + } + + private fun setVisibility(visibility: String): Int = when (visibility) { + "visible" -> View.VISIBLE + "invisible" -> View.INVISIBLE + "gone" -> View.GONE + else -> View.GONE + } + + private fun getVisibility(visibility: Int): String = when (visibility) { + View.VISIBLE -> "visible" + View.INVISIBLE -> "invisible" + View.GONE -> "gone" + else -> "gone" + } + + private fun setGravity(gravity: String): Int = when (gravity) { + "center" -> Gravity.CENTER + "left" -> Gravity.LEFT + "right" -> Gravity.RIGHT + else -> Gravity.CENTER + } + + private fun getGravity(gravity: Int): String = when (gravity) { + Gravity.CENTER -> "center" + Gravity.LEFT -> "left" + Gravity.RIGHT -> "right" + else -> "center" + } + + private fun setFont(font: String): Typeface = when (font) { + "sans-serif" -> Typeface.SANS_SERIF + "default" -> Typeface.DEFAULT + "default-bold" -> Typeface.DEFAULT_BOLD + "monospace" -> Typeface.MONOSPACE + "serif" -> Typeface.SERIF + "roboto-condensed-bold" -> ResourcesCompat.getFont(context, R.font.roboto_condensed_bold)!! + "roboto-condensed-light" -> ResourcesCompat.getFont(context, R.font.roboto_condensed_light)!! + "roboto-condensed-regular" -> ResourcesCompat.getFont(context, R.font.roboto_condensed_regular)!! + "roboto-slab-light" -> ResourcesCompat.getFont(context, R.font.roboto_slab_light)!! + else -> Typeface.DEFAULT + } + + private fun getFont(font: Typeface): String = when (font) { + Typeface.SANS_SERIF -> "sans-serif" + Typeface.DEFAULT -> "default" + Typeface.DEFAULT_BOLD -> "default-bold" + Typeface.MONOSPACE -> "monospace" + Typeface.SERIF -> "serif" + ResourcesCompat.getFont(context, R.font.roboto_condensed_bold)!! -> "roboto-condensed-bold" + ResourcesCompat.getFont(context, R.font.roboto_condensed_light)!! -> "roboto-condensed-light" + ResourcesCompat.getFont(context, R.font.roboto_condensed_regular)!! -> "roboto-condensed-regular" + ResourcesCompat.getFont(context, R.font.roboto_slab_light)!! -> "roboto-slab-light" + else -> "default" + } + + enum class Style(val key: String, val typeface: Int) { + NORMAL("normal", Typeface.NORMAL), + BOLD("bold", Typeface.BOLD), + BOLD_ITALIC("bold-italic", Typeface.BOLD_ITALIC), + ITALIC("italic", Typeface.ITALIC); + companion object { + private val keyToEnumMap = HashMap() + private val typefaceToEnumMap = HashMap() + init { + for (value in values()) keyToEnumMap[value.key] = value + for (value in values()) typefaceToEnumMap[value.typeface] = value + } + fun fromKey(key: String?): Style = + if (keyToEnumMap.containsKey(key)) { + keyToEnumMap[key] ?:NORMAL + } else { + NORMAL + } + fun fromTypeface(typeface: Int?): Style = + if (typefaceToEnumMap.containsKey(typeface)) { + typefaceToEnumMap[typeface] ?:NORMAL + } else { + NORMAL + } + } + } + fun getResourceByteArray(resourceId: Int): ByteArray? { + val inputStream = resources.openRawResource(resourceId) + val byteArrayOutputStream = ByteArrayOutputStream() + + try { + val buffer = ByteArray(1024) + var count: Int + while (inputStream.read(buffer).also { count = it } != -1) { + byteArrayOutputStream.write(buffer, 0, count) + } + byteArrayOutputStream.close() + inputStream.close() + + return byteArrayOutputStream.toByteArray() + } catch (e: Exception) { + } + return null + } + + private fun changeDrawableColor(color: Int): ColorFilter { + val colorMatrix = ColorMatrix() + colorMatrix.setSaturation(0f) // 0 désature l'image, 1 la laisse inchangée. + + // Modifier la teinte de couleur (couleur de fond) + colorMatrix.postConcat( + ColorMatrix( + floatArrayOf( + Color.red(color) / 255f, 0f, 0f, 0f, 0f, + 0f, Color.green(color) / 255f, 0f, 0f, 0f, + 0f, 0f, Color.blue(color) / 255f, 0f, 0f, + 0f, 0f, 0f, Color.alpha(color) / 255f, 0f + ) + ) + ) + + // Appliquer la matrice de couleur au ColorFilter + return ColorMatrixColorFilter(colorMatrix) + + // Appliquer le ColorFilter au Drawable + //drawable.colorFilter = colorFilter + //return drawable + } + + private fun getColor(color: String): Int { + if (color == "bgColor") + return bgColor + else + return Color.parseColor(color) + } + +} diff --git a/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/BaseWatchFace.kt b/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/BaseWatchFace.kt index a1cc53eab9..4a7fd3a5cf 100644 --- a/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/BaseWatchFace.kt +++ b/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/BaseWatchFace.kt @@ -36,6 +36,7 @@ import info.nightscout.shared.extensions.toVisibilityKeepSpace import info.nightscout.shared.sharedPreferences.SP import info.nightscout.shared.utils.DateUtil import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.kotlin.plusAssign import javax.inject.Inject import kotlin.math.floor @@ -78,12 +79,15 @@ abstract class BaseWatchFace : WatchFace() { var gridColor = Color.WHITE var basalBackgroundColor = Color.BLUE var basalCenterColor = Color.BLUE + var carbColor = Color.GREEN private var bolusColor = Color.MAGENTA private var lowResMode = false private var layoutSet = false var bIsRound = false var dividerMatchesBg = false var pointSize = 2 + var enableSecond = false + var updateSecond: Disposable? = null // Tapping times private var sgvTapTime: Long = 0 @@ -245,12 +249,13 @@ abstract class BaseWatchFace : WatchFace() { override fun onDestroy() { disposable.clear() + updateSecond?.dispose() simpleUi.onDestroy() super.onDestroy() } override fun getInteractiveModeUpdateRate(): Long { - return 60 * 1000L // Only call onTimeChanged every 60 seconds + return if (enableSecond) 1000L else 60 * 1000L // Only call onTimeChanged every 60 seconds } override fun onDraw(canvas: Canvas) { @@ -271,6 +276,8 @@ abstract class BaseWatchFace : WatchFace() { missedReadingAlert() checkVibrateHourly(oldTime, newTime) if (!simpleUi.isEnabled(currentWatchMode)) setDataFields() + } else if (layoutSet && !simpleUi.isEnabled(currentWatchMode) && enableSecond && newTime.hasSecondChanged(oldTime)) { + setSecond() } } @@ -349,9 +356,10 @@ abstract class BaseWatchFace : WatchFace() { } private fun setDateAndTime() { - binding.time?.text = if(binding.timePeriod == null) dateUtil.timeString() else dateUtil.hourString() + ":" + dateUtil.minuteString() + binding.time?.text = if(binding.timePeriod == null) dateUtil.timeString() else dateUtil.hourString() + ":" + dateUtil.minuteString() + if (enableSecond) ":" + dateUtil.secondString() else "" binding.hour?.text = dateUtil.hourString() binding.minute?.text = dateUtil.minuteString() + binding.second?.text = dateUtil.secondString() binding.dateTime?.visibility = sp.getBoolean(R.string.key_show_date, false).toVisibility() binding.dayName?.text = dateUtil.dayNameString() binding.day?.text = dateUtil.dayString() @@ -360,7 +368,12 @@ abstract class BaseWatchFace : WatchFace() { binding.timePeriod?.text = dateUtil.amPm() } - private fun setColor() { + open fun setSecond() { + binding.time?.text = if(binding.timePeriod == null) dateUtil.timeString() else dateUtil.hourString() + ":" + dateUtil.minuteString() + if (enableSecond) ":" + dateUtil.secondString() else "" + binding.second?.text = dateUtil.secondString() + } + + fun setColor() { dividerMatchesBg = sp.getBoolean(R.string.key_match_divider, false) when { lowResMode -> setColorLowRes() @@ -382,6 +395,14 @@ abstract class BaseWatchFace : WatchFace() { if (simpleUi.isEnabled(currentWatchMode)) simpleUi.setAntiAlias(currentWatchMode) else setDataFields() invalidate() + /* + if (enableSecond) + if (updateSecond == null) + updateSecond = aapsSchedulers.io.schedulePeriodicallyDirect( + ::setSecond, 1000L, 1000L, TimeUnit.MILLISECONDS) + else + updateSecond?.dispose() + */ } private fun isLowRes(watchMode: WatchMode): Boolean { @@ -409,12 +430,12 @@ abstract class BaseWatchFace : WatchFace() { if (lowResMode) BgGraphBuilder( sp, dateUtil, graphData.entries, treatmentData.predictions, treatmentData.temps, treatmentData.basals, treatmentData.boluses, pointSize, - midColor, gridColor, basalBackgroundColor, basalCenterColor, bolusColor, Color.GREEN, timeframe + midColor, gridColor, basalBackgroundColor, basalCenterColor, bolusColor, carbColor, timeframe ) else BgGraphBuilder( sp, dateUtil, graphData.entries, treatmentData.predictions, treatmentData.temps, treatmentData.basals, treatmentData.boluses, - pointSize, highColor, lowColor, midColor, gridColor, basalBackgroundColor, basalCenterColor, bolusColor, Color.GREEN, timeframe + pointSize, highColor, lowColor, midColor, gridColor, basalBackgroundColor, basalCenterColor, bolusColor, carbColor, timeframe ) binding.chart?.lineChartData = bgGraphBuilder.lineData() binding.chart?.isViewportCalculationEnabled = true diff --git a/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/WatchfaceViewAdapter.kt b/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/WatchfaceViewAdapter.kt index 6a2f78d954..61333ad1e1 100644 --- a/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/WatchfaceViewAdapter.kt +++ b/wear/src/main/java/info/nightscout/androidaps/watchfaces/utils/WatchfaceViewAdapter.kt @@ -6,6 +6,7 @@ import info.nightscout.androidaps.databinding.ActivityHome2Binding import info.nightscout.androidaps.databinding.ActivityHomeBinding import info.nightscout.androidaps.databinding.ActivityBigchartBinding import info.nightscout.androidaps.databinding.ActivityCockpitBinding +import info.nightscout.androidaps.databinding.ActivityCustomBinding import info.nightscout.androidaps.databinding.ActivityDigitalstyleBinding import info.nightscout.androidaps.databinding.ActivityNochartBinding import info.nightscout.androidaps.databinding.ActivitySteampunkBinding @@ -22,11 +23,12 @@ class WatchfaceViewAdapter( cp: ActivityCockpitBinding? = null, ds: ActivityDigitalstyleBinding? = null, nC: ActivityNochartBinding? = null, - sP: ActivitySteampunkBinding? = null + sP: ActivitySteampunkBinding? = null, + cU: ActivityCustomBinding? = null ) { init { - if (aL == null && a2 == null && aa == null && bC == null && cp == null && ds == null && nC == null && sP == null) { + if (aL == null && a2 == null && aa == null && bC == null && cp == null && ds == null && nC == null && sP == null && cU == null) { throw IllegalArgumentException("Require at least on Binding parameter") } } @@ -34,39 +36,40 @@ class WatchfaceViewAdapter( private val errorMessage = "Missing require View Binding parameter" // Required attributes val mainLayout = - aL?.mainLayout ?: a2?.mainLayout ?: aa?.mainLayout ?: bC?.mainLayout ?: bC?.mainLayout ?: cp?.mainLayout ?: ds?.mainLayout ?: nC?.mainLayout ?: sP?.mainLayout + aL?.mainLayout ?: a2?.mainLayout ?: aa?.mainLayout ?: bC?.mainLayout ?: bC?.mainLayout ?: cp?.mainLayout ?: ds?.mainLayout ?: nC?.mainLayout ?: sP?.mainLayout ?: cU?.mainLayout ?: throw IllegalArgumentException(errorMessage) val timestamp = - aL?.timestamp ?: a2?.timestamp ?: aa?.timestamp ?: bC?.timestamp ?: bC?.timestamp ?: cp?.timestamp ?: ds?.timestamp ?: nC?.timestamp ?: sP?.timestamp + aL?.timestamp ?: a2?.timestamp ?: aa?.timestamp ?: bC?.timestamp ?: bC?.timestamp ?: cp?.timestamp ?: ds?.timestamp ?: nC?.timestamp ?: sP?.timestamp ?: cU?.timestamp ?: throw IllegalArgumentException(errorMessage) val root = - aL?.root ?: a2?.root ?: aa?.root ?: bC?.root ?: bC?.root ?: cp?.root ?: ds?.root ?: nC?.root ?: sP?.root + aL?.root ?: a2?.root ?: aa?.root ?: bC?.root ?: bC?.root ?: cp?.root ?: ds?.root ?: nC?.root ?: sP?.root ?: cU?.root ?: throw IllegalArgumentException(errorMessage) // Optional attributes - val sgv = aL?.sgv ?: a2?.sgv ?: aa?.sgv ?: bC?.sgv ?: bC?.sgv ?: cp?.sgv ?: ds?.sgv ?: nC?.sgv - val direction = aL?.direction ?: a2?.direction ?: aa?.direction ?: cp?.direction ?: ds?.direction - val loop = a2?.loop ?: cp?.loop ?: sP?.loop - val delta = aL?.delta ?: a2?.delta ?: aa?.delta ?: bC?.delta ?: bC?.delta ?: cp?.delta ?: ds?.delta ?: nC?.delta - val avgDelta = a2?.avgDelta ?: bC?.avgDelta ?: bC?.avgDelta ?: cp?.avgDelta ?: ds?.avgDelta ?: nC?.avgDelta - val uploaderBattery = aL?.uploaderBattery ?: a2?.uploaderBattery ?: aa?.uploaderBattery ?: cp?.uploaderBattery ?: ds?.uploaderBattery ?: sP?.uploaderBattery - val rigBattery = a2?.rigBattery ?: cp?.rigBattery ?: ds?.rigBattery ?: sP?.rigBattery - val basalRate = a2?.basalRate ?: cp?.basalRate ?: ds?.basalRate ?: sP?.basalRate - val bgi = a2?.bgi ?: ds?.bgi - val AAPSv2 = a2?.AAPSv2 ?: cp?.AAPSv2 ?: ds?.AAPSv2 ?: sP?.AAPSv2 - val cob1 = a2?.cob1 ?: ds?.cob1 - val cob2 = a2?.cob2 ?: cp?.cob2 ?: ds?.cob2 ?: sP?.cob2 - val time = aL?.time ?: a2?.time ?: aa?.time ?: bC?.time ?: bC?.time ?: cp?.time ?: nC?.time - val minute = ds?.minute - val hour = ds?.hour - val day = a2?.day ?: ds?.day - val month = a2?.month ?: ds?.month - val iob1 = a2?.iob1 ?: ds?.iob1 - val iob2 = a2?.iob2 ?: cp?.iob2 ?: ds?.iob2 ?: sP?.iob2 - val chart = a2?.chart ?: aa?.chart ?: bC?.chart ?: bC?.chart ?: ds?.chart ?: sP?.chart + val sgv = aL?.sgv ?: a2?.sgv ?: aa?.sgv ?: bC?.sgv ?: bC?.sgv ?: cp?.sgv ?: ds?.sgv ?: nC?.sgv ?: cU?.sgv + val direction = aL?.direction ?: a2?.direction ?: aa?.direction ?: cp?.direction ?: ds?.direction ?: cU?.direction + val loop = a2?.loop ?: cp?.loop ?: sP?.loop ?: cU?.loop + val delta = aL?.delta ?: a2?.delta ?: aa?.delta ?: bC?.delta ?: bC?.delta ?: cp?.delta ?: ds?.delta ?: nC?.delta ?: cU?.delta + val avgDelta = a2?.avgDelta ?: bC?.avgDelta ?: bC?.avgDelta ?: cp?.avgDelta ?: ds?.avgDelta ?: nC?.avgDelta ?: cU?.avgDelta + val uploaderBattery = aL?.uploaderBattery ?: a2?.uploaderBattery ?: aa?.uploaderBattery ?: cp?.uploaderBattery ?: ds?.uploaderBattery ?: sP?.uploaderBattery ?: cU?.uploaderBattery + val rigBattery = a2?.rigBattery ?: cp?.rigBattery ?: ds?.rigBattery ?: sP?.rigBattery ?: cU?.rigBattery + val basalRate = a2?.basalRate ?: cp?.basalRate ?: ds?.basalRate ?: sP?.basalRate ?: cU?.basalRate + val bgi = a2?.bgi ?: ds?.bgi ?: cU?.bgi + val AAPSv2 = a2?.AAPSv2 ?: cp?.AAPSv2 ?: ds?.AAPSv2 ?: sP?.AAPSv2 ?: cU?.AAPSv2 + val cob1 = a2?.cob1 ?: ds?.cob1 ?: cU?.cob1 + val cob2 = a2?.cob2 ?: cp?.cob2 ?: ds?.cob2 ?: sP?.cob2 ?: cU?.cob2 + val time = aL?.time ?: a2?.time ?: aa?.time ?: bC?.time ?: bC?.time ?: cp?.time ?: nC?.time ?: cU?.time + val second = cU?.second + val minute = ds?.minute ?: cU?.minute + val hour = ds?.hour ?: cU?.hour + val day = a2?.day ?: ds?.day ?: cU?.day + val month = a2?.month ?: ds?.month ?: cU?.month + val iob1 = a2?.iob1 ?: ds?.iob1 ?: cU?.iob1 + val iob2 = a2?.iob2 ?: cp?.iob2 ?: ds?.iob2 ?: sP?.iob2 ?: cU?.iob2 + val chart = a2?.chart ?: aa?.chart ?: bC?.chart ?: bC?.chart ?: ds?.chart ?: sP?.chart ?: cU?.chart val status = aL?.status ?: aa?.status ?: bC?.status ?: bC?.status ?: nC?.status - val timePeriod = ds?.timePeriod ?: aL?.timePeriod ?: nC?.timePeriod ?: bC?.timePeriod - val dayName = ds?.dayName + val timePeriod = ds?.timePeriod ?: aL?.timePeriod ?: nC?.timePeriod ?: bC?.timePeriod ?: cU?.timePeriod + val dayName = ds?.dayName ?: cU?.dayName val mainMenuTap = ds?.mainMenuTap ?: sP?.mainMenuTap val chartZoomTap = ds?.chartZoomTap ?: sP?.chartZoomTap val dateTime = ds?.dateTime ?: a2?.dateTime @@ -91,6 +94,7 @@ class WatchfaceViewAdapter( is ActivityDigitalstyleBinding -> WatchfaceViewAdapter(null, null, null, null, null, bindLayout) is ActivityNochartBinding -> WatchfaceViewAdapter(null, null, null, null, null, null, bindLayout) is ActivitySteampunkBinding -> WatchfaceViewAdapter(null, null, null, null, null, null, null, bindLayout) + is ActivityCustomBinding -> WatchfaceViewAdapter(null, null, null, null, null, null, null, null, bindLayout) else -> throw IllegalArgumentException("ViewBinding is not implement in WatchfaceViewAdapter") } } diff --git a/wear/src/main/res/layout/activity_custom.xml b/wear/src/main/res/layout/activity_custom.xml new file mode 100644 index 0000000000..a1172a3c3f --- /dev/null +++ b/wear/src/main/res/layout/activity_custom.xml @@ -0,0 +1,379 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index a9f53bd230..b29ad82733 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ AAPS(Cockpit) AAPS(Steampunk) AAPS(DigitalStyle) + AAPS(Custom) AAPS(Actions) AAPS(Temp Target) AAPS(Quick Wizard) @@ -143,6 +144,7 @@ Only show time and BG Vibrate hourly Show Week number + Show seconds Your style: no style minimal style @@ -208,6 +210,14 @@ complication_tap_action carbs_button_increment_1 carbs_button_increment_2 + enable_custom_setting + digital_watchface + digital_timing + analog_watchface + show_second + highcolor + midcolor + lowcolor increment decrement H diff --git a/wear/src/main/res/xml/watch_face_configuration_custom.xml b/wear/src/main/res/xml/watch_face_configuration_custom.xml new file mode 100644 index 0000000000..ff998be701 --- /dev/null +++ b/wear/src/main/res/xml/watch_face_configuration_custom.xml @@ -0,0 +1,27 @@ + + + + + + + + + +