Initial import

This commit is contained in:
2026-04-22 22:31:52 +07:00
commit ef756b97a1
71 changed files with 4518 additions and 0 deletions

74
app/build.gradle.kts Normal file
View File

@ -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")
}

1
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
# Intentionally empty for now.

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.EmoneyInfo">
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-3389368171983845~3596282656" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.EmoneyInfo.Launch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED" />
</intent-filter>
<meta-data
android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
</activity>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -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
}
}
}

View File

@ -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"
)
}

View File

@ -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<FaqItem>
)
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)
)
)
)
}

View File

@ -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<TransactionItem> = 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)}"
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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<TransactionItem> {
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<TransactionItem> {
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<TransactionItem> {
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<TransactionItem> {
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<TransactionItem> {
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<TransactionItem> {
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<TransactionItem>()
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<TransactionItem> {
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<TransactionItem> {
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()
}
}

View File

@ -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<Int>
): List<ByteArray> {
val packet = mutableListOf<Byte>()
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))
}

View File

@ -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<CardReader> = listOf(IsoDepCardRouter(), FelicaCardReader())
private var resetMessageJob: Job? = null
private val _uiState = MutableStateFlow(
EmoneyUiState(
isNfcSupported = adapter != null,
scanMessage = currentScanMessage()
)
)
val uiState: StateFlow<EmoneyUiState> = _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)
}
}

View File

@ -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)
}

View File

@ -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() })
}
}
}
}

View File

@ -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 }
)
}

View File

@ -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)
)
}
}
)
}

View File

@ -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)
)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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<InterstitialAd?>(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
)
}
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}
}

View File

@ -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)

View File

@ -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
)
}

View File

@ -0,0 +1,5 @@
package com.iiyh.emoneyinfo.ui.theme
import androidx.compose.material3.Typography
val Typography = Typography()

View File

