diff --git a/.gitignore b/.gitignore index 0bd772cf..7146a664 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ local.properties /app/release/ /khalti-android/build/ +/.idea \ No newline at end of file diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 079ff189..00000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -Khalti Android SDK \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index fb7f4a8a..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/copyright/Khalti_Authors.xml b/.idea/copyright/Khalti_Authors.xml deleted file mode 100644 index 1c028f15..00000000 --- a/.idea/copyright/Khalti_Authors.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml deleted file mode 100644 index 37eabbdd..00000000 --- a/.idea/copyright/profiles_settings.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 30b621c0..00000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index cb1a6338..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7f..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 2bda88c7..88c7abf1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' @@ -37,7 +39,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.1.1' + kotlinCompilerExtensionVersion '1.5.10' } packagingOptions { resources { @@ -45,7 +47,7 @@ android { } } - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + tasks.withType(KotlinCompile).configureEach { kotlinOptions { freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" } @@ -54,13 +56,13 @@ android { dependencies { implementation "androidx.core:core-ktx:$core_ktx_version" - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' - implementation 'androidx.activity:activity-compose:1.6.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + implementation 'androidx.activity:activity-compose:1.8.2' implementation "androidx.compose.ui:ui:$compose_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" - implementation 'androidx.compose.material3:material3:1.1.0-alpha01' + implementation 'androidx.compose.material3:material3:1.2.1' - //implementation "com.khalti:khalti-android:$khaltiVersionName" +// implementation "com.khalti:khalti-android:$khaltiVersionName" implementation project(path: ':khalti-android') testImplementation "junit:junit:$junit_version" diff --git a/app/src/main/java/com/khalti/android/demo/MainActivity.kt b/app/src/main/java/com/khalti/android/demo/MainActivity.kt index 082a919f..edd01f94 100644 --- a/app/src/main/java/com/khalti/android/demo/MainActivity.kt +++ b/app/src/main/java/com/khalti/android/demo/MainActivity.kt @@ -9,19 +9,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import com.khalti.android.demo.composable.DemoScreen -import com.khalti.android.demo.theme.KhaltiTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - KhaltiTheme { - Surface( - Modifier.fillMaxSize(), - ) { - DemoScreen() - } + Surface( + Modifier.fillMaxSize(), + ) { + DemoScreen() } } } diff --git a/app/src/main/java/com/khalti/android/demo/composable/DemoScreen.kt b/app/src/main/java/com/khalti/android/demo/composable/DemoScreen.kt index 467a0105..9ec7d86e 100644 --- a/app/src/main/java/com/khalti/android/demo/composable/DemoScreen.kt +++ b/app/src/main/java/com/khalti/android/demo/composable/DemoScreen.kt @@ -2,121 +2,209 @@ package com.khalti.android.demo.composable +import android.annotation.SuppressLint +import android.net.Uri import android.util.Log -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.* +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.modifier.modifierLocalConsumer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.khalti.android.* +import com.khalti.android.Khalti +import com.khalti.android.data.Environment +import com.khalti.android.data.KhaltiPayConfig import com.khalti.android.demo.R +import kotlinx.coroutines.launch -const val RESULT_TAG = "KHALTI_PAY_RESULT" - -@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable +@Preview fun DemoScreen() { - var paymentUrl by remember { mutableStateOf(TextFieldValue("")) } - var returnUrl by remember { mutableStateOf(TextFieldValue("https://redirect.khalti.com")) } - - var urlErrorMessage by remember { mutableStateOf("") } - - val (result, setResult) = remember { mutableStateOf(null) } - - val khaltiPay = rememberLauncherForActivityResult(OpenKhaltiPay()) { - setResult(it) + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + val snackBarHostState = remember { + SnackbarHostState() + } - when (it) { - is PaymentSuccess -> { - Log.i(RESULT_TAG, "Payment Success") + val khalti = Khalti.init( + LocalContext.current, + KhaltiPayConfig( + publicKey = "live_public_key_979320ffda734d8e9f7758ac39ec775f", + pidx = "Prd42EcFeqvVKpHRGN3ZUZ", + returnUrl = Uri.parse("https://webhook.site/ed508278-3ce3-4f6d-98f1-0b6084c5c5cd"), + environment = Environment.TEST + ), + onPaymentResult = { paymentResult, khalti -> + Log.i("Demo | onPaymentResult", paymentResult.toString()) + khalti.close() + scope.launch { + snackBarHostState.showSnackbar("Payment successful for pidx: ${khalti.config.pidx}") } - is PaymentError -> { - Log.i(RESULT_TAG, "Payment Error") - } - is PaymentCancelled -> { - Log.i(RESULT_TAG, "Payment Cancelled") + }, + onMessage = { payload -> + Log.i( + "Demo | onMessage", + "${payload.event} ${if (payload.code != null) "(${payload.code})" else ""} | ${payload.message} | ${payload.needsPaymentConfirmation}" + ) + payload.khalti.close() + payload.throwable?.printStackTrace() + scope.launch { + snackBarHostState.showSnackbar("OnMessage: ${payload.message}") } + }, + onReturn = { _ -> + Log.i("Demo | onReturn", "OnReturn") } - } + ) - if (result != null) { - ResultDialog(result, setResult) + val publicKey = remember { + mutableStateOf(khalti.config.publicKey) + } + val pidx = remember { + mutableStateOf(khalti.config.pidx) + } + val returnUrl = remember { + mutableStateOf(khalti.config.returnUrl) + } + val environments = enumValues() + val selectedEnvironment = remember { + mutableStateOf(khalti.config.environment) } - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { - Text("Khalti Android SDK Demo") - } - - ) + snackbarHost = { + SnackbarHost(hostState = snackBarHostState) }, - content = { padding -> + content = { Column( - Modifier.fillMaxWidth(), + Modifier + .verticalScroll(state = scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(Modifier.padding(padding)) + Spacer(Modifier.padding(16.dp)) Image( - painterResource(R.drawable.khalti_logo_color), + painterResource(R.mipmap.seru), contentDescription = "Khalti Logo", - modifier = Modifier.height(200.dp) + modifier = Modifier.height(180.dp) ) - OutlinedTextField( - value = paymentUrl, - label = { Text("Payment URL") }, - placeholder = { Text("Enter payment URL") }, + Spacer(Modifier.height(30.dp)) + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + value = publicKey.value, onValueChange = { - paymentUrl = it - urlErrorMessage = "" + publicKey.value = it }, + label = { Text(text = "Public Key") }, + ) + Spacer(Modifier.height(20.dp)) + TextField( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp), - isError = urlErrorMessage.isNotEmpty(), - supportingText = { Text(urlErrorMessage) } - ) - Spacer(Modifier.height(24.dp)) - OutlinedTextField( - value = returnUrl, - label = { Text("Return URL") }, + .padding(horizontal = 16.dp), + value = returnUrl.value.toString(), onValueChange = { - returnUrl = it + returnUrl.value = Uri.parse(it) }, + label = { Text(text = "Return Url") }, + ) + Spacer(Modifier.height(20.dp)) + TextField( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) + .padding(horizontal = 16.dp), + value = pidx.value, + onValueChange = { + pidx.value = it + }, + label = { Text(text = "PIDX") }, ) - Spacer(Modifier.height(40.dp)) - FilledTonalButton( - { - try { - val config = KhaltiPayConfiguration( - paymentUrl.text, - returnUrl.text + Spacer(Modifier.height(20.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text(text = "Environment") + environments.forEach { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = (it == selectedEnvironment.value), + onClick = { selectedEnvironment.value = it } + ) + Text( + text = it.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp) ) - khaltiPay.launch(config) - } catch (e: Exception) { - val message = e.message ?: "" - - when (e) { - is IllegalArgumentException, is IllegalStateException -> { - urlErrorMessage = message - } - else -> Log.e(RESULT_TAG, message) - } } } + } + Spacer(Modifier.height(30.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Pay with Khalti") + Text(text = "Rs. 22", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(8.dp)) + Text(text = "1 day fee", style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + OutlinedButton( + { + if (publicKey.value != khalti.config.publicKey + || returnUrl.value != khalti.config.returnUrl + || pidx.value != khalti.config.pidx + || selectedEnvironment.value != khalti.config.environment + ) { + khalti.config = khalti.config.copy( + publicKey = publicKey.value, + returnUrl = returnUrl.value, + pidx = pidx.value, + environment = selectedEnvironment.value, + ) + } + + khalti.open() + } + ) { + Text("Pay Rs. 22") + } } + Spacer(Modifier.height(30.dp)) + Text( + text = "This is a demo application developed by some merchant.", + style = MaterialTheme.typography.bodySmall + ) } }, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/khalti/android/demo/composable/ResultDialog.kt b/app/src/main/java/com/khalti/android/demo/composable/ResultDialog.kt deleted file mode 100644 index b3e06e1b..00000000 --- a/app/src/main/java/com/khalti/android/demo/composable/ResultDialog.kt +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2022. The Khalti Authors. All rights reserved. - -package com.khalti.android.demo.composable - -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import com.khalti.android.* - -@Composable -fun ResultDialog(result: PaymentResult, setResult: (PaymentResult?) -> Unit) { - AlertDialog( - title = { - Text( - when (result) { - is PaymentSuccess -> "Payment Success" - is PaymentError -> "Payment Error" - is PaymentCancelled -> "Payment Cancelled" - else -> "Unknown" - } - ) - }, - text = { - when (result) { - is PaymentSuccess -> Column { - Text("Identifier: ${result.pidx}") - Text("Amount: ${result.amount}") - Text("Mobile: ${result.mobile}") - Text("Purchase Order ID: ${result.purchaseOrderId}") - Text("Purchase Order Name: ${result.purchaseOrderName}") - Text("Transaction ID: ${result.transactionId}") - } - is PaymentError -> Text(result.message) - is PaymentCancelled -> Text("The payment was cancelled.") - } - }, - confirmButton = { - FilledTonalButton( - onClick = { - setResult(null) - } - ) { - Text("OK") - } - }, - onDismissRequest = { - setResult(null) - }, - ) -} diff --git a/app/src/main/java/com/khalti/android/demo/theme/Color.kt b/app/src/main/java/com/khalti/android/demo/theme/Color.kt deleted file mode 100644 index 985fdc3f..00000000 --- a/app/src/main/java/com/khalti/android/demo/theme/Color.kt +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2022. The Khalti Authors. All rights reserved. - -package com.khalti.android.demo.theme - -import androidx.compose.ui.graphics.Color - -val khaltiPurple = Color(0xFF5E338D) \ No newline at end of file diff --git a/app/src/main/java/com/khalti/android/demo/theme/Theme.kt b/app/src/main/java/com/khalti/android/demo/theme/Theme.kt deleted file mode 100644 index 23fee6c5..00000000 --- a/app/src/main/java/com/khalti/android/demo/theme/Theme.kt +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2022. The Khalti Authors. All rights reserved. - -package com.khalti.android.demo.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.* -import androidx.core.view.ViewCompat - -private val DarkColorScheme = darkColorScheme(khaltiPurple) - -private val LightColorScheme = lightColorScheme(khaltiPurple) - -@Composable -fun KhaltiTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb() - ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme - } - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/khalti/android/demo/theme/Type.kt b/app/src/main/java/com/khalti/android/demo/theme/Type.kt deleted file mode 100644 index a00a0326..00000000 --- a/app/src/main/java/com/khalti/android/demo/theme/Type.kt +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2022. The Khalti Authors. All rights reserved. - -package com.khalti.android.demo.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.* -import androidx.compose.ui.unit.sp - -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) -) \ No newline at end of file diff --git a/app/src/main/res/drawable/khalti_logo_color.xml b/app/src/main/res/drawable/khalti_logo_color.xml deleted file mode 100644 index b5132406..00000000 --- a/app/src/main/res/drawable/khalti_logo_color.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/mipmap-xxhdpi/seru.png b/app/src/main/res/mipmap-xxhdpi/seru.png new file mode 100644 index 00000000..1c75e51d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/seru.png differ diff --git a/build.gradle b/build.gradle index bd9dade1..699cedb5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { - id 'com.android.application' version '7.3.1' apply false - id 'com.android.library' version '7.3.1' apply false - id 'org.jetbrains.kotlin.android' version '1.6.10' apply false + id 'com.android.application' version '8.3.0' apply false + id 'com.android.library' version '8.3.0' apply false + id 'org.jetbrains.kotlin.android' version '1.9.22' apply false id 'io.github.gradle-nexus.publish-plugin' version '1.1.0' } diff --git a/dependencies.gradle b/dependencies.gradle index 5ca6a668..e66759bc 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -1,10 +1,10 @@ ext { - appcompat_version = '1.5.1' - compose_version = '1.3.0' - core_ktx_version = '1.9.0' - material_version = '1.7.0' + appcompat_version = '1.6.1' + compose_version = '1.6.3' + core_ktx_version = '1.12.0' + material_version = '1.11.0' junit_version = '4.13.2' - junit_ext_version = '1.1.3' - espresso_version = '3.4.0' + junit_ext_version = '1.1.5' + espresso_version = '3.5.1' } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 3c5031eb..f19c7b9b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,5 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aaf5e0bc..20c89899 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Oct 31 20:08:12 NPT 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/khalti-android/build.gradle b/khalti-android/build.gradle index d81c9301..fe748747 100644 --- a/khalti-android/build.gradle +++ b/khalti-android/build.gradle @@ -1,12 +1,22 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id 'kotlin-parcelize' } android { namespace 'com.khalti.android' compileSdk libraryCompileSdk + buildFeatures { + buildConfig true + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.10" + } + defaultConfig { minSdk libraryMinSdk targetSdk libraryTargetSdk @@ -16,25 +26,53 @@ android { } buildTypes { + debug { + buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") + } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") } } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions { jvmTarget = '1.8' } } dependencies { + // ---------- Core ---------- implementation "androidx.core:core-ktx:$core_ktx_version" implementation "androidx.appcompat:appcompat:$appcompat_version" implementation "com.google.android.material:material:$material_version" + + // ---------- Api ---------- + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + + // ---------- Compose ---------- + def composeBom = platform('androidx.compose:compose-bom:2024.02.02') + implementation platform('androidx.compose:compose-bom:2024.02.02') + androidTestImplementation platform('androidx.compose:compose-bom:2024.02.02') + + implementation 'androidx.compose.material3:material3' + implementation 'androidx.activity:activity-compose:1.8.2' + implementation "androidx.compose.material:material-icons-extended:$compose_version" + + // Compose preview support + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + + // ---------- Test ---------- testImplementation "junit:junit:$junit_version" + androidTestImplementation "androidx.test.ext:junit:$junit_ext_version" androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } diff --git a/khalti-android/src/main/AndroidManifest.xml b/khalti-android/src/main/AndroidManifest.xml index 8c42db92..16665fc0 100644 --- a/khalti-android/src/main/AndroidManifest.xml +++ b/khalti-android/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + diff --git a/khalti-android/src/main/java/com/khalti/android/EPaymentWebClient.kt b/khalti-android/src/main/java/com/khalti/android/EPaymentWebClient.kt deleted file mode 100644 index 65a69fc2..00000000 --- a/khalti-android/src/main/java/com/khalti/android/EPaymentWebClient.kt +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) 2022. The Khalti Authors. All rights reserved. - -package com.khalti.android - -import android.app.Activity -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.webkit.* -import android.widget.Toast -import androidx.annotation.RequiresApi - -internal class EPaymentWebClient( - private val activity: Activity, - private val returnUrl: String -) : WebViewClient() { - - @RequiresApi(Build.VERSION_CODES.LOLLIPOP) - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): - Boolean = handleUri(view, request!!.url) - - @SuppressWarnings("deprecation") - @Deprecated("") - override fun shouldOverrideUrlLoading(view: WebView?, url: String?): - Boolean = handleUri(view, Uri.parse(url)) - - @RequiresApi(Build.VERSION_CODES.M) - override fun onReceivedError( - view: WebView?, - request: WebResourceRequest?, - error: WebResourceError? - ) = handleError(error?.description.toString()) - - @SuppressWarnings("deprecation") - @Deprecated("") - override fun onReceivedError( - view: WebView?, - errorCode: Int, - description: String?, - failingUrl: String? - ) = handleError(description) - - private fun handleUri(view: WebView?, uri: Uri): Boolean { - val url = uri.toString() - val path = uri.path - val fragment = uri.fragment - - val eBankingPath = "/ebanking/initiate/" - val mPinPath = "/account/transaction_pin" - - if (url == OpenKhaltiPay.DEFAULT_HOME) { - activity.finish() - } else if (url.startsWith(returnUrl)) { - val isSuccess = uri.getQueryParameter("pidx") != null - val intent = Intent() - intent.putExtra(OpenKhaltiPay.RESULT, url) - - activity.setResult( - if (isSuccess) Activity.RESULT_OK else OpenKhaltiPay.ERROR, - intent - ) - activity.finish() - } else if (path.equals(eBankingPath)) { - view?.loadUrl(url) - } else if (path.equals(mPinPath) || fragment.equals(mPinPath)) { - val deeplink = "https://khalti.com/go/?t=mpin" - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(deeplink)) - - activity.startActivity(browserIntent) - } else { - Toast.makeText(activity, "Action not permitted", Toast.LENGTH_SHORT).show() - } - - return true - } - - private fun handleError(description: String?) { - val intent = Intent() - intent.putExtra(OpenKhaltiPay.PAYMENT_URL_LOAD_ERROR_RESULT, description ?: "") - - activity.setResult(OpenKhaltiPay.PAYMENT_URL_LOAD_ERROR, intent) - activity.finish() - } -} diff --git a/khalti-android/src/main/java/com/khalti/android/Khalti.kt b/khalti-android/src/main/java/com/khalti/android/Khalti.kt new file mode 100644 index 00000000..7af272f1 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/Khalti.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android + +import android.content.Context +import android.content.Intent +import com.khalti.android.service.VerificationRepository +import com.khalti.android.data.KhaltiPayConfig +import com.khalti.android.callbacks.OnMessage +import com.khalti.android.callbacks.OnPaymentResult +import com.khalti.android.callbacks.OnReturn +import com.khalti.android.cache.Store +import com.khalti.android.utils.PackageUtil + +// Though kotlin provides named and optional parameters +// method overloading was required for Java developers +class Khalti private constructor( + private val context: Context, + var config: KhaltiPayConfig, + val onPaymentResult: OnPaymentResult, + val onMessage: OnMessage, + val onReturn: OnReturn?, +) { + companion object { + fun init( + context: Context, + config: KhaltiPayConfig, + onPaymentResult: OnPaymentResult, + onMessage: OnMessage, + onReturn: OnReturn, + ): Khalti { + val khalti = Khalti( + context, + config, + onPaymentResult, + onMessage, + onReturn, + ) + + Store.instance().put("khalti", khalti) + return khalti + } + + fun init( + context: Context, + config: KhaltiPayConfig, + onPaymentResult: OnPaymentResult, + onMessage: OnMessage, + ): Khalti { + val khalti = Khalti( + context, + config, + onPaymentResult, + onMessage, + null, + ) + + Store.instance().put("khalti", khalti) + + return khalti + } + } + + fun open() { + val packageName = context.packageName + val store = Store.instance() + val packageInfo = PackageUtil.getPackageInfo(context, packageName) + + store.put("merchant_package_name", packageName) + store.put("merchant_package_version", packageInfo?.versionName ?: "") + + val intent = Intent(context, PaymentActivity::class.java) + context.startActivity(intent) + } + + fun verify() { + val verificationRepo = VerificationRepository() + verificationRepo.verify(config.pidx, this) + } + + fun close() { + val intent = Intent("close_khalti_payment_portal") + context.sendBroadcast(intent) + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/KhaltiPayConfiguration.kt b/khalti-android/src/main/java/com/khalti/android/KhaltiPayConfiguration.kt deleted file mode 100644 index ccdc2895..00000000 --- a/khalti-android/src/main/java/com/khalti/android/KhaltiPayConfiguration.kt +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2022. The Khalti Authors. All rights reserved. - -package com.khalti.android - -import android.os.Parcel -import android.os.Parcelable - -class KhaltiPayConfiguration(val paymentUrl: String, val returnUrl: String) : Parcelable { - init { - require(paymentUrl.isNotBlank()) { "Payment URL cannot be blank" } - require(returnUrl.isNotBlank()) { "Return URL cannot be blank" } - check(paymentUrl != returnUrl) { "Payment URL and Return URL cannot be same" } - - val validPaymentUrlRegex = Regex("^https://.*pay.khalti.com/?\\?pidx=.+") - require(validPaymentUrlRegex.matches(paymentUrl)) { "Invalid Payment URL" } - } - - constructor(parcel: Parcel) : this( - parcel.readString() ?: "", - parcel.readString() ?: "" - ) - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeString(paymentUrl) - parcel.writeString(returnUrl) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): KhaltiPayConfiguration { - return KhaltiPayConfiguration(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} diff --git a/khalti-android/src/main/java/com/khalti/android/OpenKhaltiPay.kt b/khalti-android/src/main/java/com/khalti/android/OpenKhaltiPay.kt deleted file mode 100644 index 1da82b81..00000000 --- a/khalti-android/src/main/java/com/khalti/android/OpenKhaltiPay.kt +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2022. The Khalti Authors. All rights reserved. - -package com.khalti.android - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.activity.result.contract.ActivityResultContract - -class OpenKhaltiPay : ActivityResultContract() { - override fun createIntent(context: Context, input: KhaltiPayConfiguration): Intent { - return Intent(context, PaymentActivity::class.java).apply { - putExtra(CONFIG, input) - } - } - - override fun parseResult(resultCode: Int, intent: Intent?): PaymentResult { - if (resultCode == PAYMENT_URL_LOAD_ERROR) { - return PaymentError( - "Payment URL load error: ${intent?.getStringExtra(PAYMENT_URL_LOAD_ERROR_RESULT)}" - ) - } - - val url = intent?.getStringExtra(RESULT) - ?: return PaymentCancelled() - - with(Uri.parse(url)) { - return when (resultCode) { - Activity.RESULT_OK -> PaymentSuccess( - getQueryParameter("pidx") ?: "", - getQueryParameter("amount")?.toLongOrNull() ?: 0, - getQueryParameter("mobile") ?: "", - getQueryParameter("purchase_order_id") ?: "", - getQueryParameter("purchase_order_name") ?: "", - getQueryParameter("transaction_id") - ?: getQueryParameter("idx") - ?: "", - ) - ERROR -> PaymentError( - getQueryParameter("message") ?: "Payment Failed", - ) - else -> PaymentCancelled() - } - } - } - - companion object { - const val CONFIG = "config" - const val RESULT = "payment-result" - const val ERROR = -2874 - const val PAYMENT_URL_LOAD_ERROR = -2875 - const val PAYMENT_URL_LOAD_ERROR_RESULT = "payment-url-error" - const val DEFAULT_HOME = "kpg://home" - } -} - diff --git a/khalti-android/src/main/java/com/khalti/android/PaymentActivity.kt b/khalti-android/src/main/java/com/khalti/android/PaymentActivity.kt index b82e67fa..fedf134a 100644 --- a/khalti-android/src/main/java/com/khalti/android/PaymentActivity.kt +++ b/khalti-android/src/main/java/com/khalti/android/PaymentActivity.kt @@ -3,72 +3,85 @@ package com.khalti.android import android.annotation.SuppressLint -import android.app.Activity -import android.net.Uri +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Build import android.os.Bundle -import android.view.Gravity import android.webkit.* -import android.widget.LinearLayout -import android.widget.LinearLayout.LayoutParams -import android.widget.ProgressBar -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.appbar.MaterialToolbar +import android.window.OnBackInvokedDispatcher +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.RequiresApi +import com.khalti.android.payment.KhaltiPaymentPage +import com.khalti.android.payment.KhaltiPaymentViewModel +import com.khalti.android.payment.onBack -internal class PaymentActivity : Activity() { +internal class PaymentActivity : ComponentActivity() { + private var receiver: BroadcastReceiver? = null + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val layout = LinearLayout(this) - layout.orientation = LinearLayout.VERTICAL - layout.gravity = Gravity.CENTER - - val params = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - - val appBar = AppBarLayout(this) - val toolbar = MaterialToolbar(this) - - val progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal) - progressBar.isIndeterminate = true - - toolbar.title = "Pay with Khalti" - toolbar.setNavigationIcon(com.google.android.material.R.drawable.abc_ic_ab_back_material) - toolbar.setNavigationOnClickListener { - finish() + setContent { + KhaltiPaymentPage(this, KhaltiPaymentViewModel()) } + registerBroadcast() + setupBackPressListener() + } - val webView = WebView(this) - val webSettings = webView.settings + override fun onDestroy() { + unregisterBroadcast() + super.onDestroy() + } - @SuppressLint("SetJavaScriptEnabled") - webSettings.javaScriptEnabled = true - webSettings.domStorageEnabled = true + @Deprecated( + "Deprecated in Java", ReplaceWith( + "@Suppress(\"DEPRECATION\") super.onBackPressed()", "android.app.Activity" + ) + ) + override fun onBackPressed() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + onBack() + } + @Suppress("DEPRECATION") super.onBackPressed() + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(OpenKhaltiPay.CONFIG, KhaltiPayConfiguration::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(OpenKhaltiPay.CONFIG) - }?.let { it -> - webView.webViewClient = EPaymentWebClient(this, it.returnUrl) - webView.webChromeClient = object : WebChromeClient() { - override fun onProgressChanged(view: WebView?, newProgress: Int) { - progressBar.visibility = if (newProgress == 100) ProgressBar.GONE else ProgressBar.VISIBLE + @SuppressLint("UnspecifiedRegisterReceiverFlag") + private fun registerBroadcast() { + receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent != null && intent.action.equals("close_khalti_payment_portal")) { + finish() } } - - val paymentUri = Uri.parse(it.paymentUrl).buildUpon() - .appendQueryParameter("home", OpenKhaltiPay.DEFAULT_HOME) - .build() - webView.loadUrl(paymentUri.toString()) } + if (Build.VERSION.SDK_INT >= 26) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver( + receiver, IntentFilter("close_khalti_payment_portal"), RECEIVER_NOT_EXPORTED + ) + } else { + registerReceiver( + receiver, IntentFilter("close_khalti_payment_portal"), + ) + } + } + } - appBar.addView(toolbar) - - layout.addView(appBar) - layout.addView(progressBar) - layout.addView(webView, params) + private fun unregisterBroadcast() { + unregisterReceiver(receiver) + } - setContentView(layout, params) + private fun setupBackPressListener() { + if (Build.VERSION.SDK_INT >= 33) { + val priority = OnBackInvokedDispatcher.PRIORITY_DEFAULT + onBackInvokedDispatcher.registerOnBackInvokedCallback(priority) { + onBack() + } + } } } + diff --git a/khalti-android/src/main/java/com/khalti/android/PaymentResult.kt b/khalti-android/src/main/java/com/khalti/android/PaymentResult.kt deleted file mode 100644 index 39be6736..00000000 --- a/khalti-android/src/main/java/com/khalti/android/PaymentResult.kt +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2022. The Khalti Authors. All rights reserved. - -package com.khalti.android - -interface PaymentResult - -data class PaymentSuccess( - val pidx: String, - val amount: Long, - val mobile: String, - val purchaseOrderId: String, - val purchaseOrderName: String, - val transactionId: String -) : PaymentResult - -data class PaymentError( - val message: String -) : PaymentResult - -class PaymentCancelled : PaymentResult diff --git a/khalti-android/src/main/java/com/khalti/android/api/ApiClient.kt b/khalti-android/src/main/java/com/khalti/android/api/ApiClient.kt new file mode 100644 index 00000000..495234ad --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/api/ApiClient.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.api + +import android.os.Build +import com.khalti.android.BuildConfig +import com.khalti.android.resource.Err +import com.khalti.android.resource.KFailure +import com.khalti.android.resource.Ok +import com.khalti.android.resource.Result +import com.khalti.android.resource.Url +import com.khalti.android.utils.ErrorUtil +import com.khalti.android.cache.Store +import com.khalti.android.data.Environment +import com.khalti.android.Khalti +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import okio.IOException +import retrofit2.HttpException +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit + +class ApiClient { + companion object { + private const val TIME_OUT = 30L + + fun build(): ApiService { + val khalti = Store.instance().get("khalti") + assert(khalti != null) { + "Khalti object has not been cached. There probably an issue in internal logic in the sdk. Please contact the developer" + } + val url = if (khalti!!.config.environment == Environment.PROD) { + Url.BASE_KHALTI_URL_PROD + } else { + Url.BASE_KHALTI_URL_STAGING + }.value + + val loggingInterceptor = HttpLoggingInterceptor() + loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY + + val okHttpClient = OkHttpClient.Builder().readTimeout(TIME_OUT, TimeUnit.SECONDS) + .connectTimeout(TIME_OUT, TimeUnit.SECONDS).addInterceptor { + val store = Store.instance() + val packageName = store.get("merchant_package_name") ?: "" + val packageVersion = store.get("merchant_package_version") ?: "" + val request = it.request().newBuilder() + .addHeader("Authorization", "Key ${khalti.config.publicKey}") + .addHeader("checkout-version", BuildConfig.VERSION_NAME) + .addHeader("checkout-platform", "android") + .addHeader("checkout-os-version", Build.VERSION.RELEASE) + .addHeader("checkout-device-model", Build.MODEL) + .addHeader("checkout-device-manufacturer", Build.MANUFACTURER) + .addHeader("merchant-package-name", packageName) + .addHeader("merchant-package-version", packageVersion).build() + + it.proceed(request) + }.addInterceptor(loggingInterceptor).build() + + return Retrofit.Builder().baseUrl(url) + .addConverterFactory(GsonConverterFactory.create()).client(okHttpClient).build() + .create(ApiService::class.java) + } + } +} + +suspend fun safeApiCall( + dispatcher: CoroutineDispatcher, apiCall: suspend () -> Response +): Result { + return withContext(dispatcher) { + try { + val response = apiCall.invoke() + if (response.isSuccessful && response.body() != null) { + return@withContext Ok(response.body()!!) + } + return@withContext Err( + KFailure.Payment( + message = "Error", cause = Throwable( + ErrorUtil.parseError( + if (response.errorBody() != null) String( + response.errorBody()!!.bytes() + ) else "", response.code().toString() + ) + ), code = response.code() + ) + ) + } catch (t: Throwable) { + val processedThrowable = Throwable( + ErrorUtil.parseThrowableError(t.message, "600") + ) + val failure: KFailure = when (t) { + is UnknownHostException -> KFailure.ServerUnreachable( + t.message ?: "", + processedThrowable + ) + + is SocketTimeoutException -> KFailure.NoNetwork(t.message ?: "", processedThrowable) + is IOException -> KFailure.NoNetwork(t.message ?: "", processedThrowable) + is HttpException -> { + val code = t.code() + + KFailure.HttpCall(t.message ?: "", processedThrowable, code) + } + + else -> KFailure.Generic(t.message ?: "", processedThrowable) + } + return@withContext Err(failure) + } + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/api/ApiService.kt b/khalti-android/src/main/java/com/khalti/android/api/ApiService.kt new file mode 100644 index 00000000..e36040b7 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/api/ApiService.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.api + +import com.khalti.android.data.PaymentPayload +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface ApiService { + @POST("epayment/lookup/") + suspend fun verify(@Body body: Map): Response +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/cache/Store.kt b/khalti-android/src/main/java/com/khalti/android/cache/Store.kt new file mode 100644 index 00000000..ef2f7b39 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/cache/Store.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.cache + +@Suppress("UNCHECKED_CAST") +class Store private constructor() { + + companion object { + @Volatile + private var instance: Store? = null + + fun instance(): Store { + return instance ?: synchronized(this) { + instance ?: Store().also { instance = it } + } + } + } + + private val cache = HashMap() + + fun put(key: String, value: Any) { + cache[key] = value + } + + fun get(key: String): T? { + return cache[key] as T? + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/callbacks/OnMessage.kt b/khalti-android/src/main/java/com/khalti/android/callbacks/OnMessage.kt new file mode 100644 index 00000000..e4557012 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/callbacks/OnMessage.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.callbacks + +import com.khalti.android.resource.OnMessagePayload + +fun interface OnMessage { + fun invoke(payload: OnMessagePayload) +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/callbacks/OnPaymentResult.kt b/khalti-android/src/main/java/com/khalti/android/callbacks/OnPaymentResult.kt new file mode 100644 index 00000000..76362315 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/callbacks/OnPaymentResult.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.callbacks + +import com.khalti.android.Khalti +import com.khalti.android.data.PaymentResult + +fun interface OnPaymentResult { + fun invoke(result: PaymentResult, khalti: Khalti) +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/callbacks/OnReturn.kt b/khalti-android/src/main/java/com/khalti/android/callbacks/OnReturn.kt new file mode 100644 index 00000000..6931a2c4 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/callbacks/OnReturn.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.callbacks + +import com.khalti.android.Khalti + +fun interface OnReturn { + fun invoke(khalti: Khalti) +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/composable/Error.kt b/khalti-android/src/main/java/com/khalti/android/composable/Error.kt new file mode 100644 index 00000000..55566138 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/composable/Error.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SignalWifiOff +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.khalti.android.resource.ErrorType +import com.khalti.android.utils.ErrorUtil + +@Composable +fun KhaltiError( + errorType: ErrorType, + title: String? = null, + message: String? = null, + icon: ImageVector? = null, + action: String = "Try Again", + onAction: (() -> Unit)? = null, +) { + val resolvedIcon = + icon ?: if (errorType == ErrorType.network) Icons.Filled.SignalWifiOff else null + + val resolvedTitle = title + ?: if (errorType == ErrorType.network) "Network unavailable" else null + + val resolvedMessage = message ?: when (errorType) { + ErrorType.generic -> ErrorUtil.GENERIC_ERROR + ErrorType.network -> "Make sure your device is connected to the internet and try again" + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (resolvedIcon != null) { + Icon( + modifier = Modifier + .size(56.dp) + .padding(bottom = 24.dp), + imageVector = Icons.Filled.SignalWifiOff, + contentDescription = "No internet" + ) + } + if (!resolvedTitle.isNullOrEmpty()) { + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = resolvedTitle, + style = MaterialTheme.typography.titleLarge + ) + } + Text( + text = resolvedMessage, style = MaterialTheme.typography.bodyLarge + ) + + if (onAction != null) { + Spacer(modifier = Modifier.height(24.dp)) + OutlinedButton(onClick = onAction) { + Text(text = action.uppercase()) + } + } + + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/composable/KProgressDialog.kt b/khalti-android/src/main/java/com/khalti/android/composable/KProgressDialog.kt new file mode 100644 index 00000000..93ee3849 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/composable/KProgressDialog.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +fun KProgressDialog() { + Dialog( + onDismissRequest = { + /*no-op*/ + }, + DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(100.dp) + .background(Color.White, shape = RoundedCornerShape(8.dp)) + ) { + CircularProgressIndicator() + } + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/composable/KhaltiWebview.kt b/khalti-android/src/main/java/com/khalti/android/composable/KhaltiWebview.kt new file mode 100644 index 00000000..cd6a65d2 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/composable/KhaltiWebview.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.composable + +import android.annotation.SuppressLint +import android.net.Uri +import android.util.Log +import android.webkit.WebChromeClient +import android.webkit.WebView +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.khalti.android.data.KhaltiPayConfig +import com.khalti.android.resource.Url +import com.khalti.android.view.EPaymentWebClient + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun KhaltiWebView( + config: KhaltiPayConfig, + onReturnPageLoaded: () -> Unit, + onPageLoaded: () -> Unit, +) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.setSupportZoom(true) + + this.webViewClient = EPaymentWebClient(onReturnPageLoaded) + this.webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + if (newProgress == 100) { + onPageLoaded() + } + } + } + this.clearCache(true) + + val baseUrl = if (config.isProd()) { + Url.BASE_PAYMENT_URL_PROD + } else { + Url.BASE_PAYMENT_URL_STAGING + } + + val paymentUri = + Uri.parse(baseUrl.value).buildUpon().appendQueryParameter("pidx", config.pidx) + + this.loadUrl(paymentUri.toString()) + } + }, + update = { + it.loadUrl("javascript:window.location.reload(true)") + } + ) +} diff --git a/khalti-android/src/main/java/com/khalti/android/data/Environment.kt b/khalti-android/src/main/java/com/khalti/android/data/Environment.kt new file mode 100644 index 00000000..4b9fc411 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/data/Environment.kt @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.data + +enum class Environment { + PROD, TEST +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/data/KhaltiPayConfig.kt b/khalti-android/src/main/java/com/khalti/android/data/KhaltiPayConfig.kt new file mode 100644 index 00000000..8b42db90 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/data/KhaltiPayConfig.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.data + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class KhaltiPayConfig( + val publicKey: String, + val pidx: String, + val returnUrl: Uri, + val openInKhalti: Boolean = true, + val environment: Environment = Environment.PROD, +) : Parcelable { + + fun isProd(): Boolean = environment == Environment.PROD +} diff --git a/khalti-android/src/main/java/com/khalti/android/data/PaymentResult.kt b/khalti-android/src/main/java/com/khalti/android/data/PaymentResult.kt new file mode 100644 index 00000000..da004a0e --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/data/PaymentResult.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.data + +import com.google.gson.annotations.SerializedName + +data class PaymentResult( + val status: String, + val payload: PaymentPayload? = null, + val message: String? = null +) + +data class PaymentPayload( + @SerializedName("pidx") val pidx: String?, + @SerializedName("total_amount") val totalAmount: Long = 0, + @SerializedName("status") val status: String?, + @SerializedName("transaction_id") val transactionId: String?, + @SerializedName("fee") val fee: Long = 0, + @SerializedName("refunded") val refunded: Boolean = false +) { + override fun toString(): String { + return StringBuilder() + .append("pidx: $pidx\n") + .append("totalAmount: $totalAmount\n") + .append("status: $status\n") + .append("transactionId: $transactionId\n") + .append("fee: $fee\n") + .append("refunded: $refunded\n") + .toString() + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/payment/KhaltiPaymentPage.kt b/khalti-android/src/main/java/com/khalti/android/payment/KhaltiPaymentPage.kt new file mode 100644 index 00000000..dc83adf0 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/payment/KhaltiPaymentPage.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.payment + +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Build +import android.webkit.WebView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.khalti.android.Khalti +import com.khalti.android.cache.Store +import com.khalti.android.composable.KProgressDialog +import com.khalti.android.composable.KhaltiError +import com.khalti.android.composable.KhaltiWebView +import com.khalti.android.resource.ErrorType +import com.khalti.android.resource.OnMessageEvent +import com.khalti.android.resource.OnMessagePayload +import com.khalti.android.utils.NetworkUtil + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KhaltiPaymentPage(activity: Activity, viewModel: KhaltiPaymentViewModel) { + val state by viewModel.state.collectAsState() + val recomposeState = mutableStateOf(false) + + Scaffold( + topBar = { + Surface(shadowElevation = 4.dp) { + TopAppBar( + navigationIcon = { + IconButton(onClick = { + onBack() + activity.finish() + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + title = { + Text(text = "Payment Gateway") + }, + actions = { + IconButton(onClick = { + recomposeState.value = !recomposeState.value + }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = "Refresh" + ) + } + }, + ) + } + }, + ) { + Surface(modifier = Modifier.padding(top = it.calculateTopPadding())) { + val khalti = Store.instance().get("khalti") + if (khalti != null) { + + if (state.progressDialog) { + KProgressDialog() + } + + val config = khalti.config + if (state.hasNetwork) { + Box( + Modifier + .fillMaxSize() + ) { + KhaltiWebView( + config = config, + onReturnPageLoaded = { + viewModel.verifyPaymentStatus(khalti) + }, + onPageLoaded = { + viewModel.toggleLoading(false) + }, + ) + if (state.isLoading) { + LinearProgressIndicator( + Modifier + .height(6.dp) + .width(200.dp) + .align(Alignment.Center), + color = Color.Gray + ) + } + } + + } else { + KhaltiError(errorType = ErrorType.network) { + viewModel.toggleNetwork(NetworkUtil.isNetworkAvailable(activity)) + } + } + } + } + + } + + LaunchedEffect(state.hasNetwork && recomposeState.value) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + NetworkUtil.registerListener(activity) { + viewModel.toggleNetwork(it) + } + } + } +} + +fun onBack() { + val khalti = Store.instance().get("khalti") + khalti?.onMessage?.invoke( + OnMessagePayload( + OnMessageEvent.BackPressed, + "User pressed back", + khalti, + needsPaymentConfirmation = true + ) + ) +} diff --git a/khalti-android/src/main/java/com/khalti/android/payment/KhaltiPaymentViewModel.kt b/khalti-android/src/main/java/com/khalti/android/payment/KhaltiPaymentViewModel.kt new file mode 100644 index 00000000..1f2eebe9 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/payment/KhaltiPaymentViewModel.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.payment + +import androidx.lifecycle.ViewModel +import com.khalti.android.Khalti +import com.khalti.android.service.VerificationRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +data class KhaltiPaymentState( + val isLoading: Boolean = true, + val hasNetwork: Boolean = true, + val progressDialog: Boolean = false, +) + +class KhaltiPaymentViewModel : ViewModel() { + private val _state = MutableStateFlow((KhaltiPaymentState())) + val state: StateFlow = _state + + fun verifyPaymentStatus(khalti: Khalti) { + toggleProgressDialog() + val verificationRepo = VerificationRepository() + verificationRepo.verify(khalti.config.pidx, khalti) { + toggleProgressDialog(false) + } + } + + fun toggleNetwork(hasNetwork: Boolean) { + _state.update { it.copy(hasNetwork = hasNetwork) } + } + + + fun toggleLoading(show: Boolean = true) { + _state.update { it.copy(isLoading = show) } + } + + private fun toggleProgressDialog(show: Boolean = true) { + _state.update { it.copy(progressDialog = show) } + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/resource/ErrorType.kt b/khalti-android/src/main/java/com/khalti/android/resource/ErrorType.kt new file mode 100644 index 00000000..df8d9da7 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/resource/ErrorType.kt @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.resource + +enum class ErrorType { + generic, network +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/resource/KFailure.kt b/khalti-android/src/main/java/com/khalti/android/resource/KFailure.kt new file mode 100644 index 00000000..ad29df5a --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/resource/KFailure.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.resource + +abstract class KFailure(failureMessage: String, throwable: Throwable? = null, val code: Number? = null) : + Exception(failureMessage, throwable) { + + class NoNetwork(message: String, cause: Throwable? = null) : KFailure(message, cause) + + class ServerUnreachable(message: String, cause: Throwable? = null) : + KFailure(message, cause) + + class HttpCall(message: String, cause: Throwable? = null, code: Number?) : + KFailure(message, cause, code) + + class Payment(message: String, cause: Throwable? = null, code: Number?) : KFailure(message, cause, code) + + class Generic(message: String, cause: Throwable? = null) : KFailure(message, cause) +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/resource/OnMessageEvent.kt b/khalti-android/src/main/java/com/khalti/android/resource/OnMessageEvent.kt new file mode 100644 index 00000000..32551519 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/resource/OnMessageEvent.kt @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.resource + +enum class OnMessageEvent { + BackPressed, ReturnUrlLoadFailure, NetworkFailure, PaymentLookUpFailure, Unknown +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/resource/OnMessagePayload.kt b/khalti-android/src/main/java/com/khalti/android/resource/OnMessagePayload.kt new file mode 100644 index 00000000..265f0af2 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/resource/OnMessagePayload.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.resource + +import com.khalti.android.Khalti + +data class OnMessagePayload( + val event: OnMessageEvent, + val message: String, + val khalti: Khalti, + val throwable: Throwable? = null, + val code: Number? = null, + val needsPaymentConfirmation: Boolean = false, +) diff --git a/khalti-android/src/main/java/com/khalti/android/resource/Result.kt b/khalti-android/src/main/java/com/khalti/android/resource/Result.kt new file mode 100644 index 00000000..9cbf2736 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/resource/Result.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.resource + +sealed class Result(private val t: T?, private val e: E?) { + + val isOk: Boolean by lazy { + this is Ok + } + + fun match(ok: (T) -> Unit, err: (E) -> Unit) { + if (isOk) { + ok(t!!) + } else { + err(e!!) + } + } +} + +class Ok(t: T) : Result(t, null) + +class Err(e: E) : Result(null, e) diff --git a/khalti-android/src/main/java/com/khalti/android/resource/Strings.kt b/khalti-android/src/main/java/com/khalti/android/resource/Strings.kt new file mode 100644 index 00000000..f73f5113 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/resource/Strings.kt @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.resource + +enum class Strings(val value: String) { + +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/resource/Url.kt b/khalti-android/src/main/java/com/khalti/android/resource/Url.kt new file mode 100644 index 00000000..738a7fb5 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/resource/Url.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.resource + +enum class Url(val value: String) { + BASE_KHALTI_URL_PROD("https://khalti.com/api/v2/"), + BASE_KHALTI_URL_STAGING("https://dev.khalti.com/api/v2/"), + BASE_PAYMENT_URL_PROD("https://pay.khalti.com/"), + BASE_PAYMENT_URL_STAGING("https://test-pay.khalti.com/"), +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/service/VerificationRepository.kt b/khalti-android/src/main/java/com/khalti/android/service/VerificationRepository.kt new file mode 100644 index 00000000..e414a1e5 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/service/VerificationRepository.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.service + +import com.khalti.android.Khalti +import com.khalti.android.data.PaymentResult +import com.khalti.android.resource.KFailure +import com.khalti.android.resource.OnMessageEvent +import com.khalti.android.resource.OnMessagePayload +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class VerificationRepository { + private val verificationService: VerificationService by lazy { + VerificationService() + } + + @OptIn(DelicateCoroutinesApi::class) + fun verify(pidx: String, khalti: Khalti, onComplete: (() -> Unit)? = null) { + GlobalScope.launch { + val result = verificationService.verify(pidx) + onComplete?.invoke() + result.match( + ok = { + khalti.onPaymentResult.invoke( + PaymentResult( + status = it.status ?: "Payment successful", payload = it + ), khalti + ) + }, + err = { + val messageEvent = when (it) { + is KFailure.NoNetwork, is KFailure.ServerUnreachable -> OnMessageEvent.NetworkFailure + is KFailure.HttpCall, is KFailure.Payment -> OnMessageEvent.PaymentLookUpFailure + else -> OnMessageEvent.Unknown + + } + val needsConfirmations = when (it) { + is KFailure.NoNetwork, is KFailure.ServerUnreachable, is KFailure.Generic -> true + else -> false + } + khalti.onMessage.invoke( + OnMessagePayload( + messageEvent, + it.message ?: "", + khalti, + it.cause, + it.code, + needsPaymentConfirmation = needsConfirmations + ) + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/service/VerificationService.kt b/khalti-android/src/main/java/com/khalti/android/service/VerificationService.kt new file mode 100644 index 00000000..6f66941a --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/service/VerificationService.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.service + +import com.khalti.android.api.ApiClient +import com.khalti.android.api.ApiService +import com.khalti.android.api.safeApiCall +import com.khalti.android.resource.KFailure +import com.khalti.android.resource.Result +import com.khalti.android.data.PaymentPayload +import kotlinx.coroutines.Dispatchers + +class VerificationService { + private val apiService: ApiService by lazy { + ApiClient.build() + } + + suspend fun verify(pidx: String): Result { + return safeApiCall(Dispatchers.IO) { + apiService.verify(mapOf("pidx" to pidx)) + } + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/utils/ErrorUtil.kt b/khalti-android/src/main/java/com/khalti/android/utils/ErrorUtil.kt new file mode 100644 index 00000000..1ce74622 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/utils/ErrorUtil.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.utils + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.json.JSONException +import org.json.JSONObject +import java.lang.reflect.Type +import java.util.Locale + + +class ErrorUtil { + companion object { + const val GENERIC_ERROR = "An error occurred, please try again later" + private const val UNAVAILABLE_ERROR = "Service temporarily unavailable" + private const val GENERIC_JSON_ERROR = + "{\"detail\":\"An error occurred, please try again later\"}" + + + fun parseError(json: String, statusCode: String? = null): String { + var errorJson = "" + if (errorJson.isEmpty()) { + errorJson = GENERIC_JSON_ERROR + } + val map = HashMap() + if (!statusCode.isNullOrEmpty() && statusCode == "503") { + map["detail"] = UNAVAILABLE_ERROR + } else { + try { + val root = JSONObject(errorJson) + if (root.has("detail")) { + val type: Type = object : TypeToken?>() {}.type + if (root.has("meta") && root["meta"].toString() + "" != "{}") { + map.putAll( + Gson().fromJson( + JsonUtil.convertToJsonString(root["meta"]), + type + ) + ) + root.remove("meta") + } + root.remove("error_data") //Remove this if error_data is needed + root.remove("meta") + map.putAll(Gson().fromJson(root.toString() + "", type)) + } else { + if (root.has("non_field_error")) { + map["detail"] = + JsonUtil.parseJsonArray(json = root.getString("non_field_error")) + root.remove("non_field_error") + } + if (root.has("error_key")) { + map["error_key"] = root.getString("error_key") + } + val keys: Iterator<*> = root.keys() + while (keys.hasNext()) { + val currentKey = keys.next() as String + if (!currentKey.lowercase(Locale.getDefault()) + .contains("status") && !currentKey.lowercase( + Locale.getDefault() + ).contains("error_key") + ) { + map[currentKey] = JsonUtil.parseJsonArray(currentKey, errorJson) + } + } + } + } catch (e: JSONException) { + e.printStackTrace() + } + if (map.isEmpty()) { + map["detail"] = Companion.GENERIC_ERROR + } + } + if (!statusCode.isNullOrEmpty()) { + map["status"] = statusCode + } + return JsonUtil.convertToJsonString(map) + } + + fun parseThrowableError(throwable: String?, statusCode: String?): String { + val t = (throwable ?: "").lowercase(Locale.getDefault()) + val map = HashMap() + var error = GENERIC_ERROR + + if (t.contains("timed out")) { + error = + "Looks like you have an unstable network at the moment, please try again when network stabilizes" + } else if (t.contains("cannot connect") || t.contains("failed to connect")) { + error = + "Looks like the server is taking too long to respond, please try again later" + } + + if (statusCode != null) { + map["status"] = statusCode + } + + map["detail"] = error + + return JsonUtil.convertToJsonString(map) + } + + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/utils/JsonUtil.kt b/khalti-android/src/main/java/com/khalti/android/utils/JsonUtil.kt new file mode 100644 index 00000000..ba9f4f28 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/utils/JsonUtil.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.utils + +import com.google.gson.GsonBuilder +import com.khalti.android.utils.ErrorUtil.Companion.GENERIC_ERROR +import org.json.JSONException +import org.json.JSONObject + + +class JsonUtil { + companion object { + fun convertToJsonString(o: Any): String { + val gson = GsonBuilder() + .serializeNulls() + .create() + return gson.toJson(o) + } + + + fun parseJsonArray(key: String? = null, json: String): String { + val stringBuilder = StringBuilder() + try { + val jsonObject = JSONObject(json) + if (key != null) { + return parseArray(key, jsonObject) + } + for (k in jsonObject.keys()) { + stringBuilder.append(parseArray(k, jsonObject)) + } + } catch (e: JSONException) { + e.printStackTrace() + return GENERIC_ERROR + } + return stringBuilder.toString() + } + + private fun parseArray(key: String, jsonObject: JSONObject): String { + val stringBuilder = StringBuilder() + val jsonArray = jsonObject.getJSONArray(key) + val responseArray = ArrayList() + val sep = "\n" + + for (i in 0 until jsonArray.length()) { + val response = jsonArray.getString(i) + responseArray.add(response) + } + + for (i in responseArray.indices) { + stringBuilder.append(responseArray[i]).append(sep) + } + + return stringBuilder.toString() + } + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/utils/NetworkUtil.kt b/khalti-android/src/main/java/com/khalti/android/utils/NetworkUtil.kt new file mode 100644 index 00000000..9cd195c8 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/utils/NetworkUtil.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import androidx.annotation.RequiresApi + + +class NetworkUtil { + + companion object { + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + fun registerListener(context: Context, onNetworkChange: (Boolean) -> Unit) { + val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + onNetworkChange(true) + } + + override fun onLost(network: Network) { + super.onLost(network) + onNetworkChange(false) + } + } + + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + connectivityManager.registerDefaultNetworkCallback(networkCallback) + } else { + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .build() + + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + } + } + + @Suppress("DEPRECATION") + fun isNetworkAvailable(context: Context): Boolean { + var result = false + + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val netCapabilities = connectivityManager.activeNetwork ?: return false + val activeNetworkCapability = + connectivityManager.getNetworkCapabilities(netCapabilities) ?: return false + + result = activeNetworkCapability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + activeNetworkCapability.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + activeNetworkCapability.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + } else { + connectivityManager.run { + connectivityManager.activeNetworkInfo?.run { + result = when (type) { + ConnectivityManager.TYPE_WIFI -> true + ConnectivityManager.TYPE_MOBILE -> true + ConnectivityManager.TYPE_ETHERNET -> true + else -> false + } + } + } + } + + return result + } + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/utils/PackageUtil.kt b/khalti-android/src/main/java/com/khalti/android/utils/PackageUtil.kt new file mode 100644 index 00000000..e5bd231c --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/utils/PackageUtil.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.utils + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager + +class PackageUtil { + companion object { + fun doesPackageExist(context: Context, packageName: String) { + getPackageInfo(context, packageName) != null + } + + fun getPackageInfo(context: Context, packageName: String): PackageInfo? { + return try { + context.packageManager.getPackageInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + return null + } + } + } +} \ No newline at end of file diff --git a/khalti-android/src/main/java/com/khalti/android/view/EPaymentWebClient.kt b/khalti-android/src/main/java/com/khalti/android/view/EPaymentWebClient.kt new file mode 100644 index 00000000..1ac6bff0 --- /dev/null +++ b/khalti-android/src/main/java/com/khalti/android/view/EPaymentWebClient.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2024. The Khalti Authors. All rights reserved. + */ + +package com.khalti.android.view + +import android.net.Uri +import android.os.Build +import android.webkit.* +import androidx.annotation.RequiresApi +import com.khalti.android.Khalti +import com.khalti.android.cache.Store +import com.khalti.android.resource.OnMessageEvent +import com.khalti.android.resource.OnMessagePayload + +internal class EPaymentWebClient(val onReturn: () -> Unit) : WebViewClient() { + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): + Boolean = handleUri(request!!.url) + + @SuppressWarnings("deprecation") + @Deprecated("") + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): + Boolean = handleUri(Uri.parse(url)) + + @RequiresApi(Build.VERSION_CODES.M) + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) = handleError(request?.url.toString(), error?.description.toString()) + + @SuppressWarnings("deprecation") + @Deprecated("") + override fun onReceivedError( + view: WebView?, + errorCode: Int, + description: String?, + failingUrl: String? + ) = handleError(failingUrl, description) + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + + val khalti = Store.instance().get("khalti") + val returnUrl = khalti?.config?.returnUrl?.toString() ?: "" + + if (url?.startsWith(returnUrl) != false) { + khalti?.onReturn?.invoke(khalti) + onReturn() + } + } + + private fun handleUri(uri: Uri): Boolean { + // TODO (Ishwor) Handle redirection to Khalti app for setting MPIN + val url = uri.toString() + // MPIN url : /account/transaction_pin + return false + } + + private fun handleError(failingUrl: String?, description: String?) { + val khalti = Store.instance().get("khalti") + if (khalti != null) { + if (description != null) { + if (failingUrl?.startsWith(khalti.config.returnUrl.toString()) != false) { + khalti.onMessage.invoke( + OnMessagePayload( + OnMessageEvent.ReturnUrlLoadFailure, + description, + khalti, + needsPaymentConfirmation = true + ) + ) + } + } + } + } +} diff --git a/metadata.gradle b/metadata.gradle index 83f17d31..ffe972b4 100644 --- a/metadata.gradle +++ b/metadata.gradle @@ -1,8 +1,8 @@ ext { - khaltiVersionCode = 3000000 - khaltiVersionName = '3.00.00' + khaltiVersionCode = 2001003 + khaltiVersionName = '2.01.03' - libraryMinSdk = 16 - libraryCompileSdk = 33 - libraryTargetSdk = 33 + libraryMinSdk = 21 + libraryCompileSdk = 34 + libraryTargetSdk = 34 } \ No newline at end of file diff --git a/scripts/publish-module.gradle b/scripts/publish-module.gradle index ce576f81..b83ea730 100644 --- a/scripts/publish-module.gradle +++ b/scripts/publish-module.gradle @@ -1,7 +1,8 @@ apply plugin: 'maven-publish' apply plugin: 'signing' -task androidSourcesJar(type: Jar) { +tasks.register('androidSourcesJar', Jar) { + dependsOn('generateMetadataFileForKhaltiPublication') archiveClassifier.set('sources') if (project.plugins.findPlugin("com.android.library")) { from android.sourceSets.main.java.srcDirs @@ -22,9 +23,7 @@ version = PUBLISH_VERSION afterEvaluate { publishing { publications { - release(MavenPublication) { - // The coordinates of the library, being set from variables that - // we'll set up later + khalti(MavenPublication) { groupId PUBLISH_GROUP_ID artifactId PUBLISH_ARTIFACT_ID version PUBLISH_VERSION @@ -36,9 +35,8 @@ afterEvaluate { from components.java } - artifact androidSourcesJar +// artifact androidSourcesJar - // Mostly self-explanatory metadata pom { name = PUBLISH_ARTIFACT_ID description = PUBLISH_DESCRIPTION @@ -57,8 +55,6 @@ afterEvaluate { } } - // Version control info - if you're using GitHub, follow the - // format as seen here scm { connection = PUBLISH_SCM_CONNECTION developerConnection = PUBLISH_SCM_DEVELOPER_CONNECTION diff --git a/scripts/publish-root.gradle b/scripts/publish-root.gradle index 2754fb6d..7658270a 100644 --- a/scripts/publish-root.gradle +++ b/scripts/publish-root.gradle @@ -1,7 +1,7 @@ // Create variables with empty default values ext["signing.keyId"] = '' ext["signing.password"] = '' -ext["signing.secretKeyRingFile"] = '' +ext["signing.key"] = '' ext["ossrhUsername"] = '' ext["ossrhPassword"] = '' ext["sonatypeStagingProfileId"] = ''