├── 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 |
5 |
6 |
--------------------------------------------------------------------------------
/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 | [
](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 | }
--------------------------------------------------------------------------------