@ -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)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,3 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F3F3F8" />
</shape>

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#7AD4D1"
android:pathData="M16,16h76a12,12 0 0 1 12,12v52a12,12 0 0 1 -12,12H16A12,12 0 0 1 4,80V28A12,12 0 0 1 16,16z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M34,38c12,0 22,10 22,22h-8c0,-7.7 -6.3,-14 -14,-14z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M34,50c5.5,0 10,4.5 10,10h-8c0,-1.1 -0.9,-2 -2,-2z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M72,32h8v44h-8z" />
</vector>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<item>
<bitmap
android:src="@drawable/app_logo"
android:gravity="center" />
</item>
</layer-list>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,150 @@
<resources>
<string name="app_name">Emoney Info</string>
<string name="tab_home">E-Money</string>
<string name="tab_settings">Pengaturan</string>
<string name="available_balance">SALDO TERSEDIA</string>
<string name="check_balance">Cek Saldo</string>
<string name="tap_card_hint">Tempelkan kartu di bagian belakang ponsel untuk membaca saldo dan riwayat transaksi.</string>
<string name="tap_again_hint">Tempelkan kembali kartu untuk mengecek saldo dan riwayat transaksi.</string>
<string name="last_transaction">Transaksi Terakhir</string>
<string name="view_full_history">Lihat Semua Riwayat</string>
<string name="history_title">Riwayat Transaksi</string>
<string name="recent_activity">AKTIVITAS TERBARU</string>
<string name="export_pdf">Ekspor PDF</string>
<string name="pdf_open_or_share">Buka atau bagikan PDF</string>
<string name="pdf_export_failed">Gagal mengekspor PDF</string>
<string name="pdf_subtitle">Dibuat oleh aplikasi emoney Info: cek saldo dan riwayat uang elektronik.</string>
<string name="pdf_card_label">Kartu</string>
<string name="pdf_balance_label">Saldo</string>
<string name="pdf_date_label">Tanggal</string>
<string name="pdf_transaction_label">Transaksi</string>
<string name="pdf_location_label">Lokasi</string>
<string name="pdf_amount_label">Jumlah</string>
<string name="settings_title">Pengaturan</string>
<string name="section_general">Umum</string>
<string name="section_app">Aplikasi</string>
<string name="language">Bahasa</string>
<string name="language_value">Bahasa Indonesia</string>
<string name="show_card_number">Tampilkan Nomor Kartu</string>
<string name="show_card_number_desc">Tampilkan nomor kartu setelah proses scan berhasil.</string>
<string name="help_center">Pusat Bantuan</string>
<string name="about_app">Tentang Aplikasi</string>
<string name="terms_conditions">Syarat &amp; Ketentuan</string>
<string name="privacy_policy">Kebijakan Privasi</string>
<string name="faq_header">Apa yang bisa kami bantu hari ini?</string>
<string name="faq_search_placeholder">Cari pertanyaan atau jawaban</string>
<string name="faq_no_results">Tidak ada hasil ditemukan</string>
<string name="faq_help_card_title">Masih butuh bantuan?</string>
<string name="faq_help_card_desc">Kirim email ke kami dan kami akan membalas dalam 12 hari kerja.</string>
<string name="faq_email_support">Email Support</string>
<string name="about_description">Cek kartu e-money yang didukung dengan NFC, lihat saldo, dan tinjau riwayat transaksi di satu tempat.</string>
<string name="support_cards">Mendukung Mandiri e-Money, Flazz, Brizzi, TapCash, JackCard, MegaCash, dan KMT.</string>
<string name="footer_copyright">© Emoney Info</string>
<string name="scan_message">Siap memindai kartu NFC</string>
<string name="nfc_not_supported">Perangkat ini tidak mendukung NFC.</string>
<string name="nfc_disabled">NFC sedang dimatikan. Aktifkan NFC di Pengaturan, lalu tempelkan kembali kartu Anda.</string>
<string name="no_history">Tidak ada transaksi ditemukan.</string>
<string name="placeholder_card_type">Kartu E-Money</string>
<string name="placeholder_card_number">Nomor kartu disembunyikan</string>
<string name="placeholder_transaction_title">Belum ada transaksi</string>
<string name="placeholder_transaction_date">Scan kartu untuk memuat riwayat terbaru</string>
<string name="placeholder_transaction_amount">Rp 0</string>
<string name="transaction_location">Lokasi</string>
<string name="history_summary_empty">Belum ada riwayat transaksi yang berhasil dibaca untuk kartu ini.</string>
<string name="history_summary_count">%1$d transaksi</string>
<string name="scan_ready_title">Siap dipindai</string>
<string name="scan_result_title">Hasil scan</string>
<string name="card_number_label">Nomor Kartu</string>
<string name="copy_card_number">Salin nomor kartu</string>
<string name="copied_to_clipboard">Disalin ke clipboard</string>
<string name="card_type_label">Jenis Kartu</string>
<string name="last_activity_label">AKTIVITAS TERAKHIR</string>
<string name="latest_scan_message">Pesan pembaca terakhir</string>
<string name="history_summary_card">Ringkasan kartu</string>
<string name="history_section_title">TRANSAKSI TERBARU</string>
<string name="scan_to_read">Tempelkan kartu yang didukung untuk membaca saldo dan aktivitasnya.</string>
<string name="settings_header_title">Preferensi aplikasi dan dukungan</string>
<string name="faq_section_title">PUSAT BANTUAN</string>
<string name="faq_search">Cari</string>
<string name="about_section_title">TENTANG</string>
<string name="about_architecture_title">Arsitektur siap NFC</string>
<string name="about_architecture_desc">Port Android disiapkan untuk alur ISO-DEP dan FeliCa, dengan parser yang mengikuti arsitektur iOS.</string>
<string name="topup">Isi Ulang</string>
<string name="payment">Pembayaran</string>
<string name="card_unknown">Kartu E-Money</string>
<string name="card_mandiri">Mandiri e-Money</string>
<string name="card_flazz">BCA Flazz</string>
<string name="card_brizzi">BRIZZI</string>
<string name="card_tapcash">TapCash</string>
<string name="card_jackcard">JackCard</string>
<string name="card_megacash">MegaCash</string>
<string name="card_kmt">KMT</string>
<string name="scan_failed_message">Gagal membaca kartu: %1$s</string>
<string name="unknown_error">Kesalahan tidak diketahui</string>
<string name="card_not_supported">Kartu tidak didukung</string>
<string name="error_brizzi_card_number">Gagal membaca nomor kartu Brizzi</string>
<string name="error_brizzi_step1">Gagal pada proses Brizzi langkah 1</string>
<string name="error_brizzi_step2">Gagal pada proses Brizzi langkah 2</string>
<string name="error_brizzi_step3">Gagal pada proses Brizzi langkah 3</string>
<string name="error_brizzi_balance">Gagal membaca saldo Brizzi</string>
<string name="error_mandiri_card_number">Gagal membaca nomor kartu Mandiri</string>
<string name="error_mandiri_balance">Gagal membaca saldo Mandiri</string>
<string name="scan_brizzi_success">Kartu Brizzi berhasil dibaca.</string>
<string name="scan_brizzi_history_success">Kartu Brizzi dan riwayat transaksinya berhasil dibaca.</string>
<string name="scan_flazz_success">Kartu Flazz berhasil dibaca.</string>
<string name="scan_flazz_history_success">Kartu Flazz dan riwayat transaksinya berhasil dibaca.</string>
<string name="scan_flazz_classic_detected">Kartu Flazz Classic terdeteksi. Data dasar kartu berhasil dibaca; parsing saldo dan riwayat detail masih terbatas.</string>
<string name="scan_tapcash_detected_partial">TapCash terdeteksi tetapi data purse tidak dapat dibaca.</string>
<string name="scan_tapcash_success">Kartu TapCash berhasil dibaca.</string>
<string name="scan_tapcash_history_success">Kartu TapCash dan riwayat terbarunya berhasil dibaca.</string>
<string name="scan_mandiri_success">Kartu Mandiri e-Money berhasil dibaca. Format log kartu lama ini belum di-port.</string>
<string name="scan_mandiri_history_success">Kartu Mandiri e-Money dan log transaksinya berhasil dibaca.</string>
<string name="scan_jackcard_success">JackCard berhasil dibaca.</string>
<string name="scan_megacash_success">MegaCash berhasil dibaca.</string>
<string name="scan_kmt_history_success">Kartu KMT dan riwayat perjalanannya berhasil dibaca.</string>
<string name="tx_reactivation">Reaktivasi</string>
<string name="tx_void">Void</string>
<string name="tx_update_balance">Pembaruan Saldo</string>
<string name="tx_transaction">Transaksi</string>
<string name="tx_black_list_card">Kartu Black List</string>
<string name="tx_statement_fee">Biaya Laporan</string>
<string name="tx_grace_period">Masa Tenggang</string>
<string name="tx_refund">Refund</string>
<string name="tx_close">Tutup</string>
<string name="tx_atu">ATU</string>
<string name="faq_category_cards">Kartu</string>
<string name="faq_category_transactions">Transaksi</string>
<string name="faq_category_balance">Keuangan</string>
<string name="faq_category_app">Aplikasi</string>
<string name="faq_q_supported_cards">Kartu apa saja yang didukung?</string>
<string name="faq_a_supported_cards">Aplikasi mendukung Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JackCard, MegaCash, dan KMT. Pastikan ponsel Android Anda mendukung NFC.</string>
<string name="faq_q_card_not_detected">Mengapa kartu saya tidak terdeteksi?</string>
<string name="faq_a_card_not_detected">Pastikan NFC aktif di ponsel Android Anda. Tempelkan kartu secara rata di bagian belakang ponsel dekat antena NFC dan tahan diam selama pemindaian.</string>
<string name="faq_q_card_read_failed">Kartu gagal dibaca terus, apa yang harus dilakukan?</string>
<string name="faq_a_card_read_failed">Coba lepas casing tebal, bersihkan permukaan kartu, lalu coba lagi. Jika masalah berlanjut, kartu mungkin rusak.</string>
<string name="faq_q_transactions_not_shown">Mengapa transaksi saya tidak muncul?</string>
<string name="faq_a_transactions_not_shown">Aplikasi membaca transaksi terbaru yang tersimpan di chip kartu. Transaksi yang lebih lama mungkin tidak dapat diakses melalui NFC.</string>
<string name="faq_q_export_pdf">Cara ekspor riwayat ke PDF?</string>
<string name="faq_a_export_pdf">Setelah scan kartu, buka Lihat Semua Riwayat, lalu tekan tombol Ekspor PDF. Anda dapat membagikannya lewat WhatsApp, email, dan aplikasi lainnya.</string>
<string name="faq_q_balance_wrong">Saldo yang ditampilkan tidak sesuai, kenapa?</string>
<string name="faq_a_balance_wrong">Aplikasi membaca saldo langsung dari chip kartu secara real time. Perbedaan dapat terjadi jika top-up terbaru belum tersinkron ke chip.</string>
<string name="faq_q_balance_topup">Apakah bisa isi ulang saldo lewat aplikasi ini?</string>
<string name="faq_a_balance_topup">Tidak, aplikasi ini hanya bisa membaca saldo. Isi ulang harus dilakukan melalui aplikasi resmi bank, ATM, atau merchant.</string>
<string name="faq_q_app_language">Bagaimana cara ganti bahasa aplikasi?</string>
<string name="faq_a_app_language">Buka Pengaturan → Bahasa. Aplikasi mengikuti pilihan Anda dan langsung mengubah teks yang terlihat.</string>
<string name="faq_q_hide_card_number">Apa fungsi Tampilkan Nomor Kartu?</string>
<string name="faq_a_hide_card_number">Jika diaktifkan, nomor kartu penuh ditampilkan di beranda. Jika dimatikan, 12 digit pertama disamarkan untuk privasi di tempat umum.</string>
<string name="settings_privacy_first">Privasi lebih dulu</string>
<string name="settings_privacy_desc">Anda bisa menyamarkan nomor kartu dan membuat detail scan lebih nyaman ditinjau.</string>
<string name="settings_help_subtitle">FAQ dan panduan dukungan</string>
<string name="settings_about_subtitle">Versi 1.0.0</string>
<string name="terms_intro">Port Android ini mengikuti arah produk yang sama dengan aplikasi iOS. Konten legal final sebaiknya disalin dari sumber produksi sebelum rilis.</string>
<string name="terms_point_1">1. Aplikasi membaca kartu NFC yang didukung secara lokal di perangkat.</string>
<string name="terms_point_2">2. Aplikasi tidak mengubah saldo kartu atau menulis data ke kartu.</string>
<string name="terms_point_3">3. Penempatan iklan dan analitik harus ditinjau sebelum rilis.</string>
<string name="privacy_intro">Pembacaan NFC diproses secara lokal di perangkat. Teks privasi final harus diselaraskan dengan implementasi Android akhir dan penggunaan AdMob.</string>
<string name="privacy_point_1">1. Scan kartu dipicu oleh pengguna.</string>
<string name="privacy_point_2">2. Tidak ada operasi tulis yang dilakukan pada kartu.</string>
<string name="privacy_point_3">3. Perilaku SDK iklan harus ditinjau untuk kepatuhan produksi.</string>
<string name="back">Kembali</string>
</resources>

