commit ef756b97a11480598ac85271f918cf4976d0d657 Author: wirabasalamah Date: Wed Apr 22 22:31:52 2026 +0700 Initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58ee6e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +.DS_Store + +# Gradle / Kotlin +.gradle/ +.kotlin/ +build/ +*/build/ + +# Local SDK / machine-specific config +local.properties + +# IDE +.idea/ +*.iml + +# Signing keys +*.jks +*.keystore + +# Generated artifacts +app/release/ +*.apk +*.aab + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a3a9a1 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Emoney Info Android + +Android version of the iOS `Emoney Info` app. + +## Documentation +- Main code documentation: [docs/CODE_DOCUMENTATION.md](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/docs/CODE_DOCUMENTATION.md) + +## Current state +- Kotlin + Jetpack Compose application +- Home, History, Settings, FAQ, About, Terms, and Privacy screens +- NFC readers validated on real devices for Brizzi, Flazz, TapCash, KMT, JackCard, and Mandiri e-Money +- AdMob banner and interstitial units configured +- PDF export flow available from transaction history +- English and Indonesian localization enabled + +## Environment +- Package: `com.korancrew.emoneyinfo` +- minSdk: `28` +- compileSdk: `34` +- Android SDK root expected at `/opt/android-sdk` +- JDK: `17` or `21` + +## Build +Use Temurin 21 on this machine: + +```bash +JAVA_HOME=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home \ +ANDROID_SDK_ROOT=/opt/android-sdk \ +./gradlew assembleDebug +``` + +For a production verification build: + +```bash +JAVA_HOME=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home \ +ANDROID_SDK_ROOT=/opt/android-sdk \ +./gradlew assembleRelease +``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..523557e --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,74 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.iiyh.emoneyinfo" + compileSdk = 36 + compileSdkMinor = 1 + + defaultConfig { + applicationId = "com.iiyh.emoneyinfo" + minSdk = 28 + targetSdk = 36 + versionCode = 2 + versionName = "1.0.0" + + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.06.00") + + implementation("androidx.core:core-ktx:1.18.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation("androidx.activity:activity-compose:1.13.0") + implementation("androidx.navigation:navigation-compose:2.9.7") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("com.google.android.material:material:1.13.0") + implementation(composeBom) + implementation("com.google.android.gms:play-services-ads:25.2.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..eb4aef7 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Intentionally empty for now. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b6c4bc5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..664f0ad Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/iiyh/emoneyinfo/MainActivity.kt b/app/src/main/java/com/iiyh/emoneyinfo/MainActivity.kt new file mode 100644 index 0000000..1a6dc27 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/MainActivity.kt @@ -0,0 +1,83 @@ +package com.iiyh.emoneyinfo + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.ads.MobileAds +import com.google.android.gms.ads.RequestConfiguration +import com.iiyh.emoneyinfo.BuildConfig +import com.iiyh.emoneyinfo.ads.AdMobConfig +import com.iiyh.emoneyinfo.nfc.UnifiedNfcReader +import com.iiyh.emoneyinfo.ui.EmoneyInfoApp +import com.iiyh.emoneyinfo.ui.theme.EmoneyInfoTheme +import com.iiyh.emoneyinfo.util.AppLog +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + private lateinit var nfcReader: UnifiedNfcReader + private val adsEnabled = mutableStateOf(false) + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(R.style.Theme_EmoneyInfo) + super.onCreate(savedInstanceState) + nfcReader = UnifiedNfcReader(this) + nfcReader.refreshStatus() + + setContent { + EmoneyInfoTheme { + EmoneyInfoApp( + nfcReader = nfcReader, + adsEnabled = adsEnabled.value + ) + } + } + + // Give Compose a chance to draw the first frame before the ads SDK starts spinning up WebView. + window.decorView.post { + lifecycleScope.launch { + delay(1200) + initializeAds() + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + nfcReader.onNewIntent(intent) + } + + override fun onResume() { + super.onResume() + nfcReader.refreshStatus() + nfcReader.enableForegroundDispatch(this) + } + + override fun onPause() { + nfcReader.disableForegroundDispatch(this) + super.onPause() + } + + private fun initializeAds() { + if (adsEnabled.value) return + + val requestConfigurationBuilder = RequestConfiguration.Builder() + if (BuildConfig.DEBUG && AdMobConfig.TEST_DEVICE_IDS.isNotEmpty()) { + requestConfigurationBuilder.setTestDeviceIds(AdMobConfig.TEST_DEVICE_IDS) + AppLog.d("EmoneyInfoAds", "Configured debug test devices=${AdMobConfig.TEST_DEVICE_IDS.joinToString()}") + } + MobileAds.setRequestConfiguration(requestConfigurationBuilder.build()) + MobileAds.initialize(this) { initializationStatus -> + initializationStatus.adapterStatusMap.forEach { (name, status) -> + AppLog.d( + "EmoneyInfoAds", + "Adapter=$name state=${status.initializationState} latency=${status.latency} desc=${status.description}" + ) + } + adsEnabled.value = true + } + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ads/AdMobConfig.kt b/app/src/main/java/com/iiyh/emoneyinfo/ads/AdMobConfig.kt new file mode 100644 index 0000000..6739be9 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ads/AdMobConfig.kt @@ -0,0 +1,13 @@ +package com.iiyh.emoneyinfo.ads + +object AdMobConfig { + const val APP_ID = "ca-app-pub-3389368171983845~3596282656" + const val BANNER_HOME = "ca-app-pub-3389368171983845/3971687176" + const val BANNER_SETTINGS = "ca-app-pub-3389368171983845/1794140038" + const val BANNER_HISTORY = "ca-app-pub-3389368171983845/7102307034" + const val INTERSTITIAL_PDF = "ca-app-pub-3389368171983845/7236992223" + val TEST_DEVICE_IDS = listOf( + "33BE2250B43518CCDA7DE426D04EE231", + "463419B65276BB5AEDB52AC2A947CA1C" + ) +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/data/FaqData.kt b/app/src/main/java/com/iiyh/emoneyinfo/data/FaqData.kt new file mode 100644 index 0000000..66d8b64 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/data/FaqData.kt @@ -0,0 +1,48 @@ +package com.iiyh.emoneyinfo.data + +import androidx.annotation.StringRes +import com.iiyh.emoneyinfo.R + +data class FaqItem( + @param:StringRes val questionRes: Int, + @param:StringRes val answerRes: Int +) + +data class FaqCategory( + @param:StringRes val titleRes: Int, + val items: List +) + +object FaqData { + val all = listOf( + FaqCategory( + titleRes = R.string.faq_category_cards, + items = listOf( + FaqItem(R.string.faq_q_supported_cards, R.string.faq_a_supported_cards), + FaqItem(R.string.faq_q_card_not_detected, R.string.faq_a_card_not_detected), + FaqItem(R.string.faq_q_card_read_failed, R.string.faq_a_card_read_failed) + ) + ), + FaqCategory( + titleRes = R.string.faq_category_transactions, + items = listOf( + FaqItem(R.string.faq_q_transactions_not_shown, R.string.faq_a_transactions_not_shown), + FaqItem(R.string.faq_q_export_pdf, R.string.faq_a_export_pdf) + ) + ), + FaqCategory( + titleRes = R.string.faq_category_balance, + items = listOf( + FaqItem(R.string.faq_q_balance_wrong, R.string.faq_a_balance_wrong), + FaqItem(R.string.faq_q_balance_topup, R.string.faq_a_balance_topup) + ) + ), + FaqCategory( + titleRes = R.string.faq_category_app, + items = listOf( + FaqItem(R.string.faq_q_app_language, R.string.faq_a_app_language), + FaqItem(R.string.faq_q_hide_card_number, R.string.faq_a_hide_card_number) + ) + ) + ) +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/data/Models.kt b/app/src/main/java/com/iiyh/emoneyinfo/data/Models.kt new file mode 100644 index 0000000..749a871 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/data/Models.kt @@ -0,0 +1,78 @@ +package com.iiyh.emoneyinfo.data + +import androidx.annotation.StringRes +import com.iiyh.emoneyinfo.R +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.abs + +enum class CardType(@param:StringRes val labelRes: Int) { + UNKNOWN(R.string.card_unknown), + MANDIRI(R.string.card_mandiri), + FLAZZ(R.string.card_flazz), + BRIZZI(R.string.card_brizzi), + TAPCASH(R.string.card_tapcash), + JACKCARD(R.string.card_jackcard), + MEGACASH(R.string.card_megacash), + KMT(R.string.card_kmt) +} + +data class TransactionItem( + val title: String, + val date: Date, + val amount: Long, + val isCredit: Boolean, + val locationName: String = "" +) { + fun formattedAmount(): String { + val formatter = NumberFormat.getCurrencyInstance(Locale.forLanguageTag("id-ID")).apply { + maximumFractionDigits = 0 + currency = java.util.Currency.getInstance("IDR") + } + val raw = formatter.format(abs(amount)).replace("IDR", "Rp") + return if (isCredit) "+$raw" else "-$raw" + } + + fun formattedDate(): String = SimpleDateFormat("dd MMM yyyy · HH:mm", Locale.forLanguageTag("id-ID")).format(date) + + fun subtitle(): String = buildString { + append(formattedDate()) + if (locationName.isNotBlank()) { + append(" · ") + append(locationName) + } + } +} + +data class EmoneyUiState( + val cardType: CardType = CardType.UNKNOWN, + val balance: Long = 0, + val cardNumber: String = "", + val transactions: List = emptyList(), + val scanMessage: String = "", + val isNfcSupported: Boolean = true +) { + fun formattedBalance(): String { + val formatter = NumberFormat.getCurrencyInstance(Locale.forLanguageTag("id-ID")).apply { + maximumFractionDigits = 0 + currency = java.util.Currency.getInstance("IDR") + } + return formatter.format(balance).replace("IDR", "Rp") + } + + fun hasCardData(): Boolean = cardType != CardType.UNKNOWN || cardNumber.isNotBlank() || balance > 0 || transactions.isNotEmpty() +} + +fun String.formatCardNumber(): String { + val digits = filter { it.isDigit() } + if (digits.isEmpty()) return this + return digits.chunked(4).joinToString(" ") +} + +fun String.maskFirst12(): String { + val digits = filter { it.isDigit() } + if (digits.length != 16) return formatCardNumber() + return "**** **** **** ${digits.takeLast(4)}" +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/nfc/AndroidStrings.kt b/app/src/main/java/com/iiyh/emoneyinfo/nfc/AndroidStrings.kt new file mode 100644 index 0000000..e02b208 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/nfc/AndroidStrings.kt @@ -0,0 +1,8 @@ +package com.iiyh.emoneyinfo.nfc + +import android.content.Context +import com.iiyh.emoneyinfo.R + +internal class AndroidStrings(private val context: Context) { + fun get(id: Int, vararg args: Any): String = context.getString(id, *args) +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/nfc/BrizziCrypto.kt b/app/src/main/java/com/iiyh/emoneyinfo/nfc/BrizziCrypto.kt new file mode 100644 index 0000000..1ae48f8 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/nfc/BrizziCrypto.kt @@ -0,0 +1,85 @@ +package com.iiyh.emoneyinfo.nfc + +import java.security.GeneralSecurityException +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +internal object BrizziCrypto { + private const val AUTH_KEY = "0000030080000000" + private const val MASTER_3DES_KEY = "C152153D5807784C721A433B5B59636DC152153D5807784C" + + fun decryptDeSeDe(data: ByteArray): ByteArray = + tripleDesCbc(data, MASTER_3DES_KEY.hexToBytes(), ByteArray(8), Cipher.DECRYPT_MODE) + + fun encryptDeSeDe(inputHex: String, keyHex: String, ivHex: String): ByteArray { + val normalizedKey = when (keyHex.length) { + 48 -> keyHex + 32 -> keyHex + keyHex.take(16) + 16 -> keyHex + keyHex + keyHex + else -> "00000000000000000000000000000000" + } + return tripleDesCbc( + inputHex.hexToBytes(), + normalizedKey.hexToBytes(), + ivHex.hexToBytes(), + Cipher.ENCRYPT_MODE + ) + } + + fun encrypt(hex: String, keyHex: String): ByteArray { + val left = keyHex.take(16) + val right = keyHex.drop(16).take(16) + val firstDecrypt = decrypt(hex, left).toHex() + val secondEncrypt = desCbc(firstDecrypt.hexToBytes(), right.hexToBytes(), Cipher.ENCRYPT_MODE).toHex() + return decrypt(secondEncrypt, left) + } + + fun decrypt(hex: String, keyHex: String): ByteArray = + desCbc(hex.hexToBytes(), keyHex.hexToBytes(), Cipher.DECRYPT_MODE) + + fun mix(left: ByteArray, right: ByteArray): ByteArray { + require(right.isNotEmpty()) { "empty security key" } + return ByteArray(left.size) { index -> + (left[index].toInt() xor right[index % right.size].toInt()).toByte() + } + } + + fun generateSamRandom(keyCardHex: String, randomHex: String): String { + val mixed = mix(encrypt(keyCardHex, randomHex), "0000000000000000".hexToBytes()) + val sam = mixed.toHex().take(16) + val rotated = sam.hexToBytes().rotateLeftBytes(1).toHex() + val result = encrypt( + mix("1122334455667788".hexToBytes(), keyCardHex.hexToBytes()).toHex(), + randomHex + ).toHex().take(16) + + val tail = encrypt( + mix(rotated.hexToBytes(), result.hexToBytes()).toHex(), + randomHex + ).toHex() + + return result + tail + } + + fun authKey(): String = AUTH_KEY + + @Throws(GeneralSecurityException::class) + private fun tripleDesCbc( + input: ByteArray, + key: ByteArray, + iv: ByteArray, + mode: Int + ): ByteArray { + val cipher = Cipher.getInstance("DESede/CBC/NoPadding") + cipher.init(mode, SecretKeySpec(key, "DESede"), IvParameterSpec(iv)) + return cipher.doFinal(input) + } + + @Throws(GeneralSecurityException::class) + private fun desCbc(input: ByteArray, key: ByteArray, mode: Int): ByteArray { + val cipher = Cipher.getInstance("DES/CBC/NoPadding") + cipher.init(mode, SecretKeySpec(key, "DES"), IvParameterSpec(ByteArray(8))) + return cipher.doFinal(input) + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/nfc/CardReaders.kt b/app/src/main/java/com/iiyh/emoneyinfo/nfc/CardReaders.kt new file mode 100644 index 0000000..6ad1342 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/nfc/CardReaders.kt @@ -0,0 +1,861 @@ +package com.iiyh.emoneyinfo.nfc + +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.nfc.tech.NfcF +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.data.CardType +import com.iiyh.emoneyinfo.data.EmoneyUiState +import com.iiyh.emoneyinfo.data.TransactionItem +import com.iiyh.emoneyinfo.util.AppLog +import java.nio.charset.Charset +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +internal interface CardReader { + fun canHandle(tag: Tag): Boolean + fun read(tag: Tag, strings: AndroidStrings): EmoneyUiState +} + +internal class IsoDepCardRouter : CardReader { + override fun canHandle(tag: Tag): Boolean = IsoDep.get(tag) != null + + override fun read(tag: Tag, strings: AndroidStrings): EmoneyUiState { + val isoDep = IsoDep.get(tag) ?: error("IsoDep not available") + isoDep.connect() + isoDep.timeout = 5000 + return try { + BrizziReader.read(isoDep, strings) + ?: MandiriReader.read(isoDep, strings) + ?: FlazzReader.read(isoDep, strings) + ?: TapCashReader.read(isoDep, strings) + ?: JackCardReader.read(isoDep, strings) + ?: MegaCashReader.read(isoDep, strings) + ?: error(strings.get(R.string.card_not_supported)) + } finally { + runCatching { isoDep.close() } + } + } +} + +internal class FelicaCardReader : CardReader { + override fun canHandle(tag: Tag): Boolean = NfcF.get(tag) != null + + override fun read(tag: Tag, strings: AndroidStrings): EmoneyUiState { + val nfcF = NfcF.get(tag) ?: error("NfcF not available") + nfcF.connect() + nfcF.timeout = 5000 + return try { + KmtReader.read(nfcF, strings) + } finally { + runCatching { nfcF.close() } + } + } +} + +private object BrizziReader { + fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? { + AppLog.d("EmoneyInfoBrizzi", "Starting Brizzi detection") + val init = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x5A, + p1 = 0x00, + p2 = 0x00, + data = byteArrayOf(0x01, 0x00, 0x00), + le = 0x00 + ) + AppLog.d("EmoneyInfoBrizzi", "Init status=${"%02X%02X".format(init.sw1, init.sw2)}") + if (!(init.hasStatus(0x91, 0xAF) || init.hasStatus(0x91, 0x00))) return null + + val uid = readBrizziUid(isoDep) + AppLog.d("EmoneyInfoBrizzi", "UID read complete") + val cardNumberResp = listOf( + byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x17, 0x00, 0x00), + byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00) + ).asSequence().map { payload -> + isoDep.transceiveApdu( + cla = 0x90, + ins = 0xBD, + p1 = 0x00, + p2 = 0x00, + data = payload, + le = 0x00 + ) + }.firstOrNull { it.hasStatus(0x91, 0x00) } ?: isoDep.transceiveApdu( + cla = 0x90, + ins = 0xBD, + p1 = 0x00, + p2 = 0x00, + data = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x17, 0x00, 0x00), + le = 0x00 + ) + AppLog.d("EmoneyInfoBrizzi", "Card number status=${"%02X%02X".format(cardNumberResp.sw1, cardNumberResp.sw2)}") + require(cardNumberResp.hasStatus(0x91, 0x00)) { strings.get(R.string.error_brizzi_card_number) } + val cardNumber = cardNumberResp.data.toHex().safeSlice(6, 22).formatCardNumber() + AppLog.d("EmoneyInfoBrizzi", "Card number parsed") + + val process01 = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x5A, + p1 = 0x00, + p2 = 0x00, + data = byteArrayOf(0x03, 0x00, 0x00), + le = 0x00 + ) + AppLog.d("EmoneyInfoBrizzi", "Process01 status=${"%02X%02X".format(process01.sw1, process01.sw2)}") + require(process01.hasStatus(0x91, 0x00)) { strings.get(R.string.error_brizzi_step1) } + + val process02 = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x0A, + p1 = 0x00, + p2 = 0x00, + data = byteArrayOf(0x00), + le = 0x00 + ) + AppLog.d("EmoneyInfoBrizzi", "Process02 status=${"%02X%02X".format(process02.sw1, process02.sw2)}") + require(process02.hasStatus(0x91, 0x00) || process02.hasStatus(0x91, 0xAF)) { + strings.get(R.string.error_brizzi_step2) + } + + val keyCard = process02.data.toHex() + val decrypted = BrizziCrypto.decryptDeSeDe("8DC0DC40FE1DC582CF7099E2AACFBC10".hexToBytes()).toHex().take(32) + val encryptedKey = BrizziCrypto.encryptDeSeDe( + inputHex = cardNumber.replace(" ", "") + uid + "FF", + keyHex = decrypted, + ivHex = "0000000000000000" + ).toHex().take(32) + + val random = BrizziCrypto.encryptDeSeDe( + inputHex = encryptedKey, + keyHex = BrizziCrypto.decryptDeSeDe("3C37029CA595FE4E7E62FCB2F7909B2C".hexToBytes()).toHex().take(32), + ivHex = BrizziCrypto.authKey() + ).toHex().take(32) + + val samChallenge = BrizziCrypto.generateSamRandom(keyCard, random).take(32) + AppLog.d("EmoneyInfoBrizzi", "Generated Brizzi challenge") + val process03 = isoDep.transceiveApdu( + cla = 0x90, + ins = 0xAF, + p1 = 0x00, + p2 = 0x00, + data = samChallenge.hexToBytes(), + le = 0x00 + ) + AppLog.d("EmoneyInfoBrizzi", "Process03 status=${"%02X%02X".format(process03.sw1, process03.sw2)}") + require(process03.hasStatus(0x91, 0x00) || process03.hasStatus(0x91, 0xAF)) { + strings.get(R.string.error_brizzi_step3) + } + + val balanceResp = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x6C, + p1 = 0x00, + p2 = 0x00, + data = byteArrayOf(0x00), + le = 0x00 + ) + AppLog.d("EmoneyInfoBrizzi", "Balance status=${"%02X%02X".format(balanceResp.sw1, balanceResp.sw2)}") + require(balanceResp.hasStatus(0x91, 0x00) || balanceResp.hasStatus(0x91, 0xAF)) { + strings.get(R.string.error_brizzi_balance) + } + val balance = balanceResp.data.toHex().safeSlice(0, 8).reverseByteOrderHex().hexToLong() + AppLog.d("EmoneyInfoBrizzi", "Balance parsed") + + val logStart = isoDep.transceiveApdu( + cla = 0x90, + ins = 0xBB, + p1 = 0x00, + p2 = 0x00, + data = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + le = 0x00 + ) + AppLog.d("EmoneyInfoBrizzi", "Log start status=${"%02X%02X".format(logStart.sw1, logStart.sw2)}") + + val rawLog = StringBuilder() + if (logStart.hasStatus(0x91, 0x00) || logStart.hasStatus(0x91, 0xAF)) { + rawLog.append(logStart.data.toHex()) + while (true) { + val more = isoDep.transceiveApdu(cla = 0x90, ins = 0xAF, p1 = 0x00, p2 = 0x00, le = 0x00) + rawLog.append(more.data.toHex()) + if (!more.hasStatus(0x91, 0xAF)) break + } + } + + val transactions = parseBrizziLogs(rawLog.toString(), strings) + AppLog.d("EmoneyInfoBrizzi", "Parsed transactions=${transactions.size}") + return EmoneyUiState( + cardType = CardType.BRIZZI, + cardNumber = cardNumber, + balance = balance, + transactions = transactions.sortedByDescending { it.date }, + scanMessage = if (transactions.isEmpty()) { + strings.get(R.string.scan_brizzi_success) + } else { + strings.get(R.string.scan_brizzi_history_success) + } + ) + } + + private fun readBrizziUid(isoDep: IsoDep): String { + val first = isoDep.transceiveApdu(cla = 0x90, ins = 0x60, p1 = 0x00, p2 = 0x00, le = 0x00) + AppLog.d("EmoneyInfoBrizzi", "UID step1 status=${"%02X%02X".format(first.sw1, first.sw2)}") + require(first.hasStatus(0x91, 0xAF)) { "Failed to start Brizzi UID read" } + while (true) { + val next = isoDep.transceiveApdu(cla = 0x90, ins = 0xAF, p1 = 0x00, p2 = 0x00, le = 0x00) + AppLog.d("EmoneyInfoBrizzi", "UID next step status=${"%02X%02X".format(next.sw1, next.sw2)}") + if (next.hasStatus(0x91, 0xAF)) continue + return next.data.toHex().take(14) + } + } + + private fun parseBrizziLogs(logs: String, strings: AndroidStrings): List { + if (logs.isBlank() || logs.length % 64 != 0) return emptyList() + return buildList { + for (offset in logs.indices step 64) { + val chunk = logs.substring(offset, offset + 64) + val date = parseDdmmyyHhmmss( + datePart = chunk.substring(32, 38), + timePart = chunk.substring(38, 44) + ) ?: continue + val code = chunk.substring(44, 46).uppercase(Locale.US) + val title = when (code) { + "5F" -> strings.get(R.string.tx_reactivation) + "EB" -> strings.get(R.string.payment) + "EC" -> strings.get(R.string.topup) + "ED" -> strings.get(R.string.tx_void) + "EF" -> strings.get(R.string.tx_update_balance) + else -> strings.get(R.string.tx_transaction) + } + val isCredit = code == "EC" || code == "ED" + add( + TransactionItem( + title = title, + date = date, + amount = chunk.substring(46, 52).reverseByteOrderHex().hexToLong(), + isCredit = isCredit + ) + ) + } + } + } +} + +private object FlazzReader { + private val selectDfAid = "A0000000180F0000018001".hexToBytes() + + fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? { + AppLog.d("EmoneyInfoFlazz", "Starting IsoDep Flazz detection") + val select = isoDep.transceiveSelect(selectDfAid) + AppLog.d("EmoneyInfoFlazz", "Select DF status=${"%02X%02X".format(select.sw1, select.sw2)}") + val fallbackSelect = if (!select.isSuccess()) { + isoDep.transceiveApdu( + cla = 0x00, + ins = 0xA4, + p1 = 0x01, + p2 = 0x00, + data = byteArrayOf(0x02, 0x00), + le = 0x00 + ) + } else { + select + } + AppLog.d("EmoneyInfoFlazz", "Fallback select status=${"%02X%02X".format(fallbackSelect.sw1, fallbackSelect.sw2)}") + if (!fallbackSelect.isSuccess()) return null + + val cardInfo = isoDep.transceiveApdu( + cla = 0x00, + ins = 0xB0, + p1 = 0x81, + p2 = 0x00, + le = 0x8E + ) + AppLog.d("EmoneyInfoFlazz", "Card info status=${"%02X%02X".format(cardInfo.sw1, cardInfo.sw2)}") + val cardNumber = if (cardInfo.isSuccess()) { + cardInfo.data.toString(Charset.forName("ISO-8859-1")) + .substringAfter(';', "") + .substringBefore('=', "") + .trim() + } else { + "" + } + + val balanceResp = isoDep.transceiveApdu( + cla = 0x80, + ins = 0x32, + p1 = 0x00, + p2 = 0x03, + data = byteArrayOf(0x00, 0x00, 0x00, 0x00), + le = 0x00 + ) + AppLog.d("EmoneyInfoFlazz", "Balance status=${"%02X%02X".format(balanceResp.sw1, balanceResp.sw2)}") + val balance = if (balanceResp.isSuccess() && balanceResp.data.size >= 4) { + balanceResp.data.toHex().substring(2, 8).hexToLong() + } else { + 0L + } + + val transactions = if (cardInfo.isSuccess()) { + readFlazzHistory(isoDep, strings) + } else { + emptyList() + } + + return EmoneyUiState( + cardType = CardType.FLAZZ, + cardNumber = cardNumber.formatCardNumber(), + balance = balance, + transactions = transactions.sortedByDescending { it.date }, + scanMessage = if (transactions.isEmpty()) { + strings.get(R.string.scan_flazz_success) + } else { + strings.get(R.string.scan_flazz_history_success) + } + ) + } + + private fun readFlazzHistory(isoDep: IsoDep, strings: AndroidStrings): List { + val logCheck = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x81, p2 = 0x00, le = 0x00) + return if (logCheck.isSuccess()) { + readV2History(isoDep, strings) + } else { + readV1History(isoDep, strings) + } + } + + private fun readV1History(isoDep: IsoDep, strings: AndroidStrings): List { + val part1 = StringBuilder() + + for (index in 0 until 16) { + val offset = index * 15 + val first = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x84, p2 = offset, le = 60) + if (!first.isSuccess()) break + part1.append(first.data.toHex()) + } + for (index in 0 until 16) { + val offset = index * 15 + val second = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x85, p2 = offset, le = 60) + if (!second.isSuccess()) break + part1.append(second.data.toHex()) + } + return parseFlazz120Logs(part1.toString(), strings) + } + + private fun readV2History(isoDep: IsoDep, strings: AndroidStrings): List { + val mapV1 = StringBuilder() + val mapV2 = StringBuilder() + + for (index in 0 until 5) { + val offset = index * 60 + val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x85, p2 = offset, le = 120) + if (!resp.isSuccess()) break + mapV1.append(resp.data.toHex()) + } + for (index in 0 until 5) { + val offset = index * 60 + val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x84, p2 = offset, le = 240) + if (!resp.isSuccess()) break + mapV1.append(resp.data.toHex()) + } + + val random = isoDep.transceiveApdu(cla = 0x00, ins = 0x84, p1 = 0x00, p2 = 0x00, le = 8) + if (random.isSuccess()) { + val auth = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x32, + p1 = 0x03, + p2 = 0x00, + data = ("0801" + random.data.toHex()).hexToBytes(), + le = 41 + ) + if (auth.isSuccess()) { + for (index in 0 until 256) { + val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x89, p2 = index, le = 64) + if (!resp.isSuccess()) break + mapV2.append(resp.data.toHex()) + } + for (index in 0 until 256) { + val resp = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x32, + p1 = 0x03, + p2 = 0x00, + data = byteArrayOf(index.toByte()), + le = 32 + ) + if (!resp.isSuccess()) break + mapV2.append(resp.data.toHex()) + } + } + } + + return (parseFlazz120Logs(mapV1.toString(), strings) + parseFlazz64Logs(mapV2.toString(), strings)) + } + + private fun parseFlazz120Logs(logs: String, strings: AndroidStrings): List { + if (logs.isBlank() || logs.length % 120 != 0) return emptyList() + return buildList { + for (offset in logs.indices step 120) { + val chunk = logs.substring(offset, offset + 120) + val transactionTime = chunk.substring(76, 84).hexToLong() + if (transactionTime <= 0) continue + val amount = chunk.substring(12, 18).hexToLong() + val type = chunk.substring(0, 4).hexToLong() + add( + TransactionItem( + title = if (type == 1024L) strings.get(R.string.payment) else strings.get(R.string.topup), + date = flazzSecondsFrom1980(transactionTime), + amount = amount, + isCredit = type != 1024L + ) + ) + } + } + } + + private fun parseFlazz64Logs(logs: String, strings: AndroidStrings): List { + if (logs.isBlank() || logs.length % 64 != 0) return emptyList() + return buildList { + for (offset in logs.indices step 64) { + val chunk = logs.substring(offset, offset + 64) + val transactionTime = chunk.substring(8, 16).hexToLong() + if (transactionTime <= 0) continue + val type = chunk.substring(0, 2).hexToLong() + val rawAmount = chunk.substring(2, 8).hexToLong() + val amount = if (type == 4L) 16777216L - rawAmount else rawAmount + add( + TransactionItem( + title = if (type == 4L) strings.get(R.string.payment) else strings.get(R.string.topup), + date = flazzSecondsFrom1980(transactionTime), + amount = amount, + isCredit = type != 4L + ) + ) + } + } + } +} + +private object TapCashReader { + private val aid = "A000424E49100001".hexToBytes() + + fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? { + val select = isoDep.transceiveSelect(aid) + if (!select.isSuccess()) return null + + val purse = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x32, + p1 = 0x03, + p2 = 0x00, + le = 0x00 + ) + if (!purse.isSuccess() || purse.data.size < 64) { + return EmoneyUiState( + cardType = CardType.TAPCASH, + scanMessage = strings.get(R.string.scan_tapcash_detected_partial) + ) + } + + val balance = purse.data.copyOfRange(2, 5).toHex().hexToLong() + val cardNumber = purse.data.copyOfRange(8, 16).toHex().formatCardNumber() + val totalRecords = purse.data.getOrNull(40)?.toInt()?.and(0xFF)?.coerceAtMost(10) ?: 0 + val history = mutableListOf() + + for (index in 0 until totalRecords) { + val resp = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x32, + p1 = 0x03, + p2 = 0x00, + data = byteArrayOf(index.toByte()), + le = 0x10 + ) + if (!resp.isSuccess() || resp.data.size < 8) continue + parseTapCashRecord(resp.data, strings)?.let(history::add) + } + + return EmoneyUiState( + cardType = CardType.TAPCASH, + cardNumber = cardNumber, + balance = balance, + transactions = history.sortedByDescending { it.date }, + scanMessage = if (history.isEmpty()) { + strings.get(R.string.scan_tapcash_success) + } else { + strings.get(R.string.scan_tapcash_history_success) + } + ) + } + + private fun parseTapCashRecord(data: ByteArray, strings: AndroidStrings): TransactionItem? { + if (data.size < 8) return null + val header = data.copyOfRange(0, 1).toHex().uppercase(Locale.US) + val amountBytes = data.copyOfRange(1, 4).toHex() + val amount = when (header) { + "01", "05", "07", "10", "20" -> amountBytes.twosComplementHexToLong() + else -> amountBytes.hexToLong() + } + val title = when (header) { + "01" -> strings.get(R.string.payment) + "02" -> strings.get(R.string.tx_black_list_card) + "03", "04" -> strings.get(R.string.topup) + "05" -> strings.get(R.string.tx_statement_fee) + "06" -> strings.get(R.string.tx_update_balance) + "07" -> strings.get(R.string.tx_grace_period) + "10", "20" -> strings.get(R.string.tx_refund) + "22" -> strings.get(R.string.tx_close) + "F0" -> strings.get(R.string.tx_atu) + else -> strings.get(R.string.tx_transaction) + } + val processType = when (header) { + "03", "04" -> true + else -> false + } + val date = julianSecondsFrom1995(data.copyOfRange(4, 8).toHex().hexToLong()) + + return TransactionItem( + title = title, + date = date, + amount = amount, + isCredit = processType + ) + } +} + +private object MandiriReader { + private val aid = "0000000000000001".hexToBytes() + + fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? { + AppLog.d("EmoneyInfoMandiri", "Starting Mandiri detection") + val select = isoDep.transceiveSelect(aid) + AppLog.d("EmoneyInfoMandiri", "Select status=${"%02X%02X".format(select.sw1, select.sw2)}") + if (!select.isSuccess()) return null + + val cardResp = isoDep.transceiveApdu(0x00, 0xB3, 0x00, 0x00, le = 0x3F) + AppLog.d("EmoneyInfoMandiri", "Card response status=${"%02X%02X".format(cardResp.sw1, cardResp.sw2)}") + if (!cardResp.isSuccess()) error(strings.get(R.string.error_mandiri_card_number)) + val cardHex = cardResp.data.toHex() + val cardNumber = cardHex.safeSlice(0, 16).formatCardNumber() + val cardType = cardHex.safeSlice(36, 38).hexToIntOrZero() + AppLog.d("EmoneyInfoMandiri", "Card data parsed type=$cardType") + + val balanceResp = isoDep.transceiveApdu(0x00, 0xB5, 0x00, 0x00, le = 0x0A) + AppLog.d("EmoneyInfoMandiri", "Balance status=${"%02X%02X".format(balanceResp.sw1, balanceResp.sw2)}") + if (!balanceResp.isSuccess()) error(strings.get(R.string.error_mandiri_balance)) + val balance = balanceResp.data.toHex().safeSlice(0, 8).reverseByteOrderHex().hexToLong() + + val transactions = if (cardType == 131) { + readNewLogs(isoDep, strings) + } else { + readOldLogs(isoDep, strings) + } + AppLog.d("EmoneyInfoMandiri", "Transactions=${transactions.size}") + + return EmoneyUiState( + cardType = CardType.MANDIRI, + cardNumber = cardNumber, + balance = balance, + transactions = transactions.sortedByDescending { it.date }, + scanMessage = if (cardType == 131) { + strings.get(R.string.scan_mandiri_history_success) + } else if (transactions.isNotEmpty()) { + strings.get(R.string.scan_mandiri_history_success) + } else { + strings.get(R.string.scan_mandiri_success) + } + ) + } + + private fun readNewLogs(isoDep: IsoDep, strings: AndroidStrings): List { + val raw = StringBuilder() + for (index in 0 until 256) { + val resp = isoDep.transceiveApdu( + cla = 0x00, + ins = 0xD1, + p1 = index and 0xFF, + p2 = 0x00, + le = 0x00 + ) + if (!resp.isSuccess()) break + raw.append(resp.data.toHex()) + } + + val logs = raw.toString() + if (logs.isEmpty() || logs.length % 48 != 0) return emptyList() + + return buildList { + for (offset in logs.indices step 48) { + val chunk = logs.substring(offset, offset + 48) + val date = parseDdmmyyHhmmss( + datePart = chunk.substring(0, 6), + timePart = chunk.substring(6, 12) + ) ?: continue + val processType = chunk.substring(28, 32).toIntOrNull() ?: 0 + val amount = chunk.substring(32, 40).reverseByteOrderHex().hexToLong() + add( + TransactionItem( + title = if (processType == 100) strings.get(R.string.topup) else strings.get(R.string.payment), + date = date, + amount = amount, + isCredit = processType == 100 + ) + ) + } + } + } + + private fun readOldLogs(isoDep: IsoDep, strings: AndroidStrings): List { + val raw = StringBuilder() + for (index in 0 until 10) { + val resp = isoDep.transceiveApdu( + cla = 0x00, + ins = 0xB2, + p1 = index and 0xFF, + p2 = 0x00, + le = 0x1E + ) + AppLog.d( + "EmoneyInfoMandiri", + "Old log index=$index status=${"%02X%02X".format(resp.sw1, resp.sw2)}" + ) + if (!resp.isSuccess()) break + raw.append(resp.data.toHex()) + } + + val logs = raw.toString() + if (logs.isEmpty() || logs.length % 60 != 0) return emptyList() + + return buildList { + for (offset in logs.indices step 60) { + val chunk = logs.substring(offset, offset + 60) + parseOldMandiriLog(chunk, strings)?.let(::add) + } + } + } + + private fun parseOldMandiriLog(chunk: String, strings: AndroidStrings): TransactionItem? { + if (chunk.length < 60) return null + val bytes = chunk.hexToBytes() + if (bytes.size < 30) return null + + val timestampBytes = bytes.copyOfRange(0, 6) + val amountBytes = bytes.copyOfRange(10, 14) + val detailBytes = bytes.copyOfRange(18, 30) + + val calendar = Calendar.getInstance(TimeZone.getTimeZone("Asia/Jakarta")).apply { + set( + 2000 + (timestampBytes[0].toInt() and 0xFF), + ((timestampBytes[1].toInt() and 0xFF) - 1).coerceAtLeast(0), + (timestampBytes[2].toInt() and 0xFF).coerceAtLeast(1), + timestampBytes[3].toInt() and 0xFF, + timestampBytes[4].toInt() and 0xFF, + timestampBytes[5].toInt() and 0xFF + ) + set(Calendar.MILLISECOND, 0) + } + + val amount = amountBytes.littleEndianLong() + val descriptor = detailBytes.toHex() + val isCredit = descriptor.contains("64") + val title = if (isCredit) strings.get(R.string.topup) else strings.get(R.string.payment) + + return TransactionItem( + title = title, + date = calendar.time, + amount = amount, + isCredit = isCredit + ) + } +} + +private object JackCardReader { + private val aid = "A0000005714E4A43".hexToBytes() + + fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? { + val select = isoDep.transceiveSelect(aid) + if (!select.isSuccess()) return null + + val cardNumber = select.data.toHex().safeSlice(16, 32).formatCardNumber() + val balanceResp = isoDep.transceiveApdu(0x90, 0x4C, 0x00, 0x00, le = 0x04) + val balance = if (balanceResp.isSuccess()) balanceResp.data.toHex().hexToLong() else 0L + + return EmoneyUiState( + cardType = CardType.JACKCARD, + cardNumber = cardNumber, + balance = balance, + scanMessage = strings.get(R.string.scan_jackcard_success) + ) + } +} + +private object MegaCashReader { + fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? { + val init = isoDep.transceiveApdu( + cla = 0x90, + ins = 0xBD, + p1 = 0x00, + p2 = 0x00, + data = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00), + le = 0x00 + ) + if (!init.isSuccess()) return null + + val cardNumber = init.data.toHex().drop(4).formatCardNumber() + val balanceResp = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x6C, + p1 = 0x00, + p2 = 0x00, + data = byteArrayOf(0x02), + le = 0x00 + ) + val balance = if (balanceResp.isSuccess()) { + balanceResp.data.toHex().reverseByteOrderHex().hexToLong() + } else { + 0L + } + + return EmoneyUiState( + cardType = CardType.MEGACASH, + cardNumber = cardNumber, + balance = balance, + scanMessage = strings.get(R.string.scan_megacash_success) + ) + } +} + +private object KmtReader { + private val stationMap = mapOf( + 0 to "PARKIR RESKA", + 1 to "Tanah Abang", + 67 to "C-Access", + 257 to "Bogor", + 258 to "Cilebut", + 259 to "Bojonggede", + 260 to "Citayam", + 261 to "Depok", + 262 to "Depok Baru", + 263 to "Univ. Indonesia", + 264 to "Univ. Indonesia", + 265 to "Univ. Pancasila", + 272 to "Lenteng Agung", + 273 to "Tanjung Barat", + 274 to "Pasar Minggu", + 275 to "Pasar Minggu Baru", + 276 to "Duren Kalibata", + 277 to "Cawang", + 278 to "Tebet", + 279 to "Manggarai", + 280 to "Cikini", + 281 to "Gondangdia", + 288 to "Juanda", + 289 to "Sawah Besar", + 290 to "Mangga Besar", + 291 to "Jayakarta", + 292 to "Jakarta Kota", + 293 to "Bekasi", + 294 to "Kranji", + 295 to "Cakung", + 296 to "Klender Baru", + 297 to "Buaran", + 304 to "Klender", + 305 to "Jatinegara", + 313 to "Tangerang", + 327 to "Karet", + 328 to "Sudirman", + 329 to "Tanah Abang", + 336 to "Palmerah", + 337 to "Kebayoran", + 338 to "Pondok Ranji", + 339 to "Jurang Mangu", + 340 to "Sudimara", + 341 to "Rawabuntu", + 342 to "Serpong", + 343 to "Cisauk", + 344 to "Cicayur", + 345 to "Parung Panjang", + 352 to "Cilejit", + 353 to "Daru", + 354 to "Tenjo", + 355 to "Tigaraksa", + 356 to "Maja", + 357 to "Citeras", + 358 to "Rangkasbitung", + 374 to "Bekasi Timur", + 376 to "Cikarang" + ) + + fun read(nfcF: NfcF, strings: AndroidStrings): EmoneyUiState { + val cardBlock = nfcF.readWithoutEncryption( + serviceCode = byteArrayOf(0x0B, 0x30), + blockNumbers = listOf(0) + ).firstOrNull() ?: error("Failed to read KMT card number") + val cardNumber = cardBlock.toString(Charsets.UTF_8).trim('\u0000', ' ').ifBlank { "KMT" } + + val balanceBlock = nfcF.readWithoutEncryption( + serviceCode = byteArrayOf(0x17, 0x10), + blockNumbers = listOf(0) + ).firstOrNull() ?: error("Failed to read KMT balance") + val balance = balanceBlock.copyOfRange(0, 4).littleEndianLong() + + val historyBlocks = nfcF.readWithoutEncryption( + serviceCode = byteArrayOf(0x0F, 0x20), + blockNumbers = (0 until 15).toList() + ) + val transactions = historyBlocks.mapNotNull { parseHistoryBlock(it, strings) }.sortedByDescending { it.date } + + return EmoneyUiState( + cardType = CardType.KMT, + cardNumber = cardNumber, + balance = balance, + transactions = transactions, + scanMessage = strings.get(R.string.scan_kmt_history_success) + ) + } + + private fun parseHistoryBlock(data: ByteArray, strings: AndroidStrings): TransactionItem? { + if (data.size < 16) return null + val stationId = data.copyOfRange(8, 10).bigEndianLong().toInt() + val type = data[10].toInt() and 0xFF + val isParking = stationId == 0 + var isCredit = type == 0x00 + val title = when (type) { + 0x00, 0x03 -> strings.get(R.string.topup) + 0x01 -> strings.get(R.string.payment) + else -> strings.get(R.string.payment) + } + if (type == 0x03){ + isCredit = true + } + val amount = if (isParking) { + data.copyOfRange(8, 12).bigEndianLong() + } else { + data.copyOfRange(4, 8).bigEndianLong() + } + val date = if (isParking) { + parseReskaDate(data) + } else { + kmtSecondsFrom2000(data.copyOfRange(0, 4).bigEndianLong()) + } + val location = stationMap[stationId]?.uppercase(Locale.getDefault()).orEmpty() + + return TransactionItem( + title = title, + date = date, + amount = amount, + isCredit = isCredit, + locationName = location + ) + } + + private fun parseReskaDate(data: ByteArray): Date { + val first16 = data.toHex().take(16) + return runCatching { + SimpleDateFormat("ddMMyyyyHHmmssSS", Locale.US).parse(first16) + }.getOrNull() ?: Date() + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/nfc/NfcUtils.kt b/app/src/main/java/com/iiyh/emoneyinfo/nfc/NfcUtils.kt new file mode 100644 index 0000000..b65025b --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/nfc/NfcUtils.kt @@ -0,0 +1,192 @@ +package com.iiyh.emoneyinfo.nfc + +import android.nfc.tech.IsoDep +import android.nfc.tech.NfcF +import com.iiyh.emoneyinfo.util.AppLog +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +private const val NFC_LOG_TAG = "EmoneyInfoNfc" + +internal data class ApduResponse( + val data: ByteArray, + val sw1: Int, + val sw2: Int +) { + fun isSuccess(): Boolean = sw1 == 0x90 && sw2 == 0x00 + fun hasStatus(sw1: Int, sw2: Int): Boolean = this.sw1 == sw1 && this.sw2 == sw2 +} + +internal fun IsoDep.transceiveSelect(aid: ByteArray): ApduResponse = + transceiveApdu(cla = 0x00, ins = 0xA4, p1 = 0x04, p2 = 0x00, data = aid) + +internal fun IsoDep.transceiveApdu( + cla: Int, + ins: Int, + p1: Int, + p2: Int, + data: ByteArray? = null, + le: Int? = null +): ApduResponse { + val apdu = mutableListOf( + cla.toByte(), + ins.toByte(), + p1.toByte(), + p2.toByte() + ) + if (data != null) { + apdu += data.size.toByte() + apdu += data.toList() + } + if (le != null) { + apdu += if (le == 256) 0x00 else (le and 0xFF).toByte() + } + + val requestBytes = apdu.toByteArray() + AppLog.d( + NFC_LOG_TAG, + "APDU -> cla=%02X ins=%02X p1=%02X p2=%02X lc=%d le=%s data=%s".format( + cla and 0xFF, + ins and 0xFF, + p1 and 0xFF, + p2 and 0xFF, + data?.size ?: 0, + le?.toString() ?: "-", + data?.toHex() ?: "" + ) + ) + val response = try { + transceive(requestBytes) + } catch (error: Throwable) { + AppLog.e( + NFC_LOG_TAG, + "APDU !! cla=%02X ins=%02X failed: %s".format( + cla and 0xFF, + ins and 0xFF, + error.message ?: error.javaClass.simpleName + ), + error + ) + throw error + } + require(response.size >= 2) { "Invalid APDU response" } + val parsed = ApduResponse( + data = response.copyOf(response.size - 2), + sw1 = response[response.size - 2].toInt() and 0xFF, + sw2 = response[response.size - 1].toInt() and 0xFF + ) + AppLog.d( + NFC_LOG_TAG, + "APDU <- sw=%02X%02X data=%s".format(parsed.sw1, parsed.sw2, parsed.data.toHex()) + ) + return parsed +} + +internal fun NfcF.readWithoutEncryption( + serviceCode: ByteArray, + blockNumbers: List +): List { + val packet = mutableListOf() + packet += 0x00 + packet += 0x06 + packet += tag.id.toList() + packet += 0x01 + packet += serviceCode.toList() + packet += blockNumbers.size.toByte() + blockNumbers.forEach { blockNo -> + packet += 0x80.toByte() + packet += blockNo.toByte() + } + packet[0] = packet.size.toByte() + + val response = transceive(packet.toByteArray()) + require(response.size >= 13) { "Invalid FeliCa response" } + val status1 = response[10].toInt() and 0xFF + val status2 = response[11].toInt() and 0xFF + require(status1 == 0x00 && status2 == 0x00) { + "FeliCa status error: $status1/$status2" + } + val blockCount = response[12].toInt() and 0xFF + var offset = 13 + return buildList { + repeat(blockCount) { + add(response.copyOfRange(offset, offset + 16)) + offset += 16 + } + } +} + +internal fun ByteArray.toHex(): String = joinToString("") { "%02X".format(it) } + +internal fun String.hexToBytes(): ByteArray { + require(length % 2 == 0) { "Hex string must have even length" } + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} + +internal fun String.hexToLong(): Long = if (isBlank()) 0 else toLong(16) + +internal fun String.hexToIntOrZero(): Int = toIntOrNull(16) ?: 0 + +internal fun String.reverseByteOrderHex(): String = + chunked(2).reversed().joinToString("") + +internal fun String.safeSlice(start: Int, end: Int): String { + if (length <= start) return "" + return substring(start, minOf(end, length)) +} + +internal fun String.formatCardNumber(): String = + chunked(4).joinToString(" ").trim() + +internal fun ByteArray.rotateLeftBytes(count: Int): ByteArray { + if (isEmpty()) return this + val shift = count % size + return copyOfRange(shift, size) + copyOfRange(0, shift) +} + +internal fun String.twosComplementHexToLong(): Long { + val bits = length * 4 + val value = hexToLong() + val signBit = 1L shl (bits - 1) + return if ((value and signBit) == 0L) value else value - (1L shl bits) +} + +internal fun ByteArray.bigEndianLong(): Long = + fold(0L) { acc, byte -> (acc shl 8) or (byte.toInt() and 0xFF).toLong() } + +internal fun ByteArray.littleEndianLong(): Long = + reversedArray().bigEndianLong() + +internal fun parseDdmmyyHhmmss(datePart: String, timePart: String): Date? { + return runCatching { + val raw = datePart + timePart + java.text.SimpleDateFormat("ddMMyyHHmmss", Locale.US).parse(raw) + }.getOrNull() +} + +internal fun julianSecondsFrom1995(seconds: Long): Date { + val calendar = Calendar.getInstance().apply { + timeZone = TimeZone.getTimeZone("UTC") + set(1995, Calendar.JANUARY, 1, 0, 0, 0) + set(Calendar.MILLISECOND, 0) + } + return Date(calendar.timeInMillis + (seconds * 1000)) +} + +internal fun kmtSecondsFrom2000(seconds: Long): Date { + val calendar = Calendar.getInstance(TimeZone.getTimeZone("Asia/Jakarta")).apply { + set(2000, Calendar.JANUARY, 1, 7, 0, 0) + set(Calendar.MILLISECOND, 0) + } + return Date(calendar.timeInMillis + (seconds * 1000)) +} + +internal fun flazzSecondsFrom1980(seconds: Long): Date { + val calendar = Calendar.getInstance(TimeZone.getTimeZone("Asia/Jakarta")).apply { + set(1980, Calendar.JANUARY, 1, 0, 0, 0) + set(Calendar.MILLISECOND, 0) + } + return Date(calendar.timeInMillis + (seconds * 1000)) +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/nfc/UnifiedNfcReader.kt b/app/src/main/java/com/iiyh/emoneyinfo/nfc/UnifiedNfcReader.kt new file mode 100644 index 0000000..9c0ad7f --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/nfc/UnifiedNfcReader.kt @@ -0,0 +1,125 @@ +package com.iiyh.emoneyinfo.nfc + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.nfc.tech.NfcF +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.data.EmoneyUiState +import com.iiyh.emoneyinfo.util.AppLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class UnifiedNfcReader(private val context: Context) { + private val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(context) + private val strings = AndroidStrings(context) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val readers: List = listOf(IsoDepCardRouter(), FelicaCardReader()) + private var resetMessageJob: Job? = null + private val _uiState = MutableStateFlow( + EmoneyUiState( + isNfcSupported = adapter != null, + scanMessage = currentScanMessage() + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + fun refreshStatus() { + resetMessageJob?.cancel() + _uiState.value = _uiState.value.copy( + isNfcSupported = adapter != null, + scanMessage = when { + adapter == null -> context.getString(R.string.nfc_not_supported) + !adapter.isEnabled -> context.getString(R.string.nfc_disabled) + _uiState.value.hasCardData() -> _uiState.value.scanMessage + else -> currentScanMessage() + } + ) + } + + fun startScan() { + refreshStatus() + } + + fun onNewIntent(intent: Intent) { + resetMessageJob?.cancel() + val tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + } ?: return + AppLog.d("EmoneyInfoNfc", "onNewIntent techs=${tag.techList.joinToString()}") + val reader = readers.firstOrNull { it.canHandle(tag) } ?: return + runCatching { + _uiState.value = reader.read(tag, strings) + scheduleResetToRescanHint() + }.onFailure { + AppLog.e("EmoneyInfoNfc", "Scan failed: ${it.message}", it) + _uiState.value = _uiState.value.copy( + scanMessage = context.getString( + R.string.scan_failed_message, + it.message ?: context.getString(R.string.unknown_error) + ) + ) + } + } + + fun enableForegroundDispatch(activity: Activity) { + val adapter = adapter ?: return + if (!adapter.isEnabled) { + refreshStatus() + return + } + val intent = Intent(activity, activity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + val pendingIntent = PendingIntent.getActivity( + activity, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + val filters = arrayOf(IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)) + val techLists = arrayOf( + arrayOf(IsoDep::class.java.name), + arrayOf(NfcF::class.java.name) + ) + adapter.enableForegroundDispatch(activity, pendingIntent, filters, techLists) + } + + fun disableForegroundDispatch(activity: Activity) { + adapter?.disableForegroundDispatch(activity) + } + + private fun scheduleResetToRescanHint() { + resetMessageJob?.cancel() + resetMessageJob = scope.launch { + delay(5_000) + _uiState.value = _uiState.value.copy( + scanMessage = if (adapter?.isEnabled == true) { + context.getString(R.string.tap_again_hint) + } else { + currentScanMessage() + } + ) + } + } + + private fun currentScanMessage(): String = when { + adapter == null -> context.getString(R.string.nfc_not_supported) + !adapter.isEnabled -> context.getString(R.string.nfc_disabled) + else -> context.getString(R.string.tap_card_hint) + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/pdf/HistoryPdfExporter.kt b/app/src/main/java/com/iiyh/emoneyinfo/pdf/HistoryPdfExporter.kt new file mode 100644 index 0000000..9b92cb1 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/pdf/HistoryPdfExporter.kt @@ -0,0 +1,243 @@ +package com.iiyh.emoneyinfo.pdf + +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.net.Uri +import androidx.core.content.FileProvider +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.data.EmoneyUiState +import com.iiyh.emoneyinfo.data.TransactionItem +import com.iiyh.emoneyinfo.data.formatCardNumber +import com.iiyh.emoneyinfo.data.maskFirst12 +import java.io.File +import java.io.FileOutputStream +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +object HistoryPdfExporter { + private const val PAGE_WIDTH = 595 + private const val PAGE_HEIGHT = 842 + private const val MARGIN = 40f + + fun export(context: Context, state: EmoneyUiState): File { + val document = PdfDocument() + var page = document.startPage(PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create()) + var canvas = page.canvas + var y = MARGIN + var pageNumber = 1 + + val bodyPaint = paint(10f) + val boldPaint = paint(10f, Typeface.BOLD) + val titlePaint = paint(13f, Typeface.BOLD) + val headerPaint = paint(10f, Typeface.BOLD, color = pdfGreen()) + val smallPaint = paint(9f) + val footerPaint = paint(9f, color = Color.LTGRAY, align = Paint.Align.CENTER) + val amountPositivePaint = paint(9f, color = Color.rgb(33, 140, 33), align = Paint.Align.RIGHT) + val amountDefaultPaint = paint(9f, color = Color.BLACK, align = Paint.Align.RIGHT) + val rightHeaderPaint = paint(10f, Typeface.BOLD, color = pdfGreen(), align = Paint.Align.RIGHT) + val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.LTGRAY + strokeWidth = 1f + } + val altRowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(247, 247, 247) + } + + val subtitle = context.getString(R.string.pdf_subtitle) + val cardLabel = context.getString(state.cardType.labelRes) + val balanceText = state.formattedBalance() + val cardNumber = state.cardNumber.maskFirst12() + val list = state.transactions + val hasLocation = list.any { it.locationName.isNotBlank() } + val indonesianLocale = Locale.forLanguageTag("id-ID") + val dateFormatter = SimpleDateFormat("dd MMMM yyyy, HH:mm", indonesianLocale) + val numFormatter = NumberFormat.getCurrencyInstance(indonesianLocale).apply { + currency = java.util.Currency.getInstance("IDR") + maximumFractionDigits = 0 + } + + fun newPage() { + document.finishPage(page) + pageNumber += 1 + page = document.startPage(PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create()) + canvas = page.canvas + y = MARGIN + } + + val logo = BitmapFactory.decodeResource(context.resources, R.drawable.app_logo) + if (logo != null) { + val imgH = 30f + val imgW = logo.width * (imgH / logo.height.toFloat()) + canvas.drawBitmap(logo, null, android.graphics.RectF(MARGIN, y, MARGIN + imgW, y + imgH), null) + y += imgH + 12f + } else { + val fallbackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = pdfGreen() } + canvas.drawRoundRect(MARGIN, y, MARGIN + 48f, y + 48f, 10f, 10f, fallbackPaint) + val letterPaint = paint(20f, Typeface.BOLD, color = Color.WHITE, align = Paint.Align.CENTER) + canvas.drawText("E", MARGIN + 24f, y + 31f, letterPaint) + y += 60f + } + + y += drawWrappedText(canvas, subtitle, MARGIN, y, PAGE_WIDTH - MARGIN * 2, bodyPaint) + 16f + canvas.drawLine(MARGIN, y, PAGE_WIDTH - MARGIN, y, linePaint) + y += 16f + + y = drawInfoRow(canvas, context.getString(R.string.pdf_card_label), cardLabel, y, boldPaint, bodyPaint) + y = drawInfoRow(canvas, context.getString(R.string.pdf_balance_label), balanceText, y, boldPaint, bodyPaint) + y = drawInfoRow(canvas, context.getString(R.string.card_number_label), cardNumber, y, boldPaint, bodyPaint) + y += 8f + canvas.drawLine(MARGIN, y, PAGE_WIDTH - MARGIN, y, linePaint) + y += 16f + + val dateColW = 130f + val typeColW = 70f + val locationColW = if (hasLocation) 120f else 0f + val amountX = MARGIN + dateColW + typeColW + locationColW + val amountColW = PAGE_WIDTH - MARGIN - amountX + + canvas.drawText(context.getString(R.string.pdf_date_label), MARGIN, y, headerPaint) + canvas.drawText(context.getString(R.string.pdf_transaction_label), MARGIN + dateColW, y, headerPaint) + if (hasLocation) { + canvas.drawText(context.getString(R.string.pdf_location_label), MARGIN + dateColW + typeColW, y, headerPaint) + } + canvas.drawText(context.getString(R.string.pdf_amount_label), amountX + amountColW, y, rightHeaderPaint) + y += 14f + + val headerUnderline = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(102, 92, 125, 122) + strokeWidth = 1f + } + canvas.drawLine(MARGIN, y, PAGE_WIDTH - MARGIN, y, headerUnderline) + y += 12f + + list.forEachIndexed { index, item -> + val rowHeight = 16f + if (y > PAGE_HEIGHT - MARGIN - 40f) { + newPage() + } + + if (index % 2 == 0) { + canvas.drawRect(MARGIN - 4f, y - 11f, PAGE_WIDTH - MARGIN + 4f, y + 5f, altRowPaint) + } + + val dateText = dateFormatter.format(item.date) + val typeText = item.title + val amountText = numFormatter.format(item.amount).replace("IDR", "Rp") + val amountPaint = if (item.isCredit) amountPositivePaint else amountDefaultPaint + + canvas.drawText(dateText, MARGIN, y, smallPaint) + canvas.drawText(typeText, MARGIN + dateColW, y, smallPaint) + if (hasLocation) { + canvas.drawText(item.locationName.ifBlank { "–" }, MARGIN + dateColW + typeColW, y, smallPaint) + } + canvas.drawText(amountText, amountX + amountColW, y, amountPaint) + y += rowHeight + } + + y += 10f + canvas.drawLine(MARGIN, y, PAGE_WIDTH - MARGIN, y, linePaint) + y += 14f + canvas.drawText( + "emoneyInfo © ${Calendar.getInstance().get(Calendar.YEAR)}", + PAGE_WIDTH / 2f, + y, + footerPaint + ) + + document.finishPage(page) + + val file = File(context.cacheDir, "emoney_history_${System.currentTimeMillis()}.pdf") + FileOutputStream(file).use { output -> + document.writeTo(output) + } + document.close() + return file + } + + fun openOrShare(context: Context, file: File) { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val viewIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/pdf") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val packageManager = context.packageManager + val openIntents = packageManager.queryIntentActivities(viewIntent, 0).map { resolveInfo -> + Intent(viewIntent).setPackage(resolveInfo.activityInfo.packageName) + }.toTypedArray() + + val chooser = Intent.createChooser(shareIntent, context.getString(R.string.pdf_open_or_share)) + chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, openIntents) + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) + } + + private fun drawInfoRow( + canvas: Canvas, + label: String, + value: String, + y: Float, + labelPaint: Paint, + valuePaint: Paint + ): Float { + val labelW = 90f + val colonX = MARGIN + labelW + val valueX = colonX + 14f + canvas.drawText(label, MARGIN, y, labelPaint) + canvas.drawText(":", colonX, y, valuePaint) + canvas.drawText(value, valueX, y, valuePaint) + return y + 16f + } + + private fun drawWrappedText( + canvas: Canvas, + text: String, + x: Float, + y: Float, + width: Float, + paint: Paint + ): Float { + val textPaint = android.text.TextPaint(paint) + val layout = android.text.StaticLayout.Builder + .obtain(text, 0, text.length, textPaint, width.toInt()) + .build() + canvas.save() + canvas.translate(x, y) + layout.draw(canvas) + canvas.restore() + return layout.height.toFloat() + } + + private fun paint( + size: Float, + typefaceStyle: Int = Typeface.NORMAL, + color: Int = Color.BLACK, + align: Paint.Align = Paint.Align.LEFT + ) = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = size + typeface = Typeface.create(Typeface.DEFAULT, typefaceStyle) + this.color = color + textAlign = align + } + + private fun pdfGreen(): Int = Color.rgb(92, 125, 122) +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/EmoneyInfoApp.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/EmoneyInfoApp.kt new file mode 100644 index 0000000..4feb8fb --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/EmoneyInfoApp.kt @@ -0,0 +1,139 @@ +package com.iiyh.emoneyinfo.ui + +import android.content.Context +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.iiyh.emoneyinfo.R +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.iiyh.emoneyinfo.nfc.UnifiedNfcReader +import com.iiyh.emoneyinfo.ui.screens.AboutScreen +import com.iiyh.emoneyinfo.ui.screens.FaqScreen +import com.iiyh.emoneyinfo.ui.screens.HomeScreen +import com.iiyh.emoneyinfo.ui.screens.HistoryScreen +import com.iiyh.emoneyinfo.ui.screens.PrivacyPolicyScreen +import com.iiyh.emoneyinfo.ui.screens.SettingsScreen +import com.iiyh.emoneyinfo.ui.screens.TermsScreen + +private data class BottomDestination(val route: String, val label: String) + +@Composable +fun EmoneyInfoApp( + nfcReader: UnifiedNfcReader, + adsEnabled: Boolean +) { + val context = LocalContext.current + val navController = rememberNavController() + val uiState by nfcReader.uiState.collectAsState() + val preferences = remember(context) { + context.getSharedPreferences("emoney_info_prefs", Context.MODE_PRIVATE) + } + var showCardNumber by remember(preferences) { + mutableStateOf(preferences.getBoolean("masked", true)) + } + + val bottomDestinations = listOf( + BottomDestination("home", stringResource(R.string.tab_home)), + BottomDestination("settings", stringResource(R.string.tab_settings)) + ) + + Scaffold( + bottomBar = { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val visible = currentDestination?.route in setOf("home", "settings") + if (visible) { + NavigationBar { + bottomDestinations.forEach { destination -> + NavigationBarItem( + selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true, + onClick = { + if (destination.route == "home") { + navController.popBackStack("home", false) + } else { + navController.navigate(destination.route) { + launchSingleTop = true + } + } + }, + icon = { + Icon( + imageVector = if (destination.route == "home") Icons.Default.CreditCard else Icons.Default.Settings, + contentDescription = destination.label + ) + }, + label = { Text(destination.label) } + ) + } + } + } + } + ) { innerPadding -> + NavHost(navController = navController, startDestination = "home", modifier = Modifier.padding(innerPadding)) { + composable("home") { + HomeScreen( + state = uiState, + adsEnabled = adsEnabled, + showCardNumber = showCardNumber, + onScanTapped = { nfcReader.startScan() }, + onViewHistoryTapped = { navController.navigate("history") }, + onSettingsTapped = { navController.navigate("settings") } + ) + } + composable("settings") { + SettingsScreen( + adsEnabled = adsEnabled, + showCardNumber = showCardNumber, + onShowCardNumberChanged = { + showCardNumber = it + preferences.edit().putBoolean("masked", it).apply() + }, + onHelpCenterTapped = { navController.navigate("faq") }, + onAboutTapped = { navController.navigate("about") } + ) + } + composable("history") { + HistoryScreen( + state = uiState, + adsEnabled = adsEnabled, + onBack = { navController.popBackStack() } + ) + } + composable("faq") { + FaqScreen(onBack = { navController.popBackStack() }) + } + composable("about") { + AboutScreen( + onBack = { navController.popBackStack() }, + onTermsTapped = { navController.navigate("terms") }, + onPrivacyTapped = { navController.navigate("privacy") } + ) + } + composable("terms") { + TermsScreen(onBack = { navController.popBackStack() }) + } + composable("privacy") { + PrivacyPolicyScreen(onBack = { navController.popBackStack() }) + } + } + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/components/AdMobBanner.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/components/AdMobBanner.kt new file mode 100644 index 0000000..b080ac2 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/components/AdMobBanner.kt @@ -0,0 +1,64 @@ +package com.iiyh.emoneyinfo.ui.components + +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.google.android.gms.ads.AdListener +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.AdSize +import com.google.android.gms.ads.AdView +import com.google.android.gms.ads.LoadAdError +import com.iiyh.emoneyinfo.util.AppLog + +@Composable +fun AdMobBanner(adUnitId: String, modifier: Modifier = Modifier) { + val context = LocalContext.current + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val adWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() } + val adWidthDp = with(density) { adWidthPx.toDp().value.toInt() } + val adSize = remember(adWidthDp) { + AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, adWidthDp) + } + val adView = remember(adUnitId, adSize) { + AdView(context).apply { + setAdSize(adSize) + this.adUnitId = adUnitId + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + adListener = object : AdListener() { + override fun onAdLoaded() { + AppLog.d("EmoneyInfoAds", "Banner loaded: $adUnitId") + } + + override fun onAdFailedToLoad(error: LoadAdError) { + AppLog.w( + "EmoneyInfoAds", + "Banner failed: $adUnitId code=${error.code} message=${error.message}" + ) + } + } + loadAd(AdRequest.Builder().build()) + } + } + + DisposableEffect(adView) { + onDispose { + adView.destroy() + } + } + + AndroidView( + modifier = modifier, + factory = { adView } + ) +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/components/ScreenTopBar.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/components/ScreenTopBar.kt new file mode 100644 index 0000000..87598f3 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/components/ScreenTopBar.kt @@ -0,0 +1,30 @@ +package com.iiyh.emoneyinfo.ui.components + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.iiyh.emoneyinfo.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScreenTopBar(title: String, onBack: () -> Unit) { + TopAppBar( + windowInsets = WindowInsets(0, 0, 0, 0), + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + ) +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/AboutScreen.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/AboutScreen.kt new file mode 100644 index 0000000..23d3195 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/AboutScreen.kt @@ -0,0 +1,156 @@ +package com.iiyh.emoneyinfo.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.ui.components.ScreenTopBar +import com.iiyh.emoneyinfo.ui.theme.Card +import com.iiyh.emoneyinfo.ui.theme.Primary +import com.iiyh.emoneyinfo.ui.theme.Secondary +import com.iiyh.emoneyinfo.ui.theme.TextSecondary + +@Composable +fun AboutScreen(onBack: () -> Unit, onTermsTapped: () -> Unit, onPrivacyTapped: () -> Unit) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + ScreenTopBar( + title = stringResource(R.string.about_app), + onBack = onBack + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Card( + colors = CardDefaults.cardColors(containerColor = Card), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Image( + painter = painterResource(R.drawable.app_logo), + contentDescription = null, + modifier = Modifier.size(84.dp), + contentScale = ContentScale.Fit + ) + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.settings_about_subtitle), color = TextSecondary) + Text( + stringResource(R.string.about_description), + color = TextSecondary + ) + } + } + LegalRow( + title = stringResource(R.string.terms_conditions), + icon = Icons.Default.Description, + onClick = onTermsTapped + ) + LegalRow( + title = stringResource(R.string.privacy_policy), + icon = Icons.Default.Lock, + onClick = onPrivacyTapped + ) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(Brush.linearGradient(listOf(Primary, Secondary))) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(Icons.Default.CreditCard, contentDescription = null, tint = Color.White) + Text(stringResource(R.string.about_architecture_title), color = Color.White, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.about_architecture_desc), + color = Color.White + ) + } + } + } +} + +@Composable +private fun LegalRow(title: String, icon: ImageVector, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), + colors = CardDefaults.cardColors(containerColor = Card), + shape = RoundedCornerShape(18.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Card( + colors = CardDefaults.cardColors(containerColor = Primary.copy(alpha = 0.16f)), + shape = RoundedCornerShape(14.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Secondary, + modifier = Modifier.padding(12.dp) + ) + } + Text( + text = title, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = 12.dp) + ) + } + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/FaqScreen.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/FaqScreen.kt new file mode 100644 index 0000000..5b64ce2 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/FaqScreen.kt @@ -0,0 +1,194 @@ +package com.iiyh.emoneyinfo.ui.screens + +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.QuestionAnswer +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.data.FaqData +import com.iiyh.emoneyinfo.data.FaqItem +import com.iiyh.emoneyinfo.ui.components.ScreenTopBar +import com.iiyh.emoneyinfo.ui.theme.Card +import com.iiyh.emoneyinfo.ui.theme.Primary +import com.iiyh.emoneyinfo.ui.theme.Secondary +import com.iiyh.emoneyinfo.ui.theme.TextSecondary + +@Composable +fun FaqScreen(onBack: () -> Unit) { + var query by remember { mutableStateOf("") } + val uriHandler = LocalUriHandler.current + val filteredCategories = FaqData.all.mapNotNull { category -> + val filteredItems = category.items.filter { + val question = stringResource(it.questionRes) + val answer = stringResource(it.answerRes) + query.isBlank() || question.contains(query, true) || answer.contains(query, true) + } + if (filteredItems.isEmpty()) null else category.copy(items = filteredItems) + } + + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + ScreenTopBar( + title = stringResource(R.string.help_center), + onBack = onBack + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Brush.linearGradient(listOf(Primary, Secondary))) + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.faq_header), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.faq_search)) } + ) + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (filteredCategories.isEmpty()) { + item { + Text( + text = stringResource(R.string.faq_no_results), + color = TextSecondary, + style = MaterialTheme.typography.bodyMedium + ) + } + } else { + filteredCategories.forEach { category -> + item { + Text( + text = stringResource(category.titleRes), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = TextSecondary, + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp) + ) + } + items(category.items) { item -> + FaqCard(item = item) + } + } + } + item { + Spacer(modifier = Modifier.height(8.dp)) + Card( + colors = CardDefaults.cardColors(containerColor = Primary), + shape = RoundedCornerShape(20.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.faq_help_card_title), + color = Color.White, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.faq_help_card_desc), + color = Color.White.copy(alpha = 0.88f), + style = MaterialTheme.typography.bodyMedium + ) + Button( + onClick = { + uriHandler.openUri("mailto:support@iptek.co?subject=Ask%20Support") + } + ) { + Icon(Icons.Default.Email, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.faq_email_support)) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + } +} + +@Composable +private fun FaqCard(item: FaqItem) { + Card( + modifier = Modifier.padding(vertical = 2.dp), + colors = CardDefaults.cardColors(containerColor = Card), + shape = RoundedCornerShape(18.dp) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Card( + colors = CardDefaults.cardColors(containerColor = Primary.copy(alpha = 0.16f)), + shape = RoundedCornerShape(14.dp) + ) { + Icon( + imageVector = Icons.Default.QuestionAnswer, + contentDescription = null, + tint = Secondary, + modifier = Modifier.padding(10.dp) + ) + } + } + Text(stringResource(item.questionRes), fontWeight = FontWeight.SemiBold) + Text(stringResource(item.answerRes), color = TextSecondary) + } + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/HistoryScreen.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/HistoryScreen.kt new file mode 100644 index 0000000..1ca66cd --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/HistoryScreen.kt @@ -0,0 +1,228 @@ +package com.iiyh.emoneyinfo.ui.screens + +import android.app.Activity +import android.content.ContextWrapper +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.Image +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.FullScreenContentCallback +import com.google.android.gms.ads.LoadAdError +import com.google.android.gms.ads.interstitial.InterstitialAd +import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.data.EmoneyUiState +import com.iiyh.emoneyinfo.data.TransactionItem +import com.iiyh.emoneyinfo.ui.theme.Danger +import com.iiyh.emoneyinfo.ads.AdMobConfig +import com.iiyh.emoneyinfo.pdf.HistoryPdfExporter +import com.iiyh.emoneyinfo.ui.components.AdMobBanner +import com.iiyh.emoneyinfo.ui.components.ScreenTopBar +import com.iiyh.emoneyinfo.ui.theme.Card +import com.iiyh.emoneyinfo.ui.theme.Primary +import com.iiyh.emoneyinfo.ui.theme.Success +import com.iiyh.emoneyinfo.ui.theme.TextSecondary + +@Composable +fun HistoryScreen(state: EmoneyUiState, adsEnabled: Boolean, onBack: () -> Unit) { + val exportButtonHeight = 56.dp + val context = LocalContext.current + val activity = context.findActivity() + var interstitialAd by remember { mutableStateOf(null) } + + fun loadInterstitial() { + InterstitialAd.load( + context, + AdMobConfig.INTERSTITIAL_PDF, + AdRequest.Builder().build(), + object : InterstitialAdLoadCallback() { + override fun onAdLoaded(ad: InterstitialAd) { + interstitialAd = ad + } + + override fun onAdFailedToLoad(error: LoadAdError) { + interstitialAd = null + } + } + ) + } + + LaunchedEffect(adsEnabled) { + if (adsEnabled) { + loadInterstitial() + } else { + interstitialAd = null + } + } + + fun exportPdfNow() { + runCatching { + val file = HistoryPdfExporter.export(context, state) + HistoryPdfExporter.openOrShare(context, file) + }.onFailure { + Toast.makeText(context, context.getString(R.string.pdf_export_failed), Toast.LENGTH_SHORT).show() + } + } + + fun exportWithAd() { + val ad = interstitialAd + if (adsEnabled && ad != null && activity != null) { + interstitialAd = null + ad.fullScreenContentCallback = object : FullScreenContentCallback() { + override fun onAdDismissedFullScreenContent() { + loadInterstitial() + exportPdfNow() + } + + override fun onAdFailedToShowFullScreenContent(adError: com.google.android.gms.ads.AdError) { + loadInterstitial() + exportPdfNow() + } + } + ad.show(activity) + } else { + exportPdfNow() + } + } + + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + ScreenTopBar( + title = stringResource(R.string.history_title), + onBack = onBack + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (adsEnabled) { + AdMobBanner(adUnitId = AdMobConfig.BANNER_HISTORY, modifier = Modifier.fillMaxWidth()) + } + + if (state.transactions.isEmpty()) { + Text(stringResource(R.string.no_history), color = TextSecondary) + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(state.transactions) { item -> + HistoryTransactionCard(item = item) + } + item { Spacer(modifier = Modifier.padding(bottom = exportButtonHeight + 64.dp)) } + } + } + } + + Button( + onClick = { exportWithAd() }, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(24.dp) + ) { + Icon(Icons.Default.PictureAsPdf, contentDescription = null) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.export_pdf)) + } + } + } +} + +private fun android.content.Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +@Composable +private fun HistoryTransactionCard(item: TransactionItem) { + val primaryLabel = item.locationName.ifBlank { item.title } + val secondaryLabel = item.formattedDate() + val trailingSubtitle = item.title.takeIf { item.locationName.isNotBlank() } + + Card( + colors = CardDefaults.cardColors(containerColor = Card), + shape = RoundedCornerShape(18.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = if (item.isCredit) Primary.copy(alpha = 0.22f) else Danger.copy(alpha = 0.12f) + ), + shape = RoundedCornerShape(14.dp) + ) { + Image( + painter = painterResource(if (item.isCredit) R.drawable.ic_activity_outline else R.drawable.ic_card_outline), + contentDescription = null, + modifier = Modifier + .padding(12.dp) + .size(24.dp), + contentScale = ContentScale.Fit + ) + } + Spacer(modifier = Modifier.padding(horizontal = 8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(primaryLabel, fontWeight = FontWeight.SemiBold) + Text(secondaryLabel, style = MaterialTheme.typography.bodySmall, color = TextSecondary) + } + Column(horizontalAlignment = Alignment.End) { + Text(item.formattedAmount(), color = if (item.isCredit) Success else Danger) + trailingSubtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary + ) + } + } + } + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/HomeScreen.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..ea6c908 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/HomeScreen.kt @@ -0,0 +1,334 @@ +package com.iiyh.emoneyinfo.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import android.widget.Toast +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.data.EmoneyUiState +import com.iiyh.emoneyinfo.data.formatCardNumber +import com.iiyh.emoneyinfo.data.maskFirst12 +import com.iiyh.emoneyinfo.ui.theme.Primary +import com.iiyh.emoneyinfo.ads.AdMobConfig +import com.iiyh.emoneyinfo.ui.components.AdMobBanner +import com.iiyh.emoneyinfo.ui.theme.Danger +import com.iiyh.emoneyinfo.ui.theme.Secondary +import com.iiyh.emoneyinfo.ui.theme.Success +import com.iiyh.emoneyinfo.ui.theme.TextSecondary + +@Composable +fun HomeScreen( + state: EmoneyUiState, + adsEnabled: Boolean, + showCardNumber: Boolean, + onScanTapped: () -> Unit, + onViewHistoryTapped: () -> Unit, + onSettingsTapped: () -> Unit +) { + @Suppress("DEPRECATION") + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val latestTransaction = state.transactions.firstOrNull() + val hasCardData = state.hasCardData() + val displayCardNumber = when { + state.cardNumber.isBlank() -> "" + showCardNumber -> state.cardNumber.formatCardNumber() + else -> state.cardNumber.maskFirst12() + } + val latestTitle = latestTransaction?.locationName?.ifBlank { latestTransaction.title } + ?: stringResource(R.string.placeholder_transaction_title) + val latestSubtitle = latestTransaction?.formattedDate() ?: stringResource(R.string.placeholder_transaction_date) + val latestTrailingSubtitle = latestTransaction?.title?.takeIf { latestTransaction.locationName.isNotBlank() } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(R.drawable.app_logo), + contentDescription = null, + modifier = Modifier.size(42.dp), + contentScale = ContentScale.Fit + ) + Spacer(Modifier.size(10.dp)) + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onSettingsTapped) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + } + + Column { + Text(stringResource(R.string.available_balance), color = TextSecondary, style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(4.dp)) + Text(state.formattedBalance(), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(28.dp)) + .background(Brush.linearGradient(listOf(Primary, Secondary))) + ) { + Image( + painter = painterResource(R.drawable.home_header), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + contentScale = ContentScale.Crop, + alpha = 0.18f + ) + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Surface( + color = Color.White.copy(alpha = 0.16f), + shape = RoundedCornerShape(999.dp) + ) { + Text( + text = if (hasCardData) stringResource(R.string.scan_result_title) else stringResource(R.string.scan_ready_title), + color = Color.White, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium + ) + } + Text( + stringResource(state.cardType.labelRes), + color = Color.White.copy(alpha = 0.95f), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + if (state.cardNumber.isNotBlank()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(R.string.card_number_label), + color = Color.White.copy(alpha = 0.72f), + style = MaterialTheme.typography.labelMedium + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = displayCardNumber, + color = Color.White.copy(alpha = 0.92f), + style = MaterialTheme.typography.bodyMedium + ) + IconButton( + onClick = { + clipboard.setText(AnnotatedString(state.cardNumber)) + Toast.makeText( + context, + context.getString(R.string.copied_to_clipboard), + Toast.LENGTH_SHORT + ).show() + }, + modifier = Modifier.size(20.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.copy_card_number), + tint = Color.White.copy(alpha = 0.78f), + modifier = Modifier.size(14.dp) + ) + } + } + } + } + if (hasCardData) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(R.string.latest_scan_message), + color = Color.White.copy(alpha = 0.72f), + style = MaterialTheme.typography.labelMedium + ) + Text(state.scanMessage, color = Color.White, style = MaterialTheme.typography.bodyMedium) + } + } else { + Text(stringResource(R.string.tap_card_hint), color = Color.White, style = MaterialTheme.typography.bodyMedium) + } + } + } + + if (adsEnabled) { + AdMobBanner(adUnitId = AdMobConfig.BANNER_HOME, modifier = Modifier.fillMaxWidth()) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.last_activity_label), + color = TextSecondary, + style = MaterialTheme.typography.labelMedium + ) + if (latestTransaction == null) { + Text( + text = stringResource(R.string.history_summary_empty), + color = TextSecondary, + style = MaterialTheme.typography.bodySmall + ) + } else { + Text( + text = stringResource(R.string.history_summary_count, state.transactions.size), + color = TextSecondary, + style = MaterialTheme.typography.bodySmall + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + InfoChip( + modifier = Modifier.weight(1f), + title = stringResource(R.string.card_type_label), + value = stringResource(state.cardType.labelRes), + iconRes = R.drawable.ic_card_outline + ) + InfoChip( + modifier = Modifier.weight(1f), + title = stringResource(R.string.history_summary_card), + value = if (state.transactions.isEmpty()) "0" else state.transactions.size.toString(), + iconRes = R.drawable.ic_activity_outline + ) + } + + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_activity_outline), + contentDescription = null, + modifier = Modifier.size(28.dp), + contentScale = ContentScale.Fit + ) + Spacer(Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = latestTitle, + fontWeight = FontWeight.SemiBold + ) + Text( + text = latestSubtitle, + color = TextSecondary, + style = MaterialTheme.typography.bodySmall + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = latestTransaction?.formattedAmount() ?: stringResource(R.string.placeholder_transaction_amount), + color = latestTransaction?.let { if (it.isCredit) Success else Danger } + ?: MaterialTheme.colorScheme.onSurface + ) + latestTrailingSubtitle?.let { + Text( + text = it, + color = TextSecondary, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onViewHistoryTapped + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Default.History, contentDescription = null) + Text(stringResource(R.string.view_full_history), fontWeight = FontWeight.SemiBold) + } + } +} + +@Composable +private fun InfoChip( + modifier: Modifier = Modifier, + title: String, + value: String, + iconRes: Int +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + contentScale = ContentScale.Fit + ) + Column { + Text(title, color = TextSecondary, style = MaterialTheme.typography.labelSmall) + Text(value, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium) + } + } + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/PrivacyPolicyScreen.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/PrivacyPolicyScreen.kt new file mode 100644 index 0000000..f503835 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/PrivacyPolicyScreen.kt @@ -0,0 +1,45 @@ +package com.iiyh.emoneyinfo.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.ui.components.ScreenTopBar +import com.iiyh.emoneyinfo.ui.theme.TextSecondary + +@Composable +fun PrivacyPolicyScreen(onBack: () -> Unit) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + ScreenTopBar( + title = stringResource(R.string.privacy_policy), + onBack = onBack + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text(stringResource(R.string.privacy_intro), color = TextSecondary) + Text(stringResource(R.string.privacy_point_1)) + Text(stringResource(R.string.privacy_point_2)) + Text(stringResource(R.string.privacy_point_3)) + } + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/SettingsScreen.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..15f83f9 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/SettingsScreen.kt @@ -0,0 +1,181 @@ +package com.iiyh.emoneyinfo.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.ui.theme.Card +import com.iiyh.emoneyinfo.ui.theme.Primary +import com.iiyh.emoneyinfo.ui.theme.TextSecondary +import com.iiyh.emoneyinfo.ads.AdMobConfig +import com.iiyh.emoneyinfo.ui.components.AdMobBanner + +@Composable +fun SettingsScreen( + adsEnabled: Boolean, + showCardNumber: Boolean, + onShowCardNumberChanged: (Boolean) -> Unit, + onHelpCenterTapped: () -> Unit, + onAboutTapped: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + + if (adsEnabled) { + AdMobBanner(adUnitId = AdMobConfig.BANNER_SETTINGS, modifier = Modifier.fillMaxWidth()) + } + + Card( + colors = CardDefaults.cardColors(containerColor = Card), + shape = RoundedCornerShape(20.dp) + ) { + Column { + SettingRow( + title = stringResource(R.string.language), + subtitle = stringResource(R.string.language_value), + iconRes = R.drawable.ic_settings_outline + ) {} + SettingsDivider() + ToggleSettingRow( + title = stringResource(R.string.show_card_number), + subtitle = stringResource(R.string.show_card_number_desc), + iconRes = R.drawable.ic_simcard, + checked = showCardNumber, + onCheckedChange = onShowCardNumberChanged + ) + SettingsDivider() + SettingRow( + title = stringResource(R.string.help_center), + subtitle = stringResource(R.string.settings_help_subtitle), + iconRes = R.drawable.ic_activity_outline, + onClick = onHelpCenterTapped + ) + SettingsDivider() + SettingRow( + title = stringResource(R.string.about_app), + subtitle = stringResource(R.string.settings_about_subtitle), + iconRes = R.drawable.ic_card_outline, + onClick = onAboutTapped + ) + } + } + + Box(modifier = Modifier.padding(bottom = 84.dp)) + } +} + +@Composable +private fun SettingRow(title: String, subtitle: String, iconRes: Int, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Card( + colors = CardDefaults.cardColors(containerColor = Primary.copy(alpha = 0.18f)), + shape = RoundedCornerShape(14.dp) + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.padding(12.dp), + contentScale = ContentScale.Fit + ) + } + Column(modifier = Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary) + } + } +} + +@Composable +private fun SettingsDivider() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 72.dp, end = 16.dp) + ) { + androidx.compose.foundation.Canvas( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 0.5.dp) + ) { + drawLine( + color = TextSecondary.copy(alpha = 0.18f), + start = androidx.compose.ui.geometry.Offset(0f, 0f), + end = androidx.compose.ui.geometry.Offset(size.width, 0f), + strokeWidth = 1.dp.toPx() + ) + } + } +} + +@Composable +private fun ToggleSettingRow( + title: String, + subtitle: String, + iconRes: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Card( + colors = CardDefaults.cardColors(containerColor = Primary.copy(alpha = 0.18f)), + shape = RoundedCornerShape(14.dp) + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.padding(12.dp), + contentScale = ContentScale.Fit + ) + } + Column(modifier = Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/TermsScreen.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/TermsScreen.kt new file mode 100644 index 0000000..6e5a70c --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/screens/TermsScreen.kt @@ -0,0 +1,45 @@ +package com.iiyh.emoneyinfo.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.iiyh.emoneyinfo.R +import com.iiyh.emoneyinfo.ui.components.ScreenTopBar +import com.iiyh.emoneyinfo.ui.theme.TextSecondary + +@Composable +fun TermsScreen(onBack: () -> Unit) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + ScreenTopBar( + title = stringResource(R.string.terms_conditions), + onBack = onBack + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text(stringResource(R.string.terms_intro), color = TextSecondary) + Text(stringResource(R.string.terms_point_1)) + Text(stringResource(R.string.terms_point_2)) + Text(stringResource(R.string.terms_point_3)) + } + } +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Color.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Color.kt new file mode 100644 index 0000000..6c35eea --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Color.kt @@ -0,0 +1,13 @@ +package com.iiyh.emoneyinfo.ui.theme + +import androidx.compose.ui.graphics.Color + +val Background = Color(0xFFF3F3F8) +val SystemBar = Color(0xFFDCE2EA) +val Primary = Color(0xFF7AD4D1) +val Secondary = Color(0xFF5D7D7B) +val Card = Color(0xFFFFFFFF) +val TextPrimary = Color(0xFF1A1A2E) +val TextSecondary = Color(0xFF8E8E93) +val Success = Color(0xFF34C759) +val Danger = Color(0xFFE53935) diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Theme.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Theme.kt new file mode 100644 index 0000000..9431b91 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Theme.kt @@ -0,0 +1,31 @@ +package com.iiyh.emoneyinfo.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val LightColors = lightColorScheme( + primary = Primary, + secondary = Secondary, + background = Background, + surface = Card, + onPrimary = TextPrimary, + onSecondary = Card, + onBackground = TextPrimary, + onSurface = TextPrimary +) + +private val DarkColors = darkColorScheme( + primary = Primary, + secondary = Secondary +) + +@Composable +fun EmoneyInfoTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = LightColors, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Type.kt b/app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Type.kt new file mode 100644 index 0000000..5ce2672 --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package com.iiyh.emoneyinfo.ui.theme + +import androidx.compose.material3.Typography + +val Typography = Typography() diff --git a/app/src/main/java/com/iiyh/emoneyinfo/util/AppLog.kt b/app/src/main/java/com/iiyh/emoneyinfo/util/AppLog.kt new file mode 100644 index 0000000..955e37d --- /dev/null +++ b/app/src/main/java/com/iiyh/emoneyinfo/util/AppLog.kt @@ -0,0 +1,23 @@ +package com.iiyh.emoneyinfo.util + +import android.util.Log +import com.iiyh.emoneyinfo.BuildConfig + +object AppLog { + fun d(tag: String, message: String) { + if (BuildConfig.DEBUG) Log.d(tag, message) + } + + fun w(tag: String, message: String) { + if (BuildConfig.DEBUG) Log.w(tag, message) + } + + fun e(tag: String, message: String, throwable: Throwable? = null) { + if (!BuildConfig.DEBUG) return + if (throwable != null) { + Log.e(tag, message, throwable) + } else { + Log.e(tag, message) + } + } +} diff --git a/app/src/main/res/drawable-nodpi/app_logo.png b/app/src/main/res/drawable-nodpi/app_logo.png new file mode 100755 index 0000000..b82d778 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/app_logo.png differ diff --git a/app/src/main/res/drawable-nodpi/home_header.png b/app/src/main/res/drawable-nodpi/home_header.png new file mode 100755 index 0000000..30995e3 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_header.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_activity_outline.png b/app/src/main/res/drawable-nodpi/ic_activity_outline.png new file mode 100644 index 0000000..9ea069b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_activity_outline.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_card_outline.png b/app/src/main/res/drawable-nodpi/ic_card_outline.png new file mode 100644 index 0000000..f8d047b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_card_outline.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_settings_outline.png b/app/src/main/res/drawable-nodpi/ic_settings_outline.png new file mode 100644 index 0000000..fcd6087 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_settings_outline.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_simcard.png b/app/src/main/res/drawable-nodpi/ic_simcard.png new file mode 100644 index 0000000..8fd99b5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_simcard.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..0ee5682 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..3ae5a4b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/launch_background.xml b/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..d97c7ff --- /dev/null +++ b/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..65291b9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..65291b9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..a6d19e2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..551cdd0 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2d560e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..a483f08 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..e1a96f1 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e417b4f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..9d73c84 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f85b4f0 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2dd8d84 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..25807d5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..e45c88d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d1e3d05 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..6765dd3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..6e69e54 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..7d2004d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml new file mode 100644 index 0000000..37b3924 --- /dev/null +++ b/app/src/main/res/values-id/strings.xml @@ -0,0 +1,150 @@ + + Emoney Info + E-Money + Pengaturan + SALDO TERSEDIA + Cek Saldo + Tempelkan kartu di bagian belakang ponsel untuk membaca saldo dan riwayat transaksi. + Tempelkan kembali kartu untuk mengecek saldo dan riwayat transaksi. + Transaksi Terakhir + Lihat Semua Riwayat + Riwayat Transaksi + AKTIVITAS TERBARU + Ekspor PDF + Buka atau bagikan PDF + Gagal mengekspor PDF + Dibuat oleh aplikasi emoney Info: cek saldo dan riwayat uang elektronik. + Kartu + Saldo + Tanggal + Transaksi + Lokasi + Jumlah + Pengaturan + Umum + Aplikasi + Bahasa + Bahasa Indonesia + Tampilkan Nomor Kartu + Tampilkan nomor kartu setelah proses scan berhasil. + Pusat Bantuan + Tentang Aplikasi + Syarat & Ketentuan + Kebijakan Privasi + Apa yang bisa kami bantu hari ini? + Cari pertanyaan atau jawaban + Tidak ada hasil ditemukan + Masih butuh bantuan? + Kirim email ke kami dan kami akan membalas dalam 1–2 hari kerja. + Email Support + Cek kartu e-money yang didukung dengan NFC, lihat saldo, dan tinjau riwayat transaksi di satu tempat. + Mendukung Mandiri e-Money, Flazz, Brizzi, TapCash, JackCard, MegaCash, dan KMT. + © Emoney Info + Siap memindai kartu NFC + Perangkat ini tidak mendukung NFC. + NFC sedang dimatikan. Aktifkan NFC di Pengaturan, lalu tempelkan kembali kartu Anda. + Tidak ada transaksi ditemukan. + Kartu E-Money + Nomor kartu disembunyikan + Belum ada transaksi + Scan kartu untuk memuat riwayat terbaru + Rp 0 + Lokasi + Belum ada riwayat transaksi yang berhasil dibaca untuk kartu ini. + %1$d transaksi + Siap dipindai + Hasil scan + Nomor Kartu + Salin nomor kartu + Disalin ke clipboard + Jenis Kartu + AKTIVITAS TERAKHIR + Pesan pembaca terakhir + Ringkasan kartu + TRANSAKSI TERBARU + Tempelkan kartu yang didukung untuk membaca saldo dan aktivitasnya. + Preferensi aplikasi dan dukungan + PUSAT BANTUAN + Cari + TENTANG + Arsitektur siap NFC + Port Android disiapkan untuk alur ISO-DEP dan FeliCa, dengan parser yang mengikuti arsitektur iOS. + Isi Ulang + Pembayaran + Kartu E-Money + Mandiri e-Money + BCA Flazz + BRIZZI + TapCash + JackCard + MegaCash + KMT + Gagal membaca kartu: %1$s + Kesalahan tidak diketahui + Kartu tidak didukung + Gagal membaca nomor kartu Brizzi + Gagal pada proses Brizzi langkah 1 + Gagal pada proses Brizzi langkah 2 + Gagal pada proses Brizzi langkah 3 + Gagal membaca saldo Brizzi + Gagal membaca nomor kartu Mandiri + Gagal membaca saldo Mandiri + Kartu Brizzi berhasil dibaca. + Kartu Brizzi dan riwayat transaksinya berhasil dibaca. + Kartu Flazz berhasil dibaca. + Kartu Flazz dan riwayat transaksinya berhasil dibaca. + Kartu Flazz Classic terdeteksi. Data dasar kartu berhasil dibaca; parsing saldo dan riwayat detail masih terbatas. + TapCash terdeteksi tetapi data purse tidak dapat dibaca. + Kartu TapCash berhasil dibaca. + Kartu TapCash dan riwayat terbarunya berhasil dibaca. + Kartu Mandiri e-Money berhasil dibaca. Format log kartu lama ini belum di-port. + Kartu Mandiri e-Money dan log transaksinya berhasil dibaca. + JackCard berhasil dibaca. + MegaCash berhasil dibaca. + Kartu KMT dan riwayat perjalanannya berhasil dibaca. + Reaktivasi + Void + Pembaruan Saldo + Transaksi + Kartu Black List + Biaya Laporan + Masa Tenggang + Refund + Tutup + ATU + Kartu + Transaksi + Keuangan + Aplikasi + Kartu apa saja yang didukung? + Aplikasi mendukung Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JackCard, MegaCash, dan KMT. Pastikan ponsel Android Anda mendukung NFC. + Mengapa kartu saya tidak terdeteksi? + Pastikan NFC aktif di ponsel Android Anda. Tempelkan kartu secara rata di bagian belakang ponsel dekat antena NFC dan tahan diam selama pemindaian. + Kartu gagal dibaca terus, apa yang harus dilakukan? + Coba lepas casing tebal, bersihkan permukaan kartu, lalu coba lagi. Jika masalah berlanjut, kartu mungkin rusak. + Mengapa transaksi saya tidak muncul? + Aplikasi membaca transaksi terbaru yang tersimpan di chip kartu. Transaksi yang lebih lama mungkin tidak dapat diakses melalui NFC. + Cara ekspor riwayat ke PDF? + Setelah scan kartu, buka Lihat Semua Riwayat, lalu tekan tombol Ekspor PDF. Anda dapat membagikannya lewat WhatsApp, email, dan aplikasi lainnya. + Saldo yang ditampilkan tidak sesuai, kenapa? + Aplikasi membaca saldo langsung dari chip kartu secara real time. Perbedaan dapat terjadi jika top-up terbaru belum tersinkron ke chip. + Apakah bisa isi ulang saldo lewat aplikasi ini? + Tidak, aplikasi ini hanya bisa membaca saldo. Isi ulang harus dilakukan melalui aplikasi resmi bank, ATM, atau merchant. + Bagaimana cara ganti bahasa aplikasi? + Buka Pengaturan → Bahasa. Aplikasi mengikuti pilihan Anda dan langsung mengubah teks yang terlihat. + Apa fungsi Tampilkan Nomor Kartu? + Jika diaktifkan, nomor kartu penuh ditampilkan di beranda. Jika dimatikan, 12 digit pertama disamarkan untuk privasi di tempat umum. + Privasi lebih dulu + Anda bisa menyamarkan nomor kartu dan membuat detail scan lebih nyaman ditinjau. + FAQ dan panduan dukungan + Versi 1.0.0 + Port Android ini mengikuti arah produk yang sama dengan aplikasi iOS. Konten legal final sebaiknya disalin dari sumber produksi sebelum rilis. + 1. Aplikasi membaca kartu NFC yang didukung secara lokal di perangkat. + 2. Aplikasi tidak mengubah saldo kartu atau menulis data ke kartu. + 3. Penempatan iklan dan analitik harus ditinjau sebelum rilis. + Pembacaan NFC diproses secara lokal di perangkat. Teks privasi final harus diselaraskan dengan implementasi Android akhir dan penggunaan AdMob. + 1. Scan kartu dipicu oleh pengguna. + 2. Tidak ada operasi tulis yang dilakukan pada kartu. + 3. Perilaku SDK iklan harus ditinjau untuk kepatuhan produksi. + Kembali + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..e2e2d58 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + #F3F3F8 + #D7DEE8 + #7AD4D1 + #5D7D7B + #1A1A2E + #8E8E93 + #FFFFFF + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..dd03738 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,150 @@ + + Emoney Info + E-Money + Settings + AVAILABLE BALANCE + Check Balance + Tap your card on the back of your phone to read balance and transaction history. + Tap your card again to check balance and transaction history. + Last Transaction + View Full History + Transaction History + RECENT ACTIVITY + Export PDF + Open or share PDF + Failed to export PDF + Generated by emoney Info: check your e-money balance and transaction history. + Card + Balance + Date + Transaction + Location + Amount + Settings + General + App + Language + English + Show Card Number + Display the card number after a successful scan. + Help Center + About App + Terms & Conditions + Privacy Policy + How can we help you today? + Search questions or answers + No results found + Still need help? + Send us an email and we\'ll get back to you within 1–2 business days. + Email Support + Check supported e-money cards with NFC, view balance, and review transaction history in one place. + Supports Mandiri e-Money, Flazz, Brizzi, TapCash, JackCard, MegaCash, and KMT. + © Emoney Info + Ready to scan NFC card + This device does not support NFC. + NFC is turned off. Enable NFC in Settings, then tap your card again. + No transactions found. + E-Money Card + Card number hidden + No transaction yet + Scan a card to load recent history + Rp 0 + Location + No transaction history has been read for this card yet. + %1$d transactions + Ready to scan + Scan result + Card Number + Copy card number + Copied to clipboard + Card Type + LAST ACTIVITY + Latest reader message + Card overview + RECENT TRANSACTIONS + Tap a supported card to read its balance and activity. + App preferences and support + HELP CENTER + Search + ABOUT + NFC ready architecture + Android port prepared for ISO-DEP and FeliCa flows, with parser work following the iOS architecture. + Top Up + Payment + E-Money Card + Mandiri e-Money + BCA Flazz + BRIZZI + TapCash + JackCard + MegaCash + KMT + Failed to read card: %1$s + Unknown error + Card not supported + Failed to read Brizzi card number + Failed Brizzi process step 1 + Failed Brizzi process step 2 + Failed Brizzi process step 3 + Failed Brizzi balance read + Failed to read Mandiri card number + Failed to read Mandiri balance + Brizzi card read successfully. + Brizzi card and transaction history read successfully. + Flazz card read successfully. + Flazz card and transaction history read successfully. + Flazz Classic card detected. Basic card data was read; detailed balance and history parsing is still limited. + TapCash detected but purse data could not be read. + TapCash card read successfully. + TapCash card and recent history read successfully. + Mandiri e-Money card read successfully. This older card log format is not ported yet. + Mandiri e-Money card and transaction log read successfully. + JackCard read successfully. + MegaCash read successfully. + KMT card and travel history read successfully. + Reactivation + Void + Update Balance + Transaction + Black List Card + Statement Fee + Grace Period + Refund + Close + ATU + Cards + Transactions + Balance + App + What cards are supported? + The app supports Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JackCard, MegaCash and KMT. Make sure your Android phone supports NFC. + Why is my card not detected? + Make sure NFC is enabled on your Android phone. Hold the card flat against the back of your phone near the NFC antenna and keep it still during scanning. + Card read keeps failing — what should I do? + Try removing any thick phone case, clean the card surface, and try again. If the issue persists, the card may be damaged. + Why are my transactions not showing? + The app reads the recent transactions stored on the card chip itself. Older transactions may not be accessible via NFC. + How do I export transactions to PDF? + After scanning your card, open View Full History, then tap the Export PDF button. You can then share it through WhatsApp, email, or other apps. + The balance shown doesn\'t match. Why? + The app reads balance directly from the card chip in real time. Differences may occur if a recent top-up has not yet been synced to the chip. + Can I top up my card through the app? + No, this app is a read-only reader. Top-up must be done via your bank\'s official app, ATM, or merchant. + How do I change the app language? + Open Settings → Language. The app follows your selection and changes the visible text immediately. + What does Show Card Number do? + When enabled, the full card number is shown on the home screen. When disabled, the first 12 digits are masked for privacy in public spaces. + Privacy first + You can mask card numbers and keep scan details easier to review. + FAQ and support guidance + Version 1.0.0 + This Android port follows the same product direction as the iOS app. Final legal content should be copied from the production source before release. + 1. The app reads supported NFC cards locally on the device. + 2. The app does not modify card balances or write card data. + 3. Ad placements and analytics should be reviewed before release. + NFC reads are processed locally on the device. Release privacy text should be aligned with the final Android implementation and AdMob usage. + 1. Card scans are initiated by the user. + 2. No write operation is performed on cards. + 3. Advertising SDK behavior must be reviewed for production compliance. + Back + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8f99d16 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..90d21cf --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/xml/nfc_tech_filter.xml b/app/src/main/res/xml/nfc_tech_filter.xml new file mode 100644 index 0000000..2906dd9 --- /dev/null +++ b/app/src/main/res/xml/nfc_tech_filter.xml @@ -0,0 +1,9 @@ + + + + android.nfc.tech.IsoDep + + + android.nfc.tech.NfcF + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4893ab4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.2.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false +} diff --git a/docs/CODE_DOCUMENTATION.md b/docs/CODE_DOCUMENTATION.md new file mode 100644 index 0000000..82365db --- /dev/null +++ b/docs/CODE_DOCUMENTATION.md @@ -0,0 +1,414 @@ +# Code Documentation + +Dokumen ini menjelaskan struktur code Android `Emoney Info` agar developer lain bisa lebih cepat memahami project, mencari area yang relevan, dan melakukan perubahan dengan aman. + +## 1. Ringkasan Project + +Project ini adalah versi Android dari aplikasi iOS `Emoney Info`. + +Fungsi utama aplikasi: +- membaca kartu uang elektronik via NFC +- menampilkan saldo dan riwayat transaksi +- menampilkan halaman bantuan dan informasi aplikasi +- mengekspor history transaksi ke PDF +- menampilkan iklan AdMob banner dan interstitial + +Stack utama: +- Kotlin +- Jetpack Compose +- Android NFC (`IsoDep`, `NfcF`, `MifareClassic`) +- Google Mobile Ads SDK + +Package utama: +- `com.korancrew.emoneyinfo` + +## 2. Struktur Folder + +Struktur code utama ada di: + +```text +app/src/main/java/com/korancrew/emoneyinfo +├── ads +├── data +├── nfc +├── pdf +├── ui +├── util +└── MainActivity.kt +``` + +Penjelasan singkat: + +- `ads` + Menyimpan konfigurasi AdMob. + +- `data` + Menyimpan model UI utama dan data FAQ. + +- `nfc` + Berisi seluruh logic pembacaan kartu, helper APDU/FeliCa, kriptografi Brizzi, dan router NFC. + +- `pdf` + Logic export history transaksi menjadi PDF. + +- `ui` + Semua screen Compose, komponen UI, tema, dan root app navigation. + +- `util` + Helper umum. Saat ini dipakai untuk logging debug-only. + +## 3. Entry Point Aplikasi + +File utama: +- [MainActivity.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/MainActivity.kt) + +Tanggung jawab `MainActivity`: +- mengaktifkan theme utama +- inisialisasi AdMob +- membuat `UnifiedNfcReader` +- me-render root Compose lewat `EmoneyInfoApp` +- meneruskan event NFC dari `onNewIntent` +- mengaktifkan dan menonaktifkan foreground dispatch NFC pada `onResume` / `onPause` + +Secara sederhana, `MainActivity` adalah jembatan antara lifecycle Android dengan UI dan NFC reader. + +## 4. Root UI dan Navigasi + +File utama: +- [EmoneyInfoApp.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ui/EmoneyInfoApp.kt) + +`EmoneyInfoApp` adalah root Compose application. File ini menangani: +- bottom navigation +- `NavHost` dan route screen +- membaca `uiState` dari `UnifiedNfcReader` +- membaca dan menyimpan preference `show card number` + +Route utama: +- `home` +- `settings` +- `history` +- `faq` +- `about` +- `terms` +- `privacy` + +Bottom navigation hanya menampilkan: +- `E-Money` +- `Settings` + +## 5. Screen Utama + +Screen Compose ada di folder: +- `/ui/screens` + +Yang paling penting: + +- [HomeScreen.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ui/screens/HomeScreen.kt) + Menampilkan hasil scan terakhir, saldo, nomor kartu, dan transaksi terakhir. + +- [HistoryScreen.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ui/screens/HistoryScreen.kt) + Menampilkan list riwayat transaksi, banner ads, dan tombol export PDF fixed di bawah. + +- [SettingsScreen.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ui/screens/SettingsScreen.kt) + Menampilkan 4 menu utama: bahasa, tampilkan nomor kartu, pusat bantuan, dan tentang aplikasi. + +- [FaqScreen.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ui/screens/FaqScreen.kt) + Menampilkan FAQ dan pencarian pertanyaan. + +- [AboutScreen.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ui/screens/AboutScreen.kt) + Menampilkan info aplikasi dan navigasi ke terms/privacy. + +## 6. Model Data UI + +File utama: +- [Models.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/data/Models.kt) + +Model inti: + +### `CardType` +Enum untuk jenis kartu: +- `MANDIRI` +- `FLAZZ` +- `BRIZZI` +- `TAPCASH` +- `JACKCARD` +- `MEGACASH` +- `KMT` + +### `TransactionItem` +Mewakili 1 item riwayat transaksi. + +Field penting: +- `title` +- `date` +- `amount` +- `isCredit` +- `locationName` + +Method penting: +- `formattedAmount()` +- `formattedDate()` +- `subtitle()` + +Catatan: +- currency selalu diformat sebagai `IDR` / `Rp` +- warna amount di UI ditentukan dari `isCredit` + +### `EmoneyUiState` +State utama yang dipakai UI. + +Field penting: +- `cardType` +- `balance` +- `cardNumber` +- `transactions` +- `scanMessage` +- `isNfcSupported` + +Semua screen utama membaca state ini, terutama `HomeScreen` dan `HistoryScreen`. + +## 7. Arsitektur NFC + +Folder utama: +- `/nfc` + +File yang paling penting: +- [UnifiedNfcReader.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/nfc/UnifiedNfcReader.kt) +- [CardReaders.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/nfc/CardReaders.kt) +- [NfcUtils.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/nfc/NfcUtils.kt) +- [BrizziCrypto.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/nfc/BrizziCrypto.kt) +- [AndroidStrings.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/nfc/AndroidStrings.kt) + +### 7.1 `UnifiedNfcReader` + +`UnifiedNfcReader` adalah orchestrator NFC aplikasi. + +Tanggung jawab: +- mengecek apakah device support NFC +- menyimpan `uiState` dalam `StateFlow` +- menerima `Tag` dari `onNewIntent` +- memilih reader yang sesuai berdasarkan teknologi kartu +- mengubah hasil pembacaan menjadi `EmoneyUiState` +- mengatur pesan UI seperti `tap card hint` dan `tap again hint` + +Reader yang didaftarkan saat ini: +- `IsoDepCardRouter()` +- `MifareClassicCardReader()` +- `FelicaCardReader()` + +### 7.2 `CardReaders.kt` + +File ini berisi implementasi pembacaan kartu per keluarga teknologi dan per jenis kartu. + +Struktur utamanya: +- `CardReader` +- `IsoDepCardRouter` +- `FelicaCardReader` +- `MifareClassicCardReader` +- object reader spesifik, seperti `BrizziReader`, `MandiriReader`, `FlazzReader`, `TapCashReader`, dan lainnya + +Alur umum: +1. router menentukan teknologi kartu +2. router memanggil reader spesifik +3. reader mengirim command APDU atau FeliCa +4. response di-parse menjadi saldo, nomor kartu, dan history +5. hasil akhir dikembalikan sebagai `EmoneyUiState` + +### 7.3 `NfcUtils.kt` + +File ini berisi helper low-level: +- `ApduResponse` +- `transceiveApdu` +- `transceiveSelect` +- helper FeliCa read +- helper hex, endian, format card number, parsing tanggal + +Ini adalah file utility inti untuk operasi byte-level dan APDU. + +### 7.4 `BrizziCrypto.kt` + +Khusus untuk flow Brizzi yang membutuhkan proses auth/crypto. + +File ini sebaiknya disentuh dengan hati-hati karena error kecil pada: +- mode cipher +- IV +- padding +- urutan challenge + +bisa membuat kartu gagal dibaca. + +## 8. Dukungan Kartu + +Kartu yang saat ini sudah menjadi target utama: +- Brizzi +- Flazz BCA +- Mandiri e-Money +- BNI TapCash +- KMT +- JackCard +- MegaCash + +Catatan penting: +- tidak semua kartu memakai flow yang sama +- beberapa kartu memakai `IsoDep` +- KMT memakai `NfcF` +- ada fallback `MifareClassic` untuk kasus tertentu + +Untuk perubahan parser, validasi terbaik tetap lewat test pada kartu fisik nyata. + +## 9. Alur Scan NFC + +Secara sederhana: + +1. `MainActivity.onResume()` mengaktifkan foreground dispatch +2. user menempelkan kartu +3. Android mengirim `Intent` NFC ke activity yang sedang aktif +4. `MainActivity.onNewIntent()` meneruskan intent ke `UnifiedNfcReader` +5. `UnifiedNfcReader` memilih reader yang cocok +6. reader membaca data kartu +7. hasil scan dimasukkan ke `uiState` +8. UI Compose otomatis recompose dan menampilkan saldo/history terbaru + +Setelah scan berhasil, `scanMessage` akan berubah lalu kembali ke hint scan ulang setelah 5 detik. + +## 10. Localization + +String resource: +- [values/strings.xml](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/res/values/strings.xml) +- [values-id/strings.xml](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/res/values-id/strings.xml) + +App mendukung: +- Inggris +- Indonesia + +Catatan: +- bahasa UI mengikuti locale device +- currency tetap `IDR` +- beberapa pesan scan dari layer NFC juga dilokalisasi melalui `AndroidStrings` + +## 11. AdMob + +File utama: +- [AdMobConfig.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ads/AdMobConfig.kt) +- [AdMobBanner.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ui/components/AdMobBanner.kt) + +Penempatan saat ini: +- banner di `Home` +- banner di `History` +- banner di `Settings` +- interstitial sebelum export PDF + +Catatan implementasi: +- `test device IDs` hanya diaktifkan pada `BuildConfig.DEBUG` +- logging ads dibatasi ke debug build melalui `AppLog` + +## 12. Export PDF + +File utama: +- [HistoryPdfExporter.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/pdf/HistoryPdfExporter.kt) + +Fungsi utama: +- menghasilkan PDF dari `EmoneyUiState` +- menyertakan tipe kartu, saldo, nomor kartu, dan tabel transaksi +- membuka chooser agar file bisa dibuka atau dibagikan + +Flow dari UI: +1. user menekan `Export PDF` +2. jika interstitial tersedia, iklan ditampilkan dulu +3. setelah iklan selesai atau gagal tampil, PDF dibuat +4. file dibuka lewat chooser + +## 13. Preference Lokal + +Saat ini preference utama yang dipakai: +- `masked` + +Disimpan di: +- `SharedPreferences("emoney_info_prefs")` + +Dipakai untuk: +- menentukan apakah nomor kartu ditampilkan penuh atau dimasking + +Catatan: +- key `masked` saat ini bernilai `true` ketika nomor kartu ditampilkan penuh +- nama key ini tidak ideal secara semantik +- kalau nanti ingin dirapikan, sebaiknya diganti menjadi key yang lebih jelas, misalnya `show_card_number` + +## 14. Logging dan Build Production + +File utama: +- [AppLog.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/util/AppLog.kt) +- [app/build.gradle.kts](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/build.gradle.kts) + +Prinsip saat ini: +- log sensitif hanya aktif di debug build +- release build memakai: + - `minifyEnabled = true` + - `shrinkResources = true` + +Ini penting karena flow NFC dan ads punya banyak data internal yang tidak perlu muncul di production log. + +## 15. File Yang Paling Sering Diedit + +Kalau ingin ubah tampilan: +- `/ui/screens` +- `/ui/theme` +- `/res/values` + +Kalau ingin ubah parser kartu: +- `/nfc/CardReaders.kt` +- `/nfc/NfcUtils.kt` +- `/nfc/BrizziCrypto.kt` + +Kalau ingin ubah model dan formatting: +- `/data/Models.kt` + +Kalau ingin ubah ads: +- `/ads/AdMobConfig.kt` +- `/ui/components/AdMobBanner.kt` + +Kalau ingin ubah PDF: +- `/pdf/HistoryPdfExporter.kt` + +## 16. Risiko dan Catatan Maintenance + +Beberapa area sensitif: + +- parser NFC + Perubahan kecil bisa memutus pembacaan kartu tertentu. + +- flow Brizzi + Paling sensitif karena ada auth dan kriptografi. + +- formatting UI berdasarkan `locationName` + KMT memakai lokasi valid, tapi kartu lain tidak selalu punya mapping lokasi yang berguna. + +- preference `masked` + Namanya membingungkan dan bisa memicu bug kalau dibaca tanpa konteks. + +- ads production + Pastikan testing device ID tidak dipakai untuk release path, dan lakukan uji di device nyata. + +## 17. Saran Pengembangan Selanjutnya + +Beberapa perbaikan yang layak dipertimbangkan: + +- pecah `CardReaders.kt` menjadi beberapa file per kartu agar lebih mudah dirawat +- ganti `SharedPreferences` sederhana ke wrapper preference yang lebih eksplisit +- buat test parser terpisah untuk data response yang sudah diketahui +- tambahkan dokumentasi mapping kartu dan APDU per provider +- rapikan naming key `masked` + +## 18. Ringkasan Cepat + +Kalau hanya ingin memahami project dengan cepat, mulai dari file ini: + +1. [MainActivity.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/MainActivity.kt) +2. [EmoneyInfoApp.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ui/EmoneyInfoApp.kt) +3. [Models.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/data/Models.kt) +4. [UnifiedNfcReader.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/nfc/UnifiedNfcReader.kt) +5. [CardReaders.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/nfc/CardReaders.kt) +6. [HistoryScreen.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/ui/screens/HistoryScreen.kt) +7. [HistoryPdfExporter.kt](/Users/wirabasalamah/work/gitlab/EmoneyInfo-andro/app/src/main/java/com/korancrew/emoneyinfo/pdf/HistoryPdfExporter.kt) + +Dengan urutan itu, sebagian besar arsitektur project sudah akan terlihat. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8f2e28c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2733ed5 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..85aacdb --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Emoney Info" +include(":app")