Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .idea/compiler.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(project(":authy-sdk"))
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-gson:3.0.0")
implementation(libs.androidx.compose.material3)
coreLibraryDesugaring(libs.desugar.jdk.libs)
testImplementation(libs.junit)
Expand Down
25 changes: 23 additions & 2 deletions app/src/main/java/io/shortmesh/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.shortmesh.network.OtpApi
import io.shortmesh.sdk.ui.AuthyWidgetLauncherView
import io.shortmesh.sdk.viewmodel.AuthyViewModel
import io.shortmesh.ui.theme.ShortMeshSDKTheme
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -31,6 +34,7 @@ class MainActivity : ComponentActivity() {
ShortMeshSDKTheme {
var showAuthyWidget by remember { mutableStateOf(false) }
val authyViewModel: AuthyViewModel by viewModels()
val scope = rememberCoroutineScope()

Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
Expand All @@ -52,12 +56,29 @@ class MainActivity : ComponentActivity() {
showDialog = showAuthyWidget,
authyUrl = "https://authy.shortmesh.com",
viewModel = authyViewModel,
requestCodeCallback = {},
sendCodeCallback = {},
requestCodeCallback = { phoneNumber, onResult ->
val platform = authyViewModel.selectedPlatform?.name ?: ""
scope.launch {
val response = OtpApi.generate(phoneNumber, platform)
onResult(response.expires_at)
}
},
sendCodeCallback = { code ->
val phoneNumber = authyViewModel.phoneNumber ?: ""
val platform = authyViewModel.selectedPlatform?.name ?: ""
val response = OtpApi.verify(code, phoneNumber, platform)
response.error?.takeIf { it.isNotBlank() }?.let { error ->
throw IllegalStateException(error)
}
},
onVerificationFailed = { error ->
println("Verification failed: $error")
},
) {
showAuthyWidget = false
}
}

}
}
}
Expand Down
52 changes: 52 additions & 0 deletions app/src/main/java/io/shortmesh/network/OtpService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package io.shortmesh.network

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.POST

interface OtpService {
@POST("/api/v1/otp/generate")
suspend fun generate(@Body request: OtpGenerateRequest): OtpGenerateResponse

@POST("/api/v1/otp/verify")
suspend fun verify(@Body request: OtpVerifyRequest): OtpVerifyResponse
}

data class OtpGenerateRequest(
val phone_number: String,
val platform: String,
)

data class OtpGenerateResponse(
val expires_at: String?,
val message: String?,
val error: String?,
)

data class OtpVerifyRequest(
val code: String,
val phone_number: String,
val platform: String,
)

data class OtpVerifyResponse(
val message: String?,
val error: String?,
)

