├── monkey.jungle ├── publish ├── AppIcon.xcf ├── CoverImage.png ├── CoverImage.xcf ├── Screenshot1.png ├── Screenshot2.png ├── Screenshot3.png └── Connect IQ Badge-White.png ├── .gitignore ├── resources ├── drawables │ ├── launcher_icon.png │ └── drawables.xml ├── layouts │ └── layout.xml ├── strings │ └── strings.xml └── settings │ ├── properties.xml │ └── settings.xml ├── properties.mk.example ├── source ├── OtpMenuDelegate.mc ├── drawable │ ├── BackgroundView.mc │ └── TimerCircleView.mc ├── util │ ├── Constants.mc │ ├── AppTimers.mc │ └── AppData.mc ├── OtpApp.mc ├── otp │ ├── Hmac.mc │ ├── Convert.mc │ ├── Otp.mc │ └── Sha1.mc ├── OtpWidgetDelegate.mc ├── OtpWidgetView.mc └── OtpDataProvider.mc ├── tests └── otp │ ├── ConvertTest.mc │ ├── HmacTest.mc │ ├── Sha1Test.mc │ └── OtpTest.mc ├── LICENSE ├── README.md ├── Makefile ├── .github └── workflows │ └── make.yml └── manifest.xml /monkey.jungle: -------------------------------------------------------------------------------- 1 | project.manifest = manifest.xml 2 | -------------------------------------------------------------------------------- /publish/AppIcon.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jctim/otp-ciq/HEAD/publish/AppIcon.xcf -------------------------------------------------------------------------------- /publish/CoverImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jctim/otp-ciq/HEAD/publish/CoverImage.png -------------------------------------------------------------------------------- /publish/CoverImage.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jctim/otp-ciq/HEAD/publish/CoverImage.xcf -------------------------------------------------------------------------------- /publish/Screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jctim/otp-ciq/HEAD/publish/Screenshot1.png -------------------------------------------------------------------------------- /publish/Screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jctim/otp-ciq/HEAD/publish/Screenshot2.png -------------------------------------------------------------------------------- /publish/Screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jctim/otp-ciq/HEAD/publish/Screenshot3.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .lh/ 3 | .vscode/ 4 | *.iml 5 | 6 | out/ 7 | output/ 8 | bin/ 9 | 10 | properties.mk -------------------------------------------------------------------------------- /publish/Connect IQ Badge-White.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jctim/otp-ciq/HEAD/publish/Connect IQ Badge-White.png -------------------------------------------------------------------------------- /resources/drawables/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jctim/otp-ciq/HEAD/resources/drawables/launcher_icon.png -------------------------------------------------------------------------------- /resources/drawables/drawables.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /properties.mk.example: -------------------------------------------------------------------------------- 1 | DEVICE = vivoactive3 2 | SDK_HOME = /path/to/connectiq-sdk-mac-2.4.1 3 | DEPLOY = /Volumes/GARMIN/GARMIN/APPS/ 4 | DEVELOPER_KEY = /path/to/developer_key -------------------------------------------------------------------------------- /resources/layouts/layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /source/OtpMenuDelegate.mc: -------------------------------------------------------------------------------- 1 | using Toybox.WatchUi as Ui; 2 | using Toybox.System as Sys; 3 | 4 | class OtpMenuDelegate extends Ui.MenuInputDelegate { 5 | 6 | var dataProvider; 7 | 8 | function initialize(dataProvider) { 9 | Ui.MenuInputDelegate.initialize(); 10 | self.dataProvider = dataProvider; 11 | } 12 | 13 | function onMenuItem(item) { 14 | dataProvider.setCurrentAccountIdx(item); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /source/drawable/BackgroundView.mc: -------------------------------------------------------------------------------- 1 | using Toybox.WatchUi as Ui; 2 | using Toybox.Application as App; 3 | using Toybox.Graphics as Gfx; 4 | using Toybox.System as Sys; 5 | 6 | class BackgroundView extends Ui.Drawable { 7 | 8 | function initialize(params) { 9 | Drawable.initialize(params); 10 | } 11 | 12 | function draw(dc) { 13 | // Set the background color then call to clear the screen 14 | dc.setColor(Gfx.COLOR_TRANSPARENT, AppData.readProperty(Constants.BG_COLOR_PROP)); 15 | dc.clear(); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /source/util/Constants.mc: -------------------------------------------------------------------------------- 1 | module Constants { 2 | 3 | const MAX_ACCOUNTS = 10; 4 | 5 | // time consts 6 | const TIME_STEP_SEC = 30; 7 | const RED_ZONE_SEC = 25; 8 | 9 | // ui consts 10 | const ANGEL_MULTIPLIER = 360 / TIME_STEP_SEC; 11 | const START_ANGEL = 270; 12 | const ROUND_WIDTH = 12; 13 | 14 | // storage key const 15 | const CURRENT_ACC_IDX_KEY = "CurrentAccountIdx"; 16 | // property consts 17 | const BG_COLOR_PROP = "BackgroundColor"; 18 | const FG_COLOR_PROP = "ForegroundColor"; 19 | const CIRCLE_TIMER_COLOR_PROP = "CircleTimerColor"; 20 | const CIRCLE_TIMER_ARROWS_PROP = "CircleTimerArrows"; 21 | } -------------------------------------------------------------------------------- /source/util/AppTimers.mc: -------------------------------------------------------------------------------- 1 | using Toybox.Time as Time; 2 | using Toybox.Timer as Timer; 3 | using Toybox.System as Sys; 4 | 5 | module AppTimers { 6 | 7 | var uiTimer = new Timer.Timer(); 8 | 9 | function startUiTimer(callbackMethod) { 10 | var now = currentTime(); 11 | uiTimer.start(callbackMethod, 1000, true); 12 | Sys.println("UI Timer started at " + now); 13 | } 14 | 15 | function stopUiTimer() { 16 | var now = currentTime(); 17 | uiTimer.stop(); 18 | Sys.println("UI Timer stopped at " + now); 19 | } 20 | 21 | 22 | function currentTime() { 23 | return Time.now().value(); 24 | } 25 | } -------------------------------------------------------------------------------- /tests/otp/ConvertTest.mc: -------------------------------------------------------------------------------- 1 | module ConvertTest { 2 | 3 | (:test) 4 | function testByteArrayToHexString(logger) { 5 | var hexString = Convert.byteArrayToHexString([169, 153, 62, 54, 71, 6, 129, 106, 186, 62]); 6 | return hexString.equals("A9993E364706816ABA3E"); 7 | } 8 | 9 | (:test) 10 | function testHexStringToByteArray(logger) { 11 | var byteArray = Convert.hexStringToByteArray("A9993E364706816ABA3E"); 12 | return [169, 153, 62, 54, 71, 6, 129, 106, 186, 62].toString().equals(byteArray.toString()); 13 | } 14 | 15 | (:test) 16 | function testbase32decode2HexSring(logger) { 17 | var hexString = Convert.base32decode2HexString("VK54ZXPO74"); 18 | return hexString.equals("AABBCCDDEEFF"); 19 | } 20 | } -------------------------------------------------------------------------------- /source/OtpApp.mc: -------------------------------------------------------------------------------- 1 | using Toybox.Application as App; 2 | using Toybox.System as Sys; 3 | using Toybox.WatchUi as Ui; 4 | 5 | class OtpApp extends App.AppBase { 6 | 7 | var dataProvider; 8 | var view; 9 | var delegate; 10 | 11 | function initialize() { 12 | AppBase.initialize(); 13 | self.dataProvider = new OtpDataProvider(); 14 | } 15 | 16 | // onStart() is called on application start up 17 | function onStart(state) { 18 | } 19 | 20 | // onStop() is called when your application is exiting 21 | function onStop(state) { 22 | } 23 | 24 | // Return the initial view of your application here 25 | function getInitialView() { 26 | view = new OtpWidgetView(dataProvider); 27 | delegate = new OtpWidgetDelegate(view, dataProvider); 28 | return [ view, delegate ]; 29 | } 30 | 31 | // New app settings have been received so trigger a UI update 32 | function onSettingsChanged() { 33 | self.dataProvider.reloadData(); 34 | Ui.switchToView(view, delegate, Ui.SLIDE_LEFT); 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Illia Tkachuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /source/otp/Hmac.mc: -------------------------------------------------------------------------------- 1 | //! HMAC implementation with SHA1 2 | //! https://tools.ietf.org/html/rfc2104 3 | import Toybox.Lang; 4 | 5 | module Hmac { 6 | 7 | const BLOCK_SIZE = 64; 8 | 9 | //! Authenticate given text involving SHA1 and secret key 10 | //! @param [Toybox::Lang::Array] key The byte array of secret key 11 | //! @param [Toybox::Lang::Array] text The byte array of message to be authenticated 12 | //! @return [Toybox::Lang::Array] the authentication code as byte array 13 | function authenticateWithSha1(key as Array, text as Array) as Array { 14 | if (key.size() > BLOCK_SIZE) { 15 | key = Sha1.encode(key); 16 | } 17 | 18 | // HMAC = H(K XOR opad, H(K XOR ipad, text)), where H = SHA1 19 | var ipad = new [BLOCK_SIZE]; 20 | var opad = new [BLOCK_SIZE]; 21 | for (var i = 0; i < BLOCK_SIZE; i++) { 22 | var k = i < key.size() ? key[i] : 0x00; 23 | ipad[i] = k ^ 0x36; 24 | opad[i] = k ^ 0x5C; 25 | } 26 | 27 | return Sha1.encode(opad.addAll(Sha1.encode(ipad.addAll(text)))); 28 | } 29 | } -------------------------------------------------------------------------------- /tests/otp/HmacTest.mc: -------------------------------------------------------------------------------- 1 | module HmacTest { 2 | 3 | (:test) 4 | function test1AuthenticateWithSha1(logger) { 5 | // test example from Wiki - https://en.wikipedia.org/wiki/Hash-based_message_authentication_code#Examples 6 | // HMAC_SHA1("key", "The quick brown fox jumps over the lazy dog") = de7c9b85b8b78aa6bc8a7a36f70a90701c9db4d9 7 | var hmac = Hmac.authenticateWithSha1( 8 | "key".toUtf8Array(), 9 | "The quick brown fox jumps over the lazy dog".toUtf8Array()); 10 | logger.debug(hmac); 11 | var hmacHex = Convert.byteArrayToHexString(hmac); 12 | logger.debug(hmacHex); 13 | return hmacHex.equals("DE7C9B85B8B78AA6BC8A7A36F70A90701C9DB4D9"); 14 | } 15 | 16 | (:test) 17 | function test2AuthenticateWithSha1(logger) { 18 | // test example from Wiki - https://en.wikipedia.org/wiki/Hash-based_message_authentication_code#Examples 19 | // HMAC_SHA1("", "") = fbdb1d1b18aa6c08324b7d64b71fb76370690e1d 20 | var hmac = Hmac.authenticateWithSha1( 21 | "".toUtf8Array(), 22 | "".toUtf8Array()); 23 | logger.debug(hmac); 24 | var hmacHex = Convert.byteArrayToHexString(hmac); 25 | logger.debug(hmacHex); 26 | return hmacHex.equals("FBDB1D1B18AA6C08324B7D64B71FB76370690E1D"); 27 | } 28 | } -------------------------------------------------------------------------------- /resources/strings/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | OTP Auth 4 | 5 | Background Color 6 | Foreground Color 7 | Time Circle Color 8 | Use Arrows in Circle Timer 9 | 10 | Black 11 | Dark Gray 12 | Light Gray 13 | White 14 | Blue 15 | Green 16 | 17 | Account 1 18 | Account 2 19 | Account 3 20 | Account 4 21 | Account 5 22 | Account 6 23 | Account 7 24 | Account 8 25 | Account 9 26 | Account 10 27 | 28 | Account Name 29 | Account Secret Key 30 | 31 | 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [version 1](https://apps.garmin.com/en-US/apps/f341dc64-bf39-4224-9c03-14d2434354a4) 2 | 3 | # otp-ciq 4 | 5 | **OTP Auth Widget for Garmin Connect IQ** - manager of one-time passwords right on a Garmin wearable device. 6 | 7 | It actually consists of two parts: 8 | 9 | - the 'settings' part aimed to enter secret keys for several accounts (limited by 10), it runs on a smartphone (Garmin Connect™ mobile app) 10 | - the generator and viewer of one-time passwords on a Garmin wearable device 11 | 12 | ### Existing Features 13 | 14 | - Supported all existing round Garmin watches 15 | - One-time passwords are 6-digit codes 16 | - Secret keys are transferred from Garmin Connect™ to a wearable device and stay there in [Application Storage](https://developer.garmin.com/downloads/connect-iq/monkey-c/doc/Toybox/Application/Storage.html) (for Connect IQ 2.4 and higher) or in [App Properties](https://developer.garmin.com/downloads/connect-iq/monkey-c/doc/Toybox/Application/AppBase.html#getProperty-instance_method) for older devices (e.g. Fenix3) 17 | - Secret codes can be entered and copied directly to corresponding text inputs (including spaces between groups) 18 | 19 | ### TODO Features 20 | 21 | - Support Garmin watches from square family 22 | 23 | # Links 24 | 25 | - [Connect IQ Store](https://apps.garmin.com/en-US/apps/f341dc64-bf39-4224-9c03-14d2434354a4) 26 | - [TOTP RFC](https://tools.ietf.org/html/rfc6238) 27 | 28 | # License 29 | 30 | The source code is released under the [MIT license](https://opensource.org/licenses/MIT) 31 | -------------------------------------------------------------------------------- /source/OtpWidgetDelegate.mc: -------------------------------------------------------------------------------- 1 | using Toybox.WatchUi as Ui; 2 | using Toybox.System as Sys; 3 | import Toybox.Lang; 4 | 5 | class OtpWidgetDelegate extends Ui.BehaviorDelegate { 6 | 7 | var mainView; 8 | var dataProvider; 9 | var menuDelegate; 10 | 11 | function initialize(mainView, dataProvider) { 12 | Ui.BehaviorDelegate.initialize(); 13 | self.mainView = mainView; 14 | self.dataProvider = dataProvider; 15 | self.menuDelegate = new OtpMenuDelegate(dataProvider); 16 | } 17 | 18 | function onKey(key) as Boolean { 19 | if (key.getKey() == Ui.KEY_ENTER) { 20 | toNextOtpUi(); 21 | return true; 22 | } 23 | return false; 24 | } 25 | 26 | function onTap(evt) as Boolean { 27 | if (evt.getType() == Ui.CLICK_TYPE_TAP) { 28 | toNextOtpUi(); 29 | return true; 30 | } 31 | return false; 32 | } 33 | 34 | function onMenu() as Boolean { 35 | var enabledAccounts = dataProvider.getEnabledAccounts() as Array; 36 | if (enabledAccounts.size() == 0) { 37 | return false; 38 | } 39 | 40 | var menuView = new Ui.Menu(); 41 | for (var i = 0; i < enabledAccounts.size(); i++) { 42 | menuView.addItem(enabledAccounts[i].name, i as Symbol); 43 | } 44 | Ui.pushView(menuView, menuDelegate, Ui.SLIDE_UP); 45 | return true; 46 | } 47 | 48 | hidden function toNextOtpUi() { 49 | if (dataProvider.nextOtp()) { 50 | Ui.switchToView(mainView, self, Ui.SLIDE_LEFT); 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /source/otp/Convert.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Lang; 2 | 3 | module Convert { 4 | 5 | function byteArrayToHexString(bytes as Array) { 6 | var str = ""; 7 | for (var i = 0; i < bytes.size(); i++) { 8 | str += bytes[i].format("%02X"); 9 | } 10 | return str; 11 | } 12 | 13 | function hexStringToByteArray(hexStr) { 14 | var bytes = new [hexStr.length() / 2]; 15 | for (var i = 0; i < bytes.size(); i++) { 16 | var hexIdx = i * 2; 17 | bytes[i] = hexStr.substring(hexIdx, hexIdx + 2).toNumberWithBase(16); 18 | } 19 | return bytes; 20 | } 21 | 22 | const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; 23 | const BASE32 = ALPHABET.toCharArray(); 24 | const SHIFT = 5; 25 | const CSIZE = 8; 26 | 27 | function base32decode2HexString(encoded as String) { 28 | var encodedChars = encoded.toUpper().toCharArray(); 29 | var result = new [encodedChars.size() * SHIFT / CSIZE]; 30 | 31 | var resultStr = ""; 32 | var buffer = 0; 33 | var next = 0; 34 | var bitsLeft = 0; 35 | 36 | for (var i = 0; i < encodedChars.size(); i++) { 37 | buffer <<= SHIFT; 38 | buffer |= BASE32.indexOf(encodedChars[i]) & 0x1F; 39 | bitsLeft += SHIFT; 40 | if (bitsLeft >= CSIZE) { 41 | result[next] = (buffer >> (bitsLeft - CSIZE) & 0xFF); 42 | resultStr += result[next].format("%02X"); 43 | bitsLeft -= CSIZE; 44 | next++; 45 | } 46 | } 47 | return resultStr; 48 | } 49 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include properties.mk 2 | 3 | GREP := $(shell command -v ggrep >/dev/null 2>&1 && echo ggrep || echo grep) 4 | APP_NAME = $(shell $(GREP) entry manifest.xml | sed 's/.*entry="\([^"]*\).*/\1/') 5 | ALL_DEVICES = $(shell $(GREP) -Po 'product id="\K[^"]*' manifest.xml | tr '\n' ' ') 6 | 7 | build: 8 | @echo "Building $(APP_NAME) for $(DEVICE)..."; 9 | @$(SDK_HOME)/bin/monkeyc --warn --output bin/$(APP_NAME)-$(DEVICE).prg \ 10 | -f ./monkey.jungle \ 11 | -y $(DEVELOPER_KEY) \ 12 | -d $(DEVICE) 13 | 14 | build-test: 15 | @echo "Building $(APP_NAME)-test for $(DEVICE)..."; 16 | @$(SDK_HOME)/bin/monkeyc --warn --output bin/$(APP_NAME)-$(DEVICE)-test.prg \ 17 | -f ./monkey.jungle \ 18 | --unit-test \ 19 | -y $(DEVELOPER_KEY) \ 20 | -d $(DEVICE) 21 | 22 | build-all: 23 | @for device in $(ALL_DEVICES); do \ 24 | echo "Building $(APP_NAME) for $$device..."; \ 25 | $(SDK_HOME)/bin/monkeyc --warn --output bin/$(APP_NAME)-$$device.prg \ 26 | -f ./monkey.jungle \ 27 | -y $(DEVELOPER_KEY) \ 28 | -d $$device || exit 1; \ 29 | echo "-----"; \ 30 | done 31 | 32 | run: 33 | @echo "Running $(APP_NAME) on $(DEVICE)..."; 34 | @$(SDK_HOME)/bin/connectiq &&\ 35 | $(SDK_HOME)/bin/monkeydo bin/$(APP_NAME)-$(DEVICE).prg $(DEVICE) 36 | 37 | test: 38 | @echo "Running $(APP_NAME)-test on $(DEVICE)..."; 39 | @$(SDK_HOME)/bin/connectiq &&\ 40 | $(SDK_HOME)/bin/monkeydo bin/$(APP_NAME)-$(DEVICE)-test.prg $(DEVICE) -t 41 | 42 | clean: 43 | @rm -rf bin/* 44 | 45 | deploy: build 46 | @cp bin/$(APP_NAME)-$(DEVICE).prg $(DEPLOY) 47 | 48 | package: 49 | @echo "Packaging $(APP_NAME)..."; 50 | @$(SDK_HOME)/bin/monkeyc --warn -e -r --output bin/$(APP_NAME).iq \ 51 | -f ./monkey.jungle \ 52 | -y $(DEVELOPER_KEY) \ 53 | -------------------------------------------------------------------------------- /tests/otp/Sha1Test.mc: -------------------------------------------------------------------------------- 1 | module Sha1Test { 2 | 3 | (:test) 4 | function test1Encode(logger) { 5 | // test example from RFC - https://tools.ietf.org/html/rfc3174 6 | // input "abc" 7 | // result "A9 99 3E 36 47 06 81 6A BA 3E 25 71 78 50 C2 6C 9C D0 D8 9D", 8 | var sha1 = Sha1.encode("abc".toUtf8Array()); 9 | var sha1Hex = Convert.byteArrayToHexString(sha1); 10 | logger.debug(sha1Hex); 11 | var expectedBytes = [169, 153, 62, 54, 71, 6, 129, 106, 186, 62, 12 | 37, 113, 120, 80, 194, 108, 156, 208, 216, 157]; 13 | var expectedHex = "A9993E364706816ABA3E25717850C26C9CD0D89D"; 14 | return sha1.toString().equals(expectedBytes.toString()) 15 | && sha1Hex.equals(expectedHex); 16 | } 17 | 18 | (:test) 19 | function test2Encode(logger) { 20 | // test example from Wiki - https://en.wikipedia.org/wiki/SHA-1#Example_hashes 21 | // input "The quick brown fox jumps over the lazy dog" 22 | // result "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12" 23 | var sha1 = Sha1.encode("The quick brown fox jumps over the lazy dog".toUtf8Array()); 24 | var sha1Hex = Convert.byteArrayToHexString(sha1); 25 | logger.debug(sha1Hex); 26 | return sha1Hex.equals("2FD4E1C67A2D28FCED849EE1BB76E7391B93EB12"); 27 | } 28 | 29 | (:test) 30 | function test3Encode(logger) { 31 | // test example from Wiki - https://en.wikipedia.org/wiki/SHA-1#Example_hashes 32 | // input "The quick brown fox jumps over the lazy dog" 33 | // result "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12" 34 | var sha1 = Sha1.encode("It very very long string. It very very long string. It very very long string. It very very long string".toUtf8Array()); 35 | var sha1Hex = Convert.byteArrayToHexString(sha1); 36 | logger.debug(sha1Hex); 37 | return sha1Hex.equals("AACAAA9BC52E4D128A432EA65F9C6C26045C326A"); 38 | } 39 | } -------------------------------------------------------------------------------- /resources/settings/properties.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 0x000000 4 | 0xFFFFFF 5 | 0x109AD7 6 | false 7 | 8 | false 9 | false 10 | false 11 | false 12 | false 13 | false 14 | false 15 | false 16 | false 17 | false 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /tests/otp/OtpTest.mc: -------------------------------------------------------------------------------- 1 | using Toybox.Lang as Lang; 2 | using Toybox.Time as Time; 3 | 4 | module OtpTest { 5 | 6 | (:test) 7 | function testTotpSha1(logger) { 8 | logger.debug(""); 9 | 10 | // Seed for HMAC-SHA1 - 20 bytes 11 | var seed = "3132333435363738393031323334353637383930"; 12 | var t0 = 0L; 13 | var x = 30L; 14 | var testTime = [59, 1111111109, 1111111111]; 15 | var expectedCodes = ["94287082", "07081804", "14050471"]; 16 | var actualCodes = new [3]; 17 | 18 | logger.debug("+---------------+-----------------------+------------------+--------+--------+"); 19 | logger.debug("| Time(sec) | Time (UTC format) | Value of T(Hex) | TOTP | Mode |"); 20 | logger.debug("+---------------+-----------------------+------------------+--------+--------+"); 21 | for (var i = 0; i < testTime.size(); i++) { 22 | var theTime = testTime[i]; 23 | var t = (theTime - t0) / x; 24 | 25 | var steps = t.format("%016X"); 26 | 27 | var fmtTime = theTime.format("%11d"); 28 | var utcTime = Time.Gregorian.info(new Time.Moment(theTime), Time.FORMAT_SHORT); 29 | var fmtUtcTime = Lang.format("$1$:$2$:$3$ $4$/$5$/$6$", [ 30 | utcTime.hour.format("%02d"), 31 | utcTime.min.format("%02d"), 32 | utcTime.sec.format("%02d"), 33 | utcTime.day.format("%02d"), 34 | utcTime.month.format("%02d"), 35 | utcTime.year.format("%4d") 36 | ]); 37 | 38 | actualCodes[i] = Otp.generateHotpSha1(seed, t, 8); 39 | logger.debug("| " + fmtTime + " | " + fmtUtcTime + " | " + steps + " |" + actualCodes[i] + "| SHA1 |"); 40 | logger.debug("+---------------+-----------------------+------------------+--------+--------+"); 41 | } 42 | 43 | return actualCodes.toString().equals(expectedCodes.toString()); 44 | } 45 | } -------------------------------------------------------------------------------- /.github/workflows/make.yml: -------------------------------------------------------------------------------- 1 | name: Makefile CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | CIQ_SDK_VERSION: 6.2.2 15 | 16 | jobs: 17 | build: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Generate a developer key 25 | run: | 26 | openssl genrsa -out developer_key.pem 4096 27 | openssl pkcs8 -topk8 -inform PEM -outform DER -in developer_key.pem -out developer_key.der -nocrypt 28 | 29 | - uses: actions/cache@v4 30 | id: cache-sdk 31 | with: 32 | path: | 33 | /usr/local/bin/connect-iq-sdk-manager 34 | ~/.Garmin/ConnectIQ 35 | key: ${{ runner.os }}-${{ env.CIQ_SDK_VERSION }}-${{ hashFiles('manifest.xml') }} 36 | 37 | - name: Download and configure ConnectIQ SDK 38 | env: 39 | GARMIN_USERNAME: ${{ secrets.GARMIN_USERNAME }} 40 | GARMIN_PASSWORD: ${{ secrets.GARMIN_PASSWORD }} 41 | if: steps.cache-sdk.outputs.cache-hit != 'true' 42 | run: | 43 | curl -s https://raw.githubusercontent.com/lindell/connect-iq-sdk-manager-cli/master/install.sh | sh -s -- -d v0.7.1 44 | connect-iq-sdk-manager agreement view >> agreement.txt 45 | HASH=`grep -Po 'Current Hash: \K.*' agreement.txt` 46 | connect-iq-sdk-manager agreement accept --agreement-hash=$HASH 47 | connect-iq-sdk-manager login 48 | connect-iq-sdk-manager sdk set $CIQ_SDK_VERSION 49 | connect-iq-sdk-manager device download --manifest=manifest.xml 50 | 51 | - name: Create properties.mk 52 | run: | 53 | touch properties.mk 54 | echo "SDK_HOME = `connect-iq-sdk-manager sdk current-path`" >> properties.mk 55 | echo "DEVELOPER_KEY = `pwd`/developer_key.der" >> properties.mk 56 | echo "cat properties.mk" 57 | cat properties.mk 58 | 59 | - name: Build 60 | run: make build-all 61 | 62 | - name: Package 63 | run: make package 64 | -------------------------------------------------------------------------------- /source/util/AppData.mc: -------------------------------------------------------------------------------- 1 | using Toybox.Application as App; 2 | using Toybox.System as Sys; 3 | 4 | //! App Data is an abstraction to retrieve settings, properties and storage values 5 | //! for devices with CIQ version before and after 2.4 6 | module AppData { 7 | 8 | function readStorageValue(propertyName) { 9 | if (App has :Storage && App.Storage has :getValue) { 10 | return App.Storage.getValue(propertyName); 11 | } else { 12 | return App.getApp().getProperty(propertyName); 13 | } 14 | } 15 | 16 | function saveStorageValue(propertyName, propertyValue) { 17 | if (App has :Storage && App.Storage has :setValue) { 18 | App.Storage.setValue(propertyName, propertyValue); 19 | } else { 20 | App.getApp().setProperty(propertyName, propertyValue); 21 | } 22 | } 23 | 24 | function deleteStorageValue(propertyName) { 25 | if (App has :Storage && App.Storage has :deleteValue) { 26 | App.Storage.deleteValue(propertyName); 27 | } else { 28 | App.getApp().deleteProperty(propertyName); 29 | } 30 | } 31 | 32 | function readProperty(propertyName) { 33 | if (App has :Properties) { 34 | try { 35 | return App.Properties.getValue(propertyName); 36 | } catch (ex instanceof App.Properties.InvalidKeyException) { 37 | // different behaviour on device and simulator: 38 | // previously stored empty value (OtpDataProvider:89) for given property works correctly on a simulator 39 | // but deletes the property on a device, thus the exception is thrown 40 | return null; 41 | } 42 | } else { 43 | return App.getApp().getProperty(propertyName); 44 | } 45 | } 46 | 47 | function saveProperty(propertyName, propertyValue) { 48 | if (App has :Properties) { 49 | try { 50 | App.Properties.setValue(propertyName, propertyValue); 51 | } catch (ex instanceof App.Properties.InvalidKeyException) { 52 | // if the exception is throw then properties don't have that key 53 | // return false as indicator the property was not saved 54 | } 55 | } else { 56 | App.getApp().setProperty(propertyName, propertyValue); 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /source/drawable/TimerCircleView.mc: -------------------------------------------------------------------------------- 1 | using Toybox.WatchUi as Ui; 2 | using Toybox.Application as App; 3 | using Toybox.Graphics as Gfx; 4 | using Toybox.System as Sys; 5 | import Toybox.Lang; 6 | 7 | class TimerCircleView extends Ui.Drawable { 8 | 9 | function initialize(params) { 10 | Drawable.initialize(params); 11 | } 12 | 13 | function draw(dc) { 14 | var cr = getCenterAndRadius(dc); 15 | var x = cr[0]; 16 | var y = cr[1]; 17 | var maxR = cr[2]; 18 | var timeColor = AppData.readProperty(Constants.CIRCLE_TIMER_COLOR_PROP); 19 | var bgColor = AppData.readProperty(Constants.BG_COLOR_PROP); 20 | var useArros = AppData.readProperty(Constants.CIRCLE_TIMER_ARROWS_PROP); 21 | 22 | var time = System.getClockTime(); 23 | 24 | if (time.sec % Constants.TIME_STEP_SEC > Constants.RED_ZONE_SEC) { 25 | timeColor = Gfx.COLOR_RED; 26 | } 27 | 28 | var angel = time.sec * Constants.ANGEL_MULTIPLIER; 29 | var r = maxR - (Constants.ROUND_WIDTH / 2); 30 | 31 | if (time.sec % Constants.TIME_STEP_SEC != 0) { 32 | // draw main time filler 33 | dc.setPenWidth(Constants.ROUND_WIDTH); 34 | dc.setColor(timeColor, Gfx.COLOR_TRANSPARENT); 35 | dc.drawArc(x, y, r, Gfx.ARC_CLOCKWISE, 36 | Constants.START_ANGEL, Constants.START_ANGEL - angel); 37 | 38 | if (useArros) { 39 | // draw arrows of time filler 40 | dc.setPenWidth(1); 41 | for (var i = 0; i < Constants.ROUND_WIDTH; i++) { 42 | var shift = i < Constants.ROUND_WIDTH / 2 ? i : Constants.ROUND_WIDTH - i; 43 | 44 | dc.setColor(timeColor, Gfx.COLOR_TRANSPARENT); 45 | dc.drawArc(x, y, maxR - i, Gfx.ARC_CLOCKWISE, 46 | Constants.START_ANGEL - angel + 1, 47 | Constants.START_ANGEL - angel - shift - 1); 48 | 49 | dc.setColor(bgColor, Gfx.COLOR_TRANSPARENT); 50 | dc.drawArc(x, y, maxR - i, Gfx.ARC_CLOCKWISE, 51 | Constants.START_ANGEL, 52 | Constants.START_ANGEL - shift); 53 | } 54 | } 55 | } 56 | } 57 | 58 | function getCenterAndRadius(dc) as Array { 59 | return [dc.getWidth() / 2, dc.getHeight() / 2, dc.getWidth() / 2]; 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /source/otp/Otp.mc: -------------------------------------------------------------------------------- 1 | using Toybox.System as Sys; 2 | using Toybox.Time as Time; 3 | 4 | //! HOTP and TOTP implementation 5 | //! https://tools.ietf.org/html/rfc6238 6 | //! 7 | //! HOTP(K,C) = Truncate(HMAC-SHA-1(K,C)) 8 | //! 9 | //! Basically, we define TOTP as TOTP = HOTP(K, T), where T is an integer 10 | //! and represents the number of time steps between the initial counter 11 | //! time T0 and the current Unix time. 12 | //! More specifically, T = (Current Unix time - T0) / X, where the 13 | //! default floor function is used in the computation. 14 | module Otp { 15 | 16 | const DIGITS_POWER 17 | // 0 1 2 3 4 5 6 7 8 18 | = [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000]; 19 | 20 | //! Generate TOTP code, with help of HMAC_SHA1 21 | //! @param [Toybox::Lang::String] key The HEX String of secret key 22 | //! @return [Toybox::Lang::String] generated TOTP code 23 | function generateTotpSha1(base32EncodedKey) { 24 | var keyHex = Convert.base32decode2HexString(base32EncodedKey); 25 | var message = Time.now().value() / Constants.TIME_STEP_SEC; 26 | return generateHotpSha1(keyHex, message, 6); 27 | } 28 | 29 | //! Generate HOTP code, where H (has function) is HMAC_SHA1 30 | //! @param [Toybox::Lang::String] keyHex The HEX String of secret key 31 | //! @param [Toybox::Lang::String] message The String message to generate OTP 32 | //! @param [Toybox::Lang::String] how many digits to generate in OTP (no more then 8) 33 | //! @return [Toybox::Lang::String] generated HOTP code 34 | function generateHotpSha1(keyHex, message, returnDigits) { 35 | if (returnDigits > 8) { 36 | returnDigits = 8; 37 | } 38 | 39 | // Using the counter 40 | // First 8 bytes are for the movingFactor 41 | // Compliant with base RFC 4226 (HOTP) 42 | var msgHex = message.format("%016X"); 43 | 44 | // Get the HEX in a Byte[] 45 | var msgBytes = Convert.hexStringToByteArray(msgHex); 46 | var keyBytes = Convert.hexStringToByteArray(keyHex); 47 | 48 | var hash = Hmac.authenticateWithSha1(keyBytes, msgBytes); 49 | 50 | // put selected bytes into result int 51 | var offset = hash[hash.size() - 1] & 0xf; 52 | 53 | var binary = 54 | ((hash[offset] & 0x7f) << 24) | 55 | ((hash[offset + 1] & 0xff) << 16) | 56 | ((hash[offset + 2] & 0xff) << 8) | 57 | (hash[offset + 3] & 0xff); 58 | var otp = binary % DIGITS_POWER[returnDigits]; 59 | 60 | return otp.format("%0" + returnDigits + "d"); 61 | } 62 | } -------------------------------------------------------------------------------- /source/OtpWidgetView.mc: -------------------------------------------------------------------------------- 1 | using Toybox.WatchUi as Ui; 2 | using Toybox.Graphics as Gfx; 3 | using Toybox.System as Sys; 4 | using Toybox.Application as App; 5 | 6 | class OtpWidgetView extends Ui.View { 7 | 8 | var currentOtp = null; 9 | var dataProvider; 10 | 11 | function initialize(dataProvider) { 12 | View.initialize(); 13 | self.dataProvider = dataProvider; 14 | } 15 | 16 | // Load your resources here 17 | function onLayout(dc) { 18 | setLayout(Rez.Layouts.MainLayout(dc)); 19 | } 20 | 21 | // Called when this View is brought to the foreground. Restore 22 | // the state of this View and prepare it to be shown. This includes 23 | // loading resources into memory. 24 | function onShow() { 25 | reloadCurrentOtp(); 26 | AppTimers.startUiTimer(method(:uiTimerCallback)); 27 | } 28 | 29 | function uiTimerCallback() { 30 | var time = System.getClockTime(); 31 | if (time.sec % Constants.TIME_STEP_SEC == 0) { 32 | reloadCurrentOtp(); 33 | } 34 | Ui.requestUpdate(); 35 | } 36 | 37 | function reloadCurrentOtp() { 38 | currentOtp = dataProvider.getCurrentOtp(); 39 | } 40 | 41 | // Update the view 42 | function onUpdate(dc) { 43 | // Sys.println("on update"); 44 | 45 | // Update the view 46 | var fgColor = AppData.readProperty(Constants.FG_COLOR_PROP); 47 | 48 | if (currentOtp != null) { 49 | var viewName = View.findDrawableById("NameLabel") as Ui.Text; 50 | viewName.setColor(fgColor); 51 | viewName.setText(currentOtp.name); 52 | 53 | var viewCode = View.findDrawableById("CodeLabel") as Ui.Text; 54 | viewCode.setColor(fgColor); 55 | viewCode.setText(currentOtp.token); 56 | } else { 57 | var viewName = View.findDrawableById("NameLabel") as Ui.Text; 58 | viewName.setColor(fgColor); 59 | viewName.setFont(Gfx.FONT_XTINY); 60 | viewName.setText("No Accounts\nSet up in app settings"); 61 | } 62 | 63 | // Call the parent onUpdate function to redraw the layout 64 | View.onUpdate(dc); 65 | } 66 | 67 | // Called when this View is removed from the screen. Save the 68 | // state of this View here. This includes freeing resources from 69 | // memory. 70 | function onHide() { 71 | AppTimers.stopUiTimer(); 72 | } 73 | 74 | // The user has just looked at their watch. Timers and animations may be started here. 75 | function onExitSleep() { 76 | } 77 | 78 | // Terminate any active timers and prepare for slow updates. 79 | function onEnterSleep() { 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /source/otp/Sha1.mc: -------------------------------------------------------------------------------- 1 | //! SHA1 implementation 2 | //! https://tools.ietf.org/html/rfc3174 3 | //! 4 | //! Based on java implementation http://www.intertwingly.net/stories/2004/07/18/SHA1.java 5 | import Toybox.Lang; 6 | 7 | module Sha1 { 8 | 9 | const H = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]; 10 | const K = [0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xCA62C1D6]; 11 | 12 | //! Encode given byte array input to SHA1 hash as a byte array 13 | //! @param [Toybox::Lang::Array] input The byte array to be encrypted 14 | //! @return [Toybox::Lang::Array] computed SHA1 hash as byte array 15 | function encode(input as Array) as Array { 16 | var inSize = input.size(); 17 | var bloks = new [(((inSize + 8) >> 6) + 1) * 16]; 18 | var bloksSize = bloks.size(); 19 | 20 | for (var i = 0; i < bloksSize; i++) { 21 | bloks[i] = 0; 22 | } 23 | 24 | for(var i = 0; i < inSize; i++) { 25 | bloks[i >> 2] |= input[i] << (24 - (i % 4) * 8); 26 | } 27 | bloks[inSize >> 2] |= 0x80 << (24 - (inSize % 4) * 8); 28 | bloks[bloksSize - 1] = inSize * 8; 29 | 30 | var w = new [80]; 31 | 32 | var a = H[0]; 33 | var b = H[1]; 34 | var c = H[2]; 35 | var d = H[3]; 36 | var e = H[4]; 37 | 38 | for(var i = 0; i < bloksSize; i += 16) { 39 | var ta = a; 40 | var tb = b; 41 | var tc = c; 42 | var td = d; 43 | var te = e; 44 | 45 | for(var j = 0; j < 80; j++) { 46 | w[j] = (j < 16) ? bloks[i + j] : (rotate(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1)); 47 | 48 | var temp = rotate(a, 5) + e + w[j] + ( 49 | (j < 20) ? K[0] + ((b & c) | ((~b) & d)) : 50 | (j < 40) ? K[1] + (b ^ c ^ d) : 51 | (j < 60) ? K[2] + ((b & c) | (b & d) | (c & d)) : 52 | K[3] + (b ^ c ^ d) 53 | ); 54 | e = d; 55 | d = c; 56 | c = rotate(b, 30); 57 | b = a; 58 | a = temp; 59 | } 60 | 61 | a += ta; 62 | b += tb; 63 | c += tc; 64 | d += td; 65 | e += te; 66 | } 67 | var words = [a, b, c, d, e]; 68 | 69 | var res = new [20]; 70 | for (var i = 0; i < 20; i++) { 71 | res[i] = words[i>>2] >> (8 * (3 - (i & 0x03))) & 0xFF ; 72 | } 73 | 74 | return res; 75 | } 76 | 77 | function rotate(num, cnt) { 78 | var mask = (1 << cnt) - 1; 79 | var leftPart = (num << cnt) & (~mask); 80 | var rightPart = (num >> (32 - cnt)) & (mask); 81 | return leftPart | rightPart; 82 | } 83 | } -------------------------------------------------------------------------------- /manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | eng 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /resources/settings/settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @Strings.ColorBlack 6 | @Strings.ColorDarkGray 7 | @Strings.ColorLightGray 8 | 9 | 10 | 11 | 12 | 13 | @Strings.ColorBlue 14 | @Strings.ColorGreen 15 | @Strings.ColorWhite 16 | 17 | 18 | 19 | 20 | 21 | @Strings.ColorBlue 22 | @Strings.ColorGreen 23 | @Strings.ColorWhite 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /source/OtpDataProvider.mc: -------------------------------------------------------------------------------- 1 | using Toybox.Application as App; 2 | using Toybox.System as Sys; 3 | using Toybox.StringUtil as StringUtil; 4 | import Toybox.Lang; 5 | 6 | class Account { 7 | var enabled; 8 | var name; 9 | var secret; 10 | 11 | function initialize(enabled, name, secret) { 12 | self.enabled = enabled; 13 | self.name = name; 14 | self.secret = secret; 15 | } 16 | 17 | function toString() { 18 | return "account[n=" + name + ":e=" + enabled + "]"; 19 | } 20 | } 21 | 22 | class AccountToken { 23 | var name; 24 | var token; 25 | 26 | function initialize(name, token) { 27 | self.name = name; 28 | self.token = token; 29 | } 30 | 31 | function toString() { 32 | return "token[n=" + name + ":t=" + token + "]"; 33 | } 34 | } 35 | 36 | class OtpDataProvider { 37 | 38 | hidden var enabledAccounts as Array = []; 39 | hidden var currentAccountIdx as Number = -1; 40 | hidden var maxAccountIdx as Number = -1; 41 | 42 | function initialize() { 43 | reloadData(); 44 | } 45 | 46 | function reloadData() { 47 | Sys.println("reload data"); 48 | 49 | // clear current data 50 | enabledAccounts = []; 51 | currentAccountIdx = -1; 52 | maxAccountIdx = -1; 53 | 54 | var accountsFromProperties = readAccountsFromProperties(); 55 | // Migration needed to move all secrets from storage to app settings. 56 | // App Settings allow to define password fields in new SDKs, it helped to get rid of the ugly previous solution 57 | var secretsMigrated = migrateSecretsToProperties(accountsFromProperties); 58 | 59 | // Sys.println("account secrets from storage: " + accountsSecretsFromStorgate); 60 | for (var i = 0; i < Constants.MAX_ACCOUNTS; i++) { 61 | var acc = accountsFromProperties[i]; 62 | if (acc.enabled) { 63 | enabledAccounts.add(acc); 64 | } 65 | } 66 | 67 | if (enabledAccounts.size() != 0) { 68 | maxAccountIdx = enabledAccounts.size() - 1; 69 | if (secretsMigrated) { 70 | currentAccountIdx = 0; // if secretsMigrated - reset to 0. It can happen once 71 | } else { 72 | currentAccountIdx = AppData.readStorageValue(Constants.CURRENT_ACC_IDX_KEY); 73 | if (currentAccountIdx == null || currentAccountIdx < 0 || currentAccountIdx > maxAccountIdx) { 74 | currentAccountIdx = 0; 75 | } 76 | } 77 | } 78 | 79 | AppData.saveStorageValue(Constants.CURRENT_ACC_IDX_KEY, currentAccountIdx); 80 | Sys.println("enabledAccounts: " + enabledAccounts); 81 | Sys.println("currentAccountIdx: " + currentAccountIdx); 82 | Sys.println("maxAccountIdx: " + maxAccountIdx); 83 | } 84 | 85 | hidden function readAccountsFromProperties() as Array { 86 | var accounts = []; 87 | for (var accIdx = 1; accIdx <= Constants.MAX_ACCOUNTS; accIdx++) { 88 | var accEnabledProp = "Account" + accIdx + "Enabled"; 89 | var accNameProp = "Account" + accIdx + "Name"; 90 | var accSecretProp = "Account" + accIdx + "Secret"; 91 | 92 | var accEnabled = AppData.readProperty(accEnabledProp); 93 | var accName = AppData.readProperty(accNameProp); 94 | var accSecret = normalizeSecret(AppData.readProperty(accSecretProp)); 95 | 96 | accounts.add(new Account(accEnabled, accName, accSecret)); 97 | } 98 | Sys.println("accounts from properties: " + accounts); 99 | return accounts; 100 | } 101 | 102 | hidden function migrateSecretsToProperties(accountsFromProperties as Array) { 103 | var secretsMigrated = false; 104 | for (var i = 0; i < Constants.MAX_ACCOUNTS; i++) { 105 | var acc = accountsFromProperties[i]; 106 | var accIdx = i + 1; 107 | var accSecretStorageKey = "Account" + accIdx + "SecretKey"; 108 | var accSecretPropsKey = "Account" + accIdx + "Secret"; 109 | 110 | var accSecretToMigrate = AppData.readStorageValue(accSecretStorageKey); 111 | 112 | if (!isEmptyString(accSecretToMigrate)) { 113 | // if secret exists in storate but no in properties - copy it to the propertie 114 | // otherwise keep existing secret in properties 115 | if (isEmptyString(acc.secret)) { 116 | Sys.println("secret of acc " + i + " exsists in storage, will copy it to properties and clean the storage..."); 117 | acc.secret = accSecretToMigrate; 118 | AppData.saveProperty(accSecretPropsKey, acc.secret); 119 | 120 | secretsMigrated = true; 121 | } 122 | // delete secret from storage to not let migration start next time 123 | AppData.deleteStorageValue(accSecretStorageKey); 124 | } 125 | 126 | } 127 | Sys.println("secrets migrated. done = " + secretsMigrated); 128 | return secretsMigrated; 129 | } 130 | 131 | hidden function isEmptyString(str) { 132 | return str == null || (str instanceof Toybox.Lang.String && str.length() == 0); 133 | } 134 | 135 | //! delete all spaces and upper case all letters 136 | //! 137 | //! @param [Toybox::Lang::String] str must not be null 138 | //! @return [Toybox::Lang::String] normalized string 139 | hidden function normalizeSecret(str as String) as String { 140 | var chars = str.toUpper().toCharArray(); 141 | chars.removeAll(' '); 142 | // Yeah. Why not to use StringUtil::charArrayToString()? 143 | // Becasue there is a strage bug here probably in SDK: 144 | // StringUtil.charArrayToString() returns non empty String 145 | // but method length() of that string returns 0 (!!!) 146 | var outStr = ""; 147 | for (var i = 0; i < chars.size(); i++) { 148 | outStr += chars[i]; 149 | } 150 | return outStr; 151 | 152 | } 153 | 154 | function getCurrentOtp() { 155 | if (currentAccountIdx < 0 || enabledAccounts.size() == 0) { 156 | return null; 157 | } 158 | 159 | var acc = enabledAccounts[currentAccountIdx]; 160 | return new AccountToken(acc.name, Otp.generateTotpSha1(acc.secret)); 161 | } 162 | 163 | function getEnabledAccounts() { 164 | return enabledAccounts; 165 | } 166 | 167 | function setCurrentAccountIdx(newAccIdx) { 168 | currentAccountIdx = newAccIdx; 169 | if (currentAccountIdx > maxAccountIdx) { 170 | currentAccountIdx = 0; 171 | } 172 | AppData.saveStorageValue(Constants.CURRENT_ACC_IDX_KEY, currentAccountIdx); 173 | Sys.println("currentAccountIdx: " + currentAccountIdx); 174 | return true; 175 | } 176 | 177 | function nextOtp() { 178 | if (currentAccountIdx < 0 || maxAccountIdx == 0) { 179 | return false; 180 | } 181 | 182 | currentAccountIdx++; 183 | if (currentAccountIdx > maxAccountIdx) { 184 | currentAccountIdx = 0; 185 | } 186 | AppData.saveStorageValue(Constants.CURRENT_ACC_IDX_KEY, currentAccountIdx); 187 | Sys.println("currentAccountIdx: " + currentAccountIdx); 188 | return true; 189 | } 190 | 191 | function prevOtp() { 192 | if (currentAccountIdx < 0 || maxAccountIdx == 0) { 193 | return false; 194 | } 195 | 196 | currentAccountIdx--; 197 | if (currentAccountIdx < 0) { 198 | currentAccountIdx = maxAccountIdx; 199 | } 200 | AppData.saveStorageValue(Constants.CURRENT_ACC_IDX_KEY, currentAccountIdx); 201 | Sys.println("currentAccountIdx: " + currentAccountIdx); 202 | return true; 203 | } 204 | } --------------------------------------------------------------------------------