View File

@ -0,0 +1,9 @@
<resources>
<color name="background">#F3F3F8</color>
<color name="system_bar">#D7DEE8</color>
<color name="primary">#7AD4D1</color>
<color name="secondary">#5D7D7B</color>
<color name="text_primary">#1A1A2E</color>
<color name="text_secondary">#8E8E93</color>
<color name="card">#FFFFFF</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@ -0,0 +1,150 @@
<resources>
<string name="app_name">Emoney Info</string>
<string name="tab_home">E-Money</string>
<string name="tab_settings">Settings</string>
<string name="available_balance">AVAILABLE BALANCE</string>
<string name="check_balance">Check Balance</string>
<string name="tap_card_hint">Tap your card on the back of your phone to read balance and transaction history.</string>
<string name="tap_again_hint">Tap your card again to check balance and transaction history.</string>
<string name="last_transaction">Last Transaction</string>
<string name="view_full_history">View Full History</string>
<string name="history_title">Transaction History</string>
<string name="recent_activity">RECENT ACTIVITY</string>
<string name="export_pdf">Export PDF</string>
<string name="pdf_open_or_share">Open or share PDF</string>
<string name="pdf_export_failed">Failed to export PDF</string>
<string name="pdf_subtitle">Generated by emoney Info: check your e-money balance and transaction history.</string>
<string name="pdf_card_label">Card</string>
<string name="pdf_balance_label">Balance</string>
<string name="pdf_date_label">Date</string>
<string name="pdf_transaction_label">Transaction</string>
<string name="pdf_location_label">Location</string>
<string name="pdf_amount_label">Amount</string>
<string name="settings_title">Settings</string>
<string name="section_general">General</string>
<string name="section_app">App</string>
<string name="language">Language</string>
<string name="language_value">English</string>
<string name="show_card_number">Show Card Number</string>
<string name="show_card_number_desc">Display the card number after a successful scan.</string>
<string name="help_center">Help Center</string>
<string name="about_app">About App</string>
<string name="terms_conditions">Terms &amp; Conditions</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="faq_header">How can we help you today?</string>
<string name="faq_search_placeholder">Search questions or answers</string>
<string name="faq_no_results">No results found</string>
<string name="faq_help_card_title">Still need help?</string>
<string name="faq_help_card_desc">Send us an email and we\'ll get back to you within 12 business days.</string>
<string name="faq_email_support">Email Support</string>
<string name="about_description">Check supported e-money cards with NFC, view balance, and review transaction history in one place.</string>
<string name="support_cards">Supports Mandiri e-Money, Flazz, Brizzi, TapCash, JackCard, MegaCash, and KMT.</string>
<string name="footer_copyright">© Emoney Info</string>
<string name="scan_message">Ready to scan NFC card</string>
<string name="nfc_not_supported">This device does not support NFC.</string>
<string name="nfc_disabled">NFC is turned off. Enable NFC in Settings, then tap your card again.</string>
<string name="no_history">No transactions found.</string>
<string name="placeholder_card_type">E-Money Card</string>
<string name="placeholder_card_number">Card number hidden</string>
<string name="placeholder_transaction_title">No transaction yet</string>
<string name="placeholder_transaction_date">Scan a card to load recent history</string>
<string name="placeholder_transaction_amount">Rp 0</string>
<string name="transaction_location">Location</string>
<string name="history_summary_empty">No transaction history has been read for this card yet.</string>
<string name="history_summary_count">%1$d transactions</string>
<string name="scan_ready_title">Ready to scan</string>
<string name="scan_result_title">Scan result</string>
<string name="card_number_label">Card Number</string>
<string name="copy_card_number">Copy card number</string>
<string name="copied_to_clipboard">Copied to clipboard</string>
<string name="card_type_label">Card Type</string>
<string name="last_activity_label">LAST ACTIVITY</string>
<string name="latest_scan_message">Latest reader message</string>
<string name="history_summary_card">Card overview</string>
<string name="history_section_title">RECENT TRANSACTIONS</string>
<string name="scan_to_read">Tap a supported card to read its balance and activity.</string>
<string name="settings_header_title">App preferences and support</string>
<string name="faq_section_title">HELP CENTER</string>
<string name="faq_search">Search</string>
<string name="about_section_title">ABOUT</string>
<string name="about_architecture_title">NFC ready architecture</string>
<string name="about_architecture_desc">Android port prepared for ISO-DEP and FeliCa flows, with parser work following the iOS architecture.</string>
<string name="topup">Top Up</string>
<string name="payment">Payment</string>
<string name="card_unknown">E-Money Card</string>
<string name="card_mandiri">Mandiri e-Money</string>
<string name="card_flazz">BCA Flazz</string>
<string name="card_brizzi">BRIZZI</string>
<string name="card_tapcash">TapCash</string>
<string name="card_jackcard">JackCard</string>
<string name="card_megacash">MegaCash</string>
<string name="card_kmt">KMT</string>
<string name="scan_failed_message">Failed to read card: %1$s</string>
<string name="unknown_error">Unknown error</string>
<string name="card_not_supported">Card not supported</string>
<string name="error_brizzi_card_number">Failed to read Brizzi card number</string>
<string name="error_brizzi_step1">Failed Brizzi process step 1</string>
<string name="error_brizzi_step2">Failed Brizzi process step 2</string>
<string name="error_brizzi_step3">Failed Brizzi process step 3</string>
<string name="error_brizzi_balance">Failed Brizzi balance read</string>
<string name="error_mandiri_card_number">Failed to read Mandiri card number</string>
<string name="error_mandiri_balance">Failed to read Mandiri balance</string>
<string name="scan_brizzi_success">Brizzi card read successfully.</string>
<string name="scan_brizzi_history_success">Brizzi card and transaction history read successfully.</string>
<string name="scan_flazz_success">Flazz card read successfully.</string>
<string name="scan_flazz_history_success">Flazz card and transaction history read successfully.</string>
<string name="scan_flazz_classic_detected">Flazz Classic card detected. Basic card data was read; detailed balance and history parsing is still limited.</string>
<string name="scan_tapcash_detected_partial">TapCash detected but purse data could not be read.</string>
<string name="scan_tapcash_success">TapCash card read successfully.</string>
<string name="scan_tapcash_history_success">TapCash card and recent history read successfully.</string>
<string name="scan_mandiri_success">Mandiri e-Money card read successfully. This older card log format is not ported yet.</string>
<string name="scan_mandiri_history_success">Mandiri e-Money card and transaction log read successfully.</string>
<string name="scan_jackcard_success">JackCard read successfully.</string>
<string name="scan_megacash_success">MegaCash read successfully.</string>
<string name="scan_kmt_history_success">KMT card and travel history read successfully.</string>
<string name="tx_reactivation">Reactivation</string>
<string name="tx_void">Void</string>
<string name="tx_update_balance">Update Balance</string>
<string name="tx_transaction">Transaction</string>
<string name="tx_black_list_card">Black List Card</string>
<string name="tx_statement_fee">Statement Fee</string>
<string name="tx_grace_period">Grace Period</string>
<string name="tx_refund">Refund</string>
<string name="tx_close">Close</string>
<string name="tx_atu">ATU</string>
<string name="faq_category_cards">Cards</string>
<string name="faq_category_transactions">Transactions</string>
<string name="faq_category_balance">Balance</string>
<string name="faq_category_app">App</string>
<string name="faq_q_supported_cards">What cards are supported?</string>
<string name="faq_a_supported_cards">The app supports Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JackCard, MegaCash and KMT. Make sure your Android phone supports NFC.</string>
<string name="faq_q_card_not_detected">Why is my card not detected?</string>
<string name="faq_a_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.</string>
<string name="faq_q_card_read_failed">Card read keeps failing — what should I do?</string>
<string name="faq_a_card_read_failed">Try removing any thick phone case, clean the card surface, and try again. If the issue persists, the card may be damaged.</string>
<string name="faq_q_transactions_not_shown">Why are my transactions not showing?</string>
<string name="faq_a_transactions_not_shown">The app reads the recent transactions stored on the card chip itself. Older transactions may not be accessible via NFC.</string>
<string name="faq_q_export_pdf">How do I export transactions to PDF?</string>
<string name="faq_a_export_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.</string>
<string name="faq_q_balance_wrong">The balance shown doesn\'t match. Why?</string>
<string name="faq_a_balance_wrong">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.</string>
<string name="faq_q_balance_topup">Can I top up my card through the app?</string>
<string name="faq_a_balance_topup">No, this app is a read-only reader. Top-up must be done via your bank\'s official app, ATM, or merchant.</string>
<string name="faq_q_app_language">How do I change the app language?</string>
<string name="faq_a_app_language">Open Settings → Language. The app follows your selection and changes the visible text immediately.</string>
<string name="faq_q_hide_card_number">What does Show Card Number do?</string>
<string name="faq_a_hide_card_number">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.</string>
<string name="settings_privacy_first">Privacy first</string>
<string name="settings_privacy_desc">You can mask card numbers and keep scan details easier to review.</string>
<string name="settings_help_subtitle">FAQ and support guidance</string>
<string name="settings_about_subtitle">Version 1.0.0</string>
<string name="terms_intro">This Android port follows the same product direction as the iOS app. Final legal content should be copied from the production source before release.</string>
<string name="terms_point_1">1. The app reads supported NFC cards locally on the device.</string>
<string name="terms_point_2">2. The app does not modify card balances or write card data.</string>
<string name="terms_point_3">3. Ad placements and analytics should be reviewed before release.</string>
<string name="privacy_intro">NFC reads are processed locally on the device. Release privacy text should be aligned with the final Android implementation and AdMob usage.</string>
<string name="privacy_point_1">1. Card scans are initiated by the user.</string>
<string name="privacy_point_2">2. No write operation is performed on cards.</string>
<string name="privacy_point_3">3. Advertising SDK behavior must be reviewed for production compliance.</string>
<string name="back">Back</string>
</resources>

View File

@ -0,0 +1,14 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.EmoneyInfo.Launch" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
<item name="android:statusBarColor">@color/system_bar</item>
<item name="android:navigationBarColor">@color/system_bar</item>
</style>
<style name="Theme.EmoneyInfo" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@color/system_bar</item>
<item name="android:navigationBarColor">@color/system_bar</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
</style>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="exported_pdfs"
path="." />
</paths>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<tech-list>
<tech>android.nfc.tech.IsoDep</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.NfcF</tech>
</tech-list>
</resources>