object OtpApi {
private val service: OtpService by lazy {
Retrofit.Builder()
.baseUrl("https://authy.shortmesh.com")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(OtpService::class.java)
}

suspend fun generate(phoneNumber: String, platform: String): OtpGenerateResponse =
service.generate(OtpGenerateRequest(phoneNumber, platform))

suspend fun verify(code: String, phoneNumber: String, platform: String): OtpVerifyResponse =
service.verify(OtpVerifyRequest(code, phoneNumber, platform))
}
18 changes: 15 additions & 3 deletions authy-sdk/src/main/java/io/shortmesh/sdk/ui/ListPlatformsView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ fun AuthyWidgetLauncherView(
showDialog: Boolean,
authyUrl: String,
viewModel: AuthyViewModel,
requestCodeCallback: (phoneNumber: String) -> Unit = {},
sendCodeCallback: (code: String) -> Unit = {},
requestCodeCallback: (phoneNumber: String, onResult: (expiresAt: String?) -> Unit) -> Unit = { _, _ -> },
sendCodeCallback: suspend (code: String) -> Unit = {},
onVerificationFailed: (message: String) -> Unit = {},
onDismiss: () -> Unit = {},
) {
val listPlatformsUiState by viewModel.listPlatformsUiState.collectAsState()
Expand All @@ -50,6 +51,10 @@ fun AuthyWidgetLauncherView(
title = stringResource(R.string.loading_platforms),
message = stringResource(R.string.please_wait)
)
is SupportedPlatformsUiState.Verifying -> LoadingScreen(
title = stringResource(R.string.verifying),
message = stringResource(R.string.please_wait)
)

is SupportedPlatformsUiState.Error -> ErrorScreen(
message = s.message,
Expand All @@ -70,7 +75,14 @@ fun AuthyWidgetLauncherView(
is SupportedPlatformsUiState.Verify -> VerificationCodeScreen(
viewModel = viewModel,
submitCallback = sendCodeCallback,
onCancelCallback = onDismiss
onVerificationSuccess = onDismiss,
onVerificationFailed = onVerificationFailed,
onCancelCallback = onDismiss,
onResendCallback = {
requestCodeCallback(viewModel.phoneNumber ?: "") { expiresAt ->
viewModel.setOtpExpiresAt(expiresAt)
}
}
)
else -> {
onDismiss()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,15 @@ import kotlinx.coroutines.launch
@Composable
fun PhoneNumberScreen(
viewModel: AuthyViewModel,
requestCodeCallback: (phoneNumber: String) -> Unit = {},
requestCodeCallback: (phoneNumber: String, onResult: (expiresAt: String?) -> Unit) -> Unit = { _, _ -> },
onCancelCallback: () -> Unit = {},
) {
PhoneNumberScreenComponent(
requestCodeCallback = { phoneNumber ->
requestCodeCallback(phoneNumber)
viewModel.submitPhoneNumber(phoneNumber)
requestCodeCallback(phoneNumber) { expiresAt ->
viewModel.setOtpExpiresAt(expiresAt)
}
},
onCancelCallback
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
Expand All @@ -32,20 +36,32 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.shortmesh.sdk.R
import io.shortmesh.sdk.viewmodel.AuthyViewModel
import kotlinx.coroutines.delay

@Composable
fun VerificationCodeScreen(
viewModel: AuthyViewModel,
submitCallback: (code: String) -> Unit = {},
submitCallback: suspend (code: String) -> Unit = {},
onVerificationSuccess: () -> Unit = {},
onVerificationFailed: (message: String) -> Unit = {},
onCancelCallback: () -> Unit = {},
onResendCallback: () -> Unit = {},
) {
val otpExpiresInSeconds by viewModel.otpExpiresInSeconds.collectAsState()
VerificationCodeScreenComponent(
platformName = viewModel.selectedPlatform?.display_name ?: "",
phoneNumber = viewModel.phoneNumber ?: "",
expiresInSeconds = otpExpiresInSeconds,
submitCallback = { code ->
viewModel.submitCode(code, submitCallback)
viewModel.submitCode(
code = code,
callback = submitCallback,
onSuccess = onVerificationSuccess,
onFailure = onVerificationFailed,
)
},
onCancelCallback
onCancelCallback = onCancelCallback,
onResendCallback = onResendCallback
)
}

Expand All @@ -54,16 +70,28 @@ fun VerificationCodeScreen(
private fun VerificationCodeScreenComponent(
platformName: String = "",
phoneNumber: String = "",
expiresInSeconds: Long? = null,
submitCallback: (code: String) -> Unit = {},
onCancelCallback: () -> Unit = {},
onResendCallback: () -> Unit = {},
) {
var code by remember { mutableStateOf("") }
var remainingSeconds by remember { mutableLongStateOf(0L) }
val isExpired = expiresInSeconds != null && remainingSeconds <= 0

LaunchedEffect(expiresInSeconds) {
if (expiresInSeconds == null) return@LaunchedEffect
remainingSeconds = expiresInSeconds.coerceAtLeast(0L)
while (remainingSeconds > 0) {
delay(1000L)
remainingSeconds -= 1
if (remainingSeconds <= 0) break
}
}

Card(
shape = RoundedCornerShape(16.dp),
modifier = Modifier
.padding(16.dp)
// .width(400.dp)
modifier = Modifier.padding(16.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
Expand All @@ -74,8 +102,8 @@ private fun VerificationCodeScreenComponent(
onValueChange = { code = it },
enabled = true,
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.enter_code))},
placeholder = {Text(stringResource(R.string.enter_code))},
label = { Text(stringResource(R.string.enter_code)) },
placeholder = { Text(stringResource(R.string.enter_code)) },
supportingText = {
Text(
text = buildAnnotatedString {
Expand All @@ -92,6 +120,36 @@ private fun VerificationCodeScreenComponent(
},
isError = false,
)

if (expiresInSeconds != null) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isExpired) {
stringResource(R.string.otp_expired)
} else {
stringResource(R.string.otp_expires_in, formatCountdown(remainingSeconds))
},
style = MaterialTheme.typography.bodySmall,
color = if (isExpired || remainingSeconds < 60) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
TextButton(
onClick = onResendCallback,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
) {
Text(stringResource(R.string.resend_code))
}
}
}

Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
Expand All @@ -113,9 +171,7 @@ private fun VerificationCodeScreenComponent(
Spacer(modifier = Modifier.width(8.dp))

Button(
onClick = {
submitCallback(code)
},
onClick = { submitCallback(code) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 0.dp),
Expand All @@ -126,4 +182,10 @@ private fun VerificationCodeScreenComponent(
}
}
}
}

private fun formatCountdown(seconds: Long): String {
val mins = seconds / 60
val secs = seconds % 60
return "%d:%02d".format(mins, secs)
}
Loading