diff --git a/android.yaml b/android.yaml new file mode 100644 index 00000000..fb76d25e --- /dev/null +++ b/android.yaml @@ -0,0 +1,62 @@ +name: Android Alpha 2 + +on: + push: + branches: [ feature/android-native ] + pull_request: + branches: [ feature/android-native ] + +jobs: + build: + name: Debug apk + runs-on: ubuntu-latest + + steps: + - name: Checkout Yubikit Next + uses: actions/checkout@v2 + with: + repository: Yubico/yubikit-android + ref: next + path: kit + + - name: set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: '11' + + - name: Build Yubikit-android + run: ./gradlew --stacktrace check test build javadocJar publishToMavenLocal + working-directory: ./kit + + - name: Install Flutter + uses: subosito/flutter-action@v1 + with: + channel: 'beta' + - run: | + flutter config + flutter --version + + - uses: actions/checkout@v2 + with: + path: 'app' + + - name: Run tests + run: | + flutter test + flutter analyze + working-directory: ./app + + - name: Build the App + run: flutter build apk --debug + working-directory: ./app + + - name: Upload artifacts + run: | + mkdir artifacts + mv build/app/outputs/flutter-apk/app-debug.apk artifacts/yubico-authenticator-alpha2.apk + working-directory: ./app + + - uses: actions/upload-artifact@v2 + with: + name: yubico-authenticator-alpha2.apk.zip + path: app/artifacts/* diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..6f568019 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..9945d2fd --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,82 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new FileNotFoundException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: "org.jetbrains.kotlin.plugin.serialization" +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion flutter.compileSdkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "com.yubico.authenticator" + minSdkVersion project.minSdkVersion + targetSdkVersion project.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + lintOptions { + abortOnError false + } +} + +flutter { + source '../..' +} + +dependencies { + api "com.yubico.yubikit:android:$project.yubiKitVersion" + api "com.yubico.yubikit:management:$project.yubiKitVersion" + api "com.yubico.yubikit:oath:$project.yubiKitVersion" + + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2' + + // Lifecycle + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1" + implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" + + implementation 'androidx.fragment:fragment-ktx:1.4.1' +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..fe5b5c89 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..6662ae67 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/yubico/authenticator/api/AppApiImpl.kt b/android/app/src/main/java/com/yubico/authenticator/api/AppApiImpl.kt new file mode 100644 index 00000000..70d79445 --- /dev/null +++ b/android/app/src/main/java/com/yubico/authenticator/api/AppApiImpl.kt @@ -0,0 +1,28 @@ +package com.yubico.authenticator.api + +import com.yubico.authenticator.MainViewModel +import com.yubico.authenticator.OperationContext +import com.yubico.authenticator.ParameterException + +class AppApiImpl(private val modelView: MainViewModel) : Pigeon.AppApi { + + override fun setContext(subPageIndex: Long?, result: Pigeon.Result?) { + + result?.run { + if (subPageIndex == null) { + result.error(ParameterException()) + return + } + + val contextValue = OperationContext.getByValue(subPageIndex) + if (contextValue == OperationContext.Invalid) { + // returning success is all we can do here + result.success(null) + return + } + + modelView.setContext(contextValue) + result.success(null) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/yubico/authenticator/api/HDialogApiImpl.kt b/android/app/src/main/java/com/yubico/authenticator/api/HDialogApiImpl.kt new file mode 100644 index 00000000..5d67eda7 --- /dev/null +++ b/android/app/src/main/java/com/yubico/authenticator/api/HDialogApiImpl.kt @@ -0,0 +1,11 @@ +package com.yubico.authenticator.api + +import com.yubico.authenticator.MainViewModel + +class HDialogApiImpl(private val viewModel : MainViewModel) : Pigeon.HDialogApi { + override fun dialogClosed(result: Pigeon.Result?) { + result?.run { + viewModel.onDialogClosed(result) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/yubico/authenticator/api/OathApiImpl.kt b/android/app/src/main/java/com/yubico/authenticator/api/OathApiImpl.kt new file mode 100644 index 00000000..2509d1cb --- /dev/null +++ b/android/app/src/main/java/com/yubico/authenticator/api/OathApiImpl.kt @@ -0,0 +1,101 @@ +package com.yubico.authenticator.api + +import com.yubico.authenticator.MainViewModel +import com.yubico.authenticator.api.Pigeon.OathApi +import com.yubico.authenticator.api.Pigeon.Result + +class OathApiImpl(private val viewModel: MainViewModel) : OathApi { + + override fun reset(result: Result?) { + result?.run { + viewModel.resetOathSession(result) + } + } + + override fun unlock( + password: String?, + remember: Boolean?, + result: Result? + ) { + result?.run { + viewModel.unlockOathSession(password, remember, result) + } + } + + override fun setPassword( + newPassword: String?, + result: Result? + ) { + result?.run { + viewModel.setOathPassword(null, newPassword, result) + } + } + + override fun changePassword( + currentPassword: String?, + newPassword: String?, + result: Result? + ) { + result?.run { + viewModel.setOathPassword(currentPassword, newPassword, result) + } + } + + override fun unsetPassword(currentPassword: String?, result: Result?) { + result?.run { + viewModel.unsetOathPassword(currentPassword, result) + } + } + + override fun forgetPassword(result: Result?) { + result?.run { + viewModel.forgetPassword(result) + } + } + + override fun addAccount( + uri: String?, + requireTouch: Boolean?, + result: Result? + ) { + result?.run { + viewModel.addAccount(uri, requireTouch, result) + } + } + + override fun renameAccount(uri: String?, name: String?, result: Result?) { + result?.run { + viewModel.renameCredential(uri, name, null, result) + } + } + + override fun renameAccountWithIssuer( + uri: String?, + name: String?, + issuer: String?, + result: Result? + ) { + result?.run { + viewModel.renameCredential(uri, name, issuer, result) + } + } + + override fun deleteAccount(uri: String?, result: Result?) { + result?.run { + viewModel.deleteAccount(uri, result) + } + } + + override fun refreshCodes(result: Result?) { + result?.run { + viewModel.refreshOathCodes(result) + } + } + + override fun calculate(uri: String?, result: Result?) { + result?.run { + viewModel.calculate(uri, result) + } + } + +} \ No newline at end of file diff --git a/android/app/src/main/java/com/yubico/authenticator/api/Pigeon.java b/android/app/src/main/java/com/yubico/authenticator/api/Pigeon.java new file mode 100644 index 00000000..75d90e5f --- /dev/null +++ b/android/app/src/main/java/com/yubico/authenticator/api/Pigeon.java @@ -0,0 +1,673 @@ +// Autogenerated from Pigeon (v1.0.19), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +package com.yubico.authenticator.api; + +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MessageCodec; +import io.flutter.plugin.common.StandardMessageCodec; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +/** Generated class from Pigeon. */ +@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"}) +public class Pigeon { + + public interface Result { + void success(T result); + void error(Throwable error); + } + private static class OathApiCodec extends StandardMessageCodec { + public static final OathApiCodec INSTANCE = new OathApiCodec(); + private OathApiCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter.*/ + public interface OathApi { + void reset(Result result); + void unlock(String password, Boolean remember, Result result); + void setPassword(String newPassword, Result result); + void changePassword(String currentPassword, String newPassword, Result result); + void unsetPassword(String currentPassword, Result result); + void forgetPassword(Result result); + void addAccount(String uri, Boolean requireTouch, Result result); + void renameAccount(String uri, String name, Result result); + void renameAccountWithIssuer(String uri, String name, String issuer, Result result); + void deleteAccount(String uri, Result result); + void refreshCodes(Result result); + void calculate(String uri, Result result); + + /** The codec used by OathApi. */ + static MessageCodec getCodec() { + return OathApiCodec.INSTANCE; + } + + /** Sets up an instance of `OathApi` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, OathApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.reset", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + Result resultCallback = new Result() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.reset(resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.unlock", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + String passwordArg = (String)args.get(0); + if (passwordArg == null) { + throw new NullPointerException("passwordArg unexpectedly null."); + } + Boolean rememberArg = (Boolean)args.get(1); + if (rememberArg == null) { + throw new NullPointerException("rememberArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(Boolean result) { + wrapped.put("result", result); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.unlock(passwordArg, rememberArg, resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.setPassword", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + String newPasswordArg = (String)args.get(0); + if (newPasswordArg == null) { + throw new NullPointerException("newPasswordArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.setPassword(newPasswordArg, resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.changePassword", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + String currentPasswordArg = (String)args.get(0); + if (currentPasswordArg == null) { + throw new NullPointerException("currentPasswordArg unexpectedly null."); + } + String newPasswordArg = (String)args.get(1); + if (newPasswordArg == null) { + throw new NullPointerException("newPasswordArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.changePassword(currentPasswordArg, newPasswordArg, resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.unsetPassword", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + String currentPasswordArg = (String)args.get(0); + if (currentPasswordArg == null) { + throw new NullPointerException("currentPasswordArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.unsetPassword(currentPasswordArg, resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.forgetPassword", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + Result resultCallback = new Result() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.forgetPassword(resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.addAccount", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + String uriArg = (String)args.get(0); + if (uriArg == null) { + throw new NullPointerException("uriArg unexpectedly null."); + } + Boolean requireTouchArg = (Boolean)args.get(1); + if (requireTouchArg == null) { + throw new NullPointerException("requireTouchArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(String result) { + wrapped.put("result", result); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.addAccount(uriArg, requireTouchArg, resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.renameAccount", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + String uriArg = (String)args.get(0); + if (uriArg == null) { + throw new NullPointerException("uriArg unexpectedly null."); + } + String nameArg = (String)args.get(1); + if (nameArg == null) { + throw new NullPointerException("nameArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(String result) { + wrapped.put("result", result); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.renameAccount(uriArg, nameArg, resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.renameAccountWithIssuer", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + String uriArg = (String)args.get(0); + if (uriArg == null) { + throw new NullPointerException("uriArg unexpectedly null."); + } + String nameArg = (String)args.get(1); + if (nameArg == null) { + throw new NullPointerException("nameArg unexpectedly null."); + } + String issuerArg = (String)args.get(2); + if (issuerArg == null) { + throw new NullPointerException("issuerArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(String result) { + wrapped.put("result", result); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.renameAccountWithIssuer(uriArg, nameArg, issuerArg, resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.deleteAccount", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + String uriArg = (String)args.get(0); + if (uriArg == null) { + throw new NullPointerException("uriArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.deleteAccount(uriArg, resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.refreshCodes", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + Result resultCallback = new Result() { + public void success(String result) { + wrapped.put("result", result); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.refreshCodes(resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.OathApi.calculate", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + String uriArg = (String)args.get(0); + if (uriArg == null) { + throw new NullPointerException("uriArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(String result) { + wrapped.put("result", result); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.calculate(uriArg, resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + private static class AppApiCodec extends StandardMessageCodec { + public static final AppApiCodec INSTANCE = new AppApiCodec(); + private AppApiCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter.*/ + public interface AppApi { + void setContext(Long subPageIndex, Result result); + + /** The codec used by AppApi. */ + static MessageCodec getCodec() { + return AppApiCodec.INSTANCE; + } + + /** Sets up an instance of `AppApi` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, AppApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.AppApi.setContext", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + Number subPageIndexArg = (Number)args.get(0); + if (subPageIndexArg == null) { + throw new NullPointerException("subPageIndexArg unexpectedly null."); + } + Result resultCallback = new Result() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.setContext(subPageIndexArg.longValue(), resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + private static class FOathApiCodec extends StandardMessageCodec { + public static final FOathApiCodec INSTANCE = new FOathApiCodec(); + private FOathApiCodec() {} + } + + /** Generated class from Pigeon that represents Flutter messages that can be called from Java.*/ + public static class FOathApi { + private final BinaryMessenger binaryMessenger; + public FOathApi(BinaryMessenger argBinaryMessenger){ + this.binaryMessenger = argBinaryMessenger; + } + public interface Reply { + void reply(T reply); + } + static MessageCodec getCodec() { + return FOathApiCodec.INSTANCE; + } + + public void updateSession(String sessionJsonArg, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.FOathApi.updateSession", getCodec()); + channel.send(new ArrayList(Arrays.asList(sessionJsonArg)), channelReply -> { + callback.reply(null); + }); + } + public void updateOathCredentials(String credentialListJsonArg, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.FOathApi.updateOathCredentials", getCodec()); + channel.send(new ArrayList(Arrays.asList(credentialListJsonArg)), channelReply -> { + callback.reply(null); + }); + } + } + private static class FManagementApiCodec extends StandardMessageCodec { + public static final FManagementApiCodec INSTANCE = new FManagementApiCodec(); + private FManagementApiCodec() {} + } + + /** Generated class from Pigeon that represents Flutter messages that can be called from Java.*/ + public static class FManagementApi { + private final BinaryMessenger binaryMessenger; + public FManagementApi(BinaryMessenger argBinaryMessenger){ + this.binaryMessenger = argBinaryMessenger; + } + public interface Reply { + void reply(T reply); + } + static MessageCodec getCodec() { + return FManagementApiCodec.INSTANCE; + } + + public void updateDeviceInfo(String deviceInfoJsonArg, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.FManagementApi.updateDeviceInfo", getCodec()); + channel.send(new ArrayList(Arrays.asList(deviceInfoJsonArg)), channelReply -> { + callback.reply(null); + }); + } + } + private static class FDialogApiCodec extends StandardMessageCodec { + public static final FDialogApiCodec INSTANCE = new FDialogApiCodec(); + private FDialogApiCodec() {} + } + + /** Generated class from Pigeon that represents Flutter messages that can be called from Java.*/ + public static class FDialogApi { + private final BinaryMessenger binaryMessenger; + public FDialogApi(BinaryMessenger argBinaryMessenger){ + this.binaryMessenger = argBinaryMessenger; + } + public interface Reply { + void reply(T reply); + } + static MessageCodec getCodec() { + return FDialogApiCodec.INSTANCE; + } + + public void showDialogApi(String dialogParametersJsonArg, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.FDialogApi.showDialogApi", getCodec()); + channel.send(new ArrayList(Arrays.asList(dialogParametersJsonArg)), channelReply -> { + callback.reply(null); + }); + } + public void closeDialogApi(Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.FDialogApi.closeDialogApi", getCodec()); + channel.send(null, channelReply -> { + callback.reply(null); + }); + } + } + private static class HDialogApiCodec extends StandardMessageCodec { + public static final HDialogApiCodec INSTANCE = new HDialogApiCodec(); + private HDialogApiCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter.*/ + public interface HDialogApi { + void dialogClosed(Result result); + + /** The codec used by HDialogApi. */ + static MessageCodec getCodec() { + return HDialogApiCodec.INSTANCE; + } + + /** Sets up an instance of `HDialogApi` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, HDialogApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.HDialogApi.dialogClosed", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + Result resultCallback = new Result() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.dialogClosed(resultCallback); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + private static Map wrapError(Throwable exception) { + Map errorMap = new HashMap<>(); + errorMap.put("message", exception.toString()); + errorMap.put("code", exception.getClass().getSimpleName()); + errorMap.put("details", "Cause: " + exception.getCause() + ", Stacktrace: " + Log.getStackTraceString(exception)); + return errorMap; + } +} diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt new file mode 100644 index 00000000..df8cce14 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -0,0 +1,103 @@ +package com.yubico.authenticator + +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope +import com.yubico.authenticator.api.AppApiImpl +import com.yubico.authenticator.api.HDialogApiImpl +import com.yubico.authenticator.api.OathApiImpl +import com.yubico.authenticator.api.Pigeon +import com.yubico.yubikit.android.YubiKitManager +import com.yubico.yubikit.android.transport.nfc.NfcConfiguration +import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable +import com.yubico.yubikit.android.transport.usb.UsbConfiguration +import com.yubico.yubikit.core.Logger +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.properties.Delegates + +class MainActivity : FlutterFragmentActivity() { + private val viewModel: MainViewModel by viewModels() + private val nfcConfiguration = NfcConfiguration() + + private var hasNfc by Delegates.notNull() + + private lateinit var yubikit: YubiKitManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // simple logger + Logger.setLogger(object : Logger() { + override fun logDebug(message: String) { + Log.d("yubico-authenticator", message) + } + + override fun logError(message: String, throwable: Throwable) { + Log.e("yubico-authenticator", message, throwable) + } + }) + + yubikit = YubiKitManager(this) + + viewModel.handleYubiKey.observe(this) { + if (it) { + yubikit.startUsbDiscovery(UsbConfiguration()) { device -> + viewModel.yubiKeyDevice.postValue(device) + device.setOnClosed { viewModel.yubiKeyDevice.postValue(null) } + } + hasNfc = try { + yubikit.startNfcDiscovery(nfcConfiguration, this) { device -> + viewModel.yubiKeyDevice.apply { + lifecycleScope.launch(Dispatchers.Main) { + value = device + postValue(null) + } + } + } + true + } catch (e: NfcNotAvailable) { + false + } + } else { + yubikit.stopNfcDiscovery(this) + yubikit.stopUsbDiscovery() + } + } + + viewModel.yubiKeyDevice.observe(this) { yubikey -> + + lifecycleScope.launch(Dispatchers.Main) { + withContext(Dispatchers.Main) { + if (yubikey != null) { + Logger.d("A device was connected: $yubikey") + viewModel.yubikeyAttached(yubikey) + + } else { + Logger.d("A device was disconnected") + viewModel.yubikeyDetached() + } + } + } + } + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + val messenger = flutterEngine.dartExecutor.binaryMessenger + + viewModel.setFOathApi(Pigeon.FOathApi(messenger)) + viewModel.setFManagementApi(Pigeon.FManagementApi(messenger)) + viewModel.setFDialogApi(Pigeon.FDialogApi(messenger)) + Pigeon.OathApi.setup(messenger, OathApiImpl(viewModel)) + Pigeon.AppApi.setup(messenger, AppApiImpl(viewModel)) + Pigeon.HDialogApi.setup(messenger, HDialogApiImpl(viewModel)) + } + +} diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt new file mode 100644 index 00000000..5b5a30cd --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt @@ -0,0 +1,523 @@ +package com.yubico.authenticator + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yubico.authenticator.api.Pigeon +import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice +import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice +import com.yubico.yubikit.core.Logger +import com.yubico.yubikit.core.YubiKeyDevice +import com.yubico.yubikit.core.smartcard.SmartCardConnection +import com.yubico.yubikit.core.util.Result +import com.yubico.yubikit.management.ManagementSession +import com.yubico.yubikit.management.ManagementSession.FEATURE_DEVICE_INFO +import com.yubico.yubikit.oath.CredentialData +import com.yubico.yubikit.oath.OathSession +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.URI +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +data class YubiKeyAction( + val message: String, + val action: suspend (Result) -> Unit +) + +enum class OperationContext(val value: Long) { + Oath(0), Yubikey(1), Invalid(-1); + + companion object { + fun getByValue(value: Long) = values().firstOrNull { it.value == value } ?: Invalid + } +} + +class ParameterException : Exception("Invalid parameters") + +class MainViewModel : ViewModel() { + + private val _handleYubiKey = MutableLiveData(true) + val handleYubiKey: LiveData = _handleYubiKey + + val yubiKeyDevice = MutableLiveData() + private var isUsbKeyConnected: Boolean = false + + private var _oathSessionPassword: CharArray? = null + + private var _operationContext = OperationContext.Oath + + private lateinit var _fOathApi: Pigeon.FOathApi + private lateinit var _fManagementApi: Pigeon.FManagementApi + private lateinit var _fDialogApi: Pigeon.FDialogApi + + fun setContext(value: OperationContext) { + _operationContext = value + Logger.d("Operation context is now $_operationContext") + } + + fun setFOathApi(oathApi: Pigeon.FOathApi) { + _fOathApi = oathApi + } + + fun setFManagementApi(managementApi: Pigeon.FManagementApi) { + _fManagementApi = managementApi + } + + fun setFDialogApi(dialogApi: Pigeon.FDialogApi) { + _fDialogApi = dialogApi + } + + private suspend fun sendDeviceInfo(device: YubiKeyDevice) { + + val deviceInfoData = suspendCoroutine { + device.requestConnection(SmartCardConnection::class.java) { result -> + val managementSession = ManagementSession(result.value) + + if (!managementSession.supports(FEATURE_DEVICE_INFO)) { + it.resume("NO_FEATURE_DEVICE_INFO") + return@requestConnection + } + + try { + val deviceInfo = managementSession.deviceInfo + val deviceInfoData = + SerializeHelpers.serialize(deviceInfo, device is NfcYubiKeyDevice) + it.resume(deviceInfoData.toString()) + } catch (cause: Throwable) { + Logger.e("Failed to get device info", cause) + it.resumeWithException(cause) + } + } + } + + _fManagementApi.updateDeviceInfo(deviceInfoData) {} + } + + private suspend fun sendOathInfo(device: YubiKeyDevice) { + + val oathSessionData = suspendCoroutine { + device.requestConnection(SmartCardConnection::class.java) { result -> + val oathSession = OathSession(result.value) + val isRemembered = false + val oathSessionData = SerializeHelpers.serialize(oathSession, isRemembered) + it.resume(oathSessionData.toString()) + } + } + + _fOathApi.updateSession(oathSessionData) {} + } + + private suspend fun sendOathCodes(device: YubiKeyDevice) { + val sendOathCodes = suspendCoroutine { + device.requestConnection(SmartCardConnection::class.java) { result -> + val oathSession = OathSession(result.value) + + val isLocked = isOathSessionLocked(oathSession) + if (!isLocked) { + + val codes = SerializeHelpers.serialize( + oathSession.deviceId, + oathSession.calculateCodes() + ) + it.resume(codes.toString()) + } + } + } + + _fOathApi.updateOathCredentials(sendOathCodes) {} + } + + private val _pendingYubiKeyAction = MutableLiveData() + private val pendingYubiKeyAction: LiveData = _pendingYubiKeyAction + + private suspend fun provideYubiKey(result: Result) = + withContext(Dispatchers.IO) { + pendingYubiKeyAction.value?.let { + _pendingYubiKeyAction.postValue(null) + if (!isUsbKeyConnected) { + withContext(Dispatchers.Main) { + requestHideDialog() + } + } + it.action.invoke(result) + } + } + + suspend fun yubikeyAttached(device: YubiKeyDevice) { + + isUsbKeyConnected = device is UsbYubiKeyDevice + + withContext(Dispatchers.IO) { + if (pendingYubiKeyAction.value != null) { + provideYubiKey(Result.success(device)) + } else { + withContext(Dispatchers.Main) { + when (_operationContext) { + OperationContext.Oath -> { + try { + sendDeviceInfo(device) + } catch (cause: Throwable) { + Logger.e("Failed to send device info", cause) + } + sendOathInfo(device) + sendOathCodes(device) + } + OperationContext.Yubikey -> { + sendDeviceInfo(device) + } + + else -> {} + } + } + } + } + } + + fun yubikeyDetached() { + + if (isUsbKeyConnected) { + // forget the current password only for usb keys + _oathSessionPassword = null + _fManagementApi.updateDeviceInfo("") {} + } + + } + + fun onDialogClosed(result: Pigeon.Result) { + viewModelScope.launch { + try { + provideYubiKey(Result.failure(Exception("User canceled"))) + result.success(null) + } catch (cause: Throwable) { + Logger.d("failed") + result.error(Exception("Failed to close dialog during User cancel action")) + } + } + } + + // requests flutter to show a dialog + private fun requestShowDialog(message: String) = + _fDialogApi.showDialogApi(SerializeHelpers.messageParameters(message).toString()) + {} + + private fun requestHideDialog() { + _fDialogApi.closeDialogApi { } + } + + private fun withUnlockedSession(session: OathSession, block: (OathSession) -> T): T { + val isLocked = isOathSessionLocked(session) + if (isLocked) { + throw Exception("Session is locked") + } + return block(session) + } + + private fun getOathCredential(oathSession: OathSession, credentialId: String) = + oathSession.credentials.firstOrNull { credential -> + (credential != null) && SerializeHelpers.credentialIdAsString( + credential.id + ) == credentialId + } ?: throw Exception("Failed to find account to delete") + + + fun deleteAccount(credentialId: String?, result: Pigeon.Result) { + + if (credentialId == null) { + result.error(ParameterException()) + return + } + + viewModelScope.launch(Dispatchers.IO) { + useOathSession("Delete account", true) { session -> + withUnlockedSession(session) { + val credential = getOathCredential(session, credentialId) + session.deleteCredential(credential) + result.success(null) + } + } + } + } + + fun addAccount(otpUri: String?, requireTouch: Boolean?, result: Pigeon.Result) { + if (otpUri == null || requireTouch == null) { + result.error(ParameterException()) + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + useOathSession("Add account", true) { session -> + withUnlockedSession(session) { + val credentialData: CredentialData = + CredentialData.parseUri(URI.create(otpUri)) + + val jsonResult = SerializeHelpers.serialize( + session.deviceId, + session.putCredential(credentialData, requireTouch) + ).toString() + result.success(jsonResult) + + } + } + } catch (cause: Throwable) { + result.error(cause) + } + } + } + + fun renameCredential( + credentialId: String?, + name: String?, + issuer: String?, + result: Pigeon.Result + ) { + if (credentialId == null || name == null) { + result.error(ParameterException()) + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + useOathSession("Rename", true) { session -> + withUnlockedSession(session) { + val credential = getOathCredential(session, credentialId) + + // rename credential + val newCredential = + session.renameCredential(credential, name, issuer) + + val resultJson = SerializeHelpers.serialize( + session.deviceId, + newCredential + ).toString() + + result.success(resultJson) + } + } + } catch (cause: Throwable) { + result.error(cause) + } + } + } + + fun setOathPassword(current: String?, password: String?, result: Pigeon.Result) { + if (password == null) { + result.error(ParameterException()) + return + } + viewModelScope.launch(Dispatchers.IO) { + try { + useOathSession("Set password", true) { session -> + if (session.isAccessKeySet) { + if (current == null) { + throw Exception("Must provide current password to be able to change it") + } + // test current password sent by the user + if (!session.unlock(current.toCharArray())) { + throw Exception("Provided current password is invalid") + } + } + val newPass = password.toCharArray() + session.setPassword(newPass) + _oathSessionPassword = newPass + Logger.d("Successfully set password") + } + } catch (cause: Throwable) { + result.error(cause) + } + } + } + + + fun unsetOathPassword(currentPassword: String?, result: Pigeon.Result) { + + if (currentPassword == null) { + result.error(ParameterException()) + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + useOathSession("Unset password", true) { session -> + if (session.isAccessKeySet) { + // test current password sent by the user + if (session.unlock(currentPassword.toCharArray())) { + session.deleteAccessKey() + _oathSessionPassword = null + Logger.d("Successfully unset password") + result.success(null) + return@useOathSession + } + } + result.error(Exception("Unset password failed")) + } + } catch (cause: Throwable) { + result.error(cause) + } + } + } + + fun refreshOathCodes(result: Pigeon.Result) { + viewModelScope.launch(Dispatchers.IO) { + try { + if (!isUsbKeyConnected) { + throw Exception("Cannot refresh for nfc key") + } + + useOathSession("Refresh codes", false) { + withUnlockedSession(it) { session -> + val resultJson = SerializeHelpers.serialize( + session.deviceId, + session.calculateCodes() + ).toString() + + result.success(resultJson) + } + } + } catch (cause: Throwable) { + result.error(cause) + } + } + } + + fun calculate(credentialId: String?, result: Pigeon.Result) { + + if (credentialId == null) { + result.error(ParameterException()) + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + useOathSession("Calculate", true) { + withUnlockedSession(it) { session -> + + val credential = getOathCredential(session, credentialId) + + val resultJson = SerializeHelpers.serialize( + session.calculateCode(credential) + ).toString() + + result.success(resultJson) + } + } + } catch (cause: Throwable) { + result.error(cause) + } + } + } + + fun unlockOathSession( + password: String?, + remember: Boolean?, + result: Pigeon.Result + ) { + + if (password == null || remember == null) { + result.error(ParameterException()) + return + } + viewModelScope.launch(Dispatchers.IO) { + try { + var codes: String? = null + useOathSession("Unlocking", true) { + _oathSessionPassword = password.toCharArray() + val isLocked = isOathSessionLocked(it) + + if (!isLocked) { + codes = SerializeHelpers.serialize( + it.deviceId, + it.calculateCodes() + ).toString() + } + + result.success(!isLocked) + } + + codes?.let { + _fOathApi.updateOathCredentials(it) {} + } + + } catch (cause: Throwable) { + result.error(cause) + } + } + } + + fun resetOathSession(result: Pigeon.Result) { + viewModelScope.launch(Dispatchers.IO) { + try { + useOathSession("Reset YubiKey", true) { + // note, it is ok to reset locked session + it.reset() + result.success(null) + } + } catch (e: Throwable) { + result.error(e) + } + } + } + + private suspend fun useOathSession( + title: String, + queryUserToTap: Boolean, + action: (OathSession) -> T + ) = suspendCoroutine { outer -> + if (queryUserToTap && !isUsbKeyConnected) { + viewModelScope.launch(Dispatchers.Main) { + requestShowDialog(title) + } + } + _pendingYubiKeyAction.postValue(YubiKeyAction(title) { yubiKey -> + outer.resumeWith(runCatching { + suspendCoroutine { inner -> + yubiKey.value.requestConnection(SmartCardConnection::class.java) { + inner.resumeWith(runCatching { + action.invoke(OathSession(it.value)) + }) + } + } + }) + }) + + yubiKeyDevice.value?.let { + viewModelScope.launch(Dispatchers.IO) { + provideYubiKey(Result.success(it)) + } + } + } + + /** + * returns true if the session cannot be unlocked (either we don't have a password, or the password is incorrect + * + * returns false if we can unlock the session + */ + private fun isOathSessionLocked(session: OathSession): Boolean { + if (!session.isLocked) { + return false + } + + if (_oathSessionPassword == null) { + return true // we have no password to unlock + } + + val unlockSucceed = session.unlock(_oathSessionPassword!!) + + if (unlockSucceed) { + return false // we have everything to unlock the session + } + + _oathSessionPassword = null // reset the password as well as it did not work + return true // the unlock did not work, session is locked + } + + fun forgetPassword(result: Pigeon.Result) { + result.success(null) + } + +} diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/SerializeHelpers.kt b/android/app/src/main/kotlin/com/yubico/authenticator/SerializeHelpers.kt new file mode 100644 index 00000000..668c3b91 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/SerializeHelpers.kt @@ -0,0 +1,131 @@ +package com.yubico.authenticator + +import com.yubico.yubikit.core.Transport +import com.yubico.yubikit.core.Version +import com.yubico.yubikit.management.DeviceConfig +import com.yubico.yubikit.management.DeviceInfo +import com.yubico.yubikit.oath.Code +import com.yubico.yubikit.oath.Credential +import com.yubico.yubikit.oath.OathSession +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +class SerializeHelpers { + companion object { + private fun serialize(config: DeviceConfig) = with(config) { + JsonObject( + mapOf( + "device_flags" to JsonPrimitive(deviceFlags), + "challenge_response_timeout" to JsonPrimitive(challengeResponseTimeout), + "auto_eject_timeout" to JsonPrimitive(autoEjectTimeout), + "enabled_capabilities" to JsonObject( + mapOf( + "usb" to JsonPrimitive(getEnabledCapabilities(Transport.USB) ?: 0), + "nfc" to JsonPrimitive(getEnabledCapabilities(Transport.NFC) ?: 0), + ) + ) + ) + ) + } + + private fun serialize(version: Version) = with(version) { + JsonArray( + listOf( + JsonPrimitive(major), + JsonPrimitive(minor), + JsonPrimitive(micro) + ) + ) + } + + fun serialize(data: DeviceInfo, isNfcDevice: Boolean) = + with(data) { + JsonObject( + mapOf( + "config" to serialize(config), + "serial" to JsonPrimitive(serialNumber), + "version" to serialize(version), + "form_factor" to JsonPrimitive(formFactor.value), + "is_locked" to JsonPrimitive(isLocked), + "is_sky" to JsonPrimitive(false), // FIXME return correct value + "is_fips" to JsonPrimitive(false), // FIXME return correct value + "name" to JsonPrimitive("FIXME"), // FIXME return correct value + "isNFC" to JsonPrimitive(isNfcDevice), + "supported_capabilities" to JsonObject( + mapOf( + "usb" to JsonPrimitive(getSupportedCapabilities(Transport.USB)), + "nfc" to JsonPrimitive(getSupportedCapabilities(Transport.NFC)), + ) + ) + ) + ) + } + + fun serialize(oathSession: OathSession, remembered: Boolean) = + JsonObject( + mapOf( + "deviceId" to JsonPrimitive(oathSession.deviceId), + "hasKey" to JsonPrimitive(oathSession.isAccessKeySet), + "remembered" to JsonPrimitive(remembered), + "locked" to JsonPrimitive(oathSession.isLocked) + ) + ) + + + fun serialize(code: Code?) = + when (code) { + null -> JsonNull + else -> JsonObject( + mapOf( + "value" to JsonPrimitive(code.value), + "valid_from" to JsonPrimitive(code.validFrom / 1000), + "valid_to" to JsonPrimitive(code.validUntil / 1000) + ) + ) + } + + fun credentialIdAsString(id: ByteArray): String = id.joinToString( + separator = "" + ) { b -> "%02x".format(b) } + + fun serialize(deviceId: String, credential: Credential) = + JsonObject( + mapOf( + "id" to JsonPrimitive( + credentialIdAsString(credential.id) + ), + "device_id" to JsonPrimitive(deviceId), + "issuer" to JsonPrimitive(credential.issuer), + "name" to JsonPrimitive(credential.accountName), + "oath_type" to JsonPrimitive(credential.oathType.value), + "period" to JsonPrimitive(credential.period), + "touch_required" to JsonPrimitive(credential.isTouchRequired), + ) + ) + + fun serialize(deviceId: String, codes: Map) = + JsonObject( + mapOf( + "entries" to JsonArray( + codes.map { (credential, code) -> + JsonObject( + mapOf( + "credential" to serialize(deviceId, credential), + "code" to serialize(code) + ) + ) + } + ) + ) + ) + + fun messageParameters(message: String) = JsonObject( + mapOf( + "message" to JsonPrimitive(message) + ) + ) + + } +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..3db14bb5 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..e9c9fe68 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Yubico AuthenticatorNG + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..d460d1e9 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/device_filter.xml b/android/app/src/main/res/xml/device_filter.xml new file mode 100644 index 00000000..a65c3cb7 --- /dev/null +++ b/android/app/src/main/res/xml/device_filter.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..fe5b5c89 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 00000000..5294a8ba --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,42 @@ +buildscript { + ext.kotlin_version = '1.6.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:4.1.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:1.6.10" + } +} + +allprojects { + repositories { + google() + mavenCentral() + mavenLocal() // TODO: Remove this before release + } + + project.ext { + minSdkVersion = 21 + targetSdkVersion = 31 + compileSdkVersion = 31 + buildToolsVersion = "30.0.3" + + yubiKitVersion = "2.1.0-SNAPSHOT" + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..94adc3a3 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..bc6a58af --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 00000000..44e62bcf --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"