commit d8f8327e2883c3a8c2212fb22ff0a7e5878825ca
Author: JoeAllen <2494136784@qq.com>
Date: Fri Jul 4 17:18:37 2025 +0800
feat: 添加蓝牙读卡功能- 新增蓝牙帮助类和读卡相关 Activity
- 实现蓝牙设备扫描、连接和读卡功能
- 添加卡信息处理和展示逻辑
- 集成第三方库实现 EMV 进程和数据解析
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..359bb53
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..d17e107
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+i9-bl-test
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000..371f2e2
--- /dev/null
+++ b/.idea/appInsightsSettings.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..b268ef3
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..97f0a8e
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..8d66637
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..74dd639
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..d843f34
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..a966338
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,44 @@
+plugins {
+ alias(libs.plugins.android.application)
+}
+
+android {
+ namespace 'com.serverapk.i9_bl_test'
+ compileSdk 36
+
+ defaultConfig {
+ applicationId "com.serverapk.i9_bl_test"
+ minSdk 24
+ targetSdk 36
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+}
+
+dependencies {
+
+ implementation libs.appcompat
+ implementation libs.material
+ implementation libs.activity
+ implementation libs.constraintlayout
+ implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'], exclude: [])
+ testImplementation libs.junit
+ androidTestImplementation libs.ext.junit
+ androidTestImplementation libs.espresso.core
+
+ implementation 'com.google.code.gson:gson:2.8.9'
+
+}
diff --git a/app/libs/j2xx.jar b/app/libs/j2xx.jar
new file mode 100644
index 0000000..3207ef0
Binary files /dev/null and b/app/libs/j2xx.jar differ
diff --git a/app/libs/pl2303g_driver.jar b/app/libs/pl2303g_driver.jar
new file mode 100644
index 0000000..c1945b5
Binary files /dev/null and b/app/libs/pl2303g_driver.jar differ
diff --git a/app/libs/pl2303lib_v2.0.4.10.jar b/app/libs/pl2303lib_v2.0.4.10.jar
new file mode 100644
index 0000000..2fb3ce4
Binary files /dev/null and b/app/libs/pl2303lib_v2.0.4.10.jar differ
diff --git a/app/libs/posDeviceSDKV2.2.4.jar b/app/libs/posDeviceSDKV2.2.4.jar
new file mode 100644
index 0000000..56c2abe
Binary files /dev/null and b/app/libs/posDeviceSDKV2.2.4.jar differ
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/serverapk/i9_bl_test/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/serverapk/i9_bl_test/ExampleInstrumentedTest.java
new file mode 100644
index 0000000..35e955c
--- /dev/null
+++ b/app/src/androidTest/java/com/serverapk/i9_bl_test/ExampleInstrumentedTest.java
@@ -0,0 +1,26 @@
+package com.serverapk.i9_bl_test;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.*;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see Testing documentation
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+ @Test
+ public void useAppContext() {
+ // Context of the app under test.
+ Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ assertEquals("com.serverapk.i9_bl_test", appContext.getPackageName());
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..96f9e55
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/serverapk/i9_bl_test/BluetoothHelper.java b/app/src/main/java/com/serverapk/i9_bl_test/BluetoothHelper.java
new file mode 100644
index 0000000..f2455ad
--- /dev/null
+++ b/app/src/main/java/com/serverapk/i9_bl_test/BluetoothHelper.java
@@ -0,0 +1,54 @@
+package com.serverapk.i9_bl_test;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.util.Log;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@SuppressLint("MissingPermission")
+public class BluetoothHelper {
+
+ private static final String TAG = "BluetoothHelper";
+
+ public static Set getConnectedBluetoothDevices(Context context, ServiceListener listener) {
+ BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+
+ if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
+ Log.w(TAG, "蓝牙未启用");
+ return new HashSet<>();
+ }
+
+ Set pairedDevices = bluetoothAdapter.getBondedDevices();
+ for (BluetoothDevice device : pairedDevices) {
+ Log.d(TAG, "已配对设备: " + device.getName() + " - " + device.getAddress());
+ }
+
+ bluetoothAdapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() {
+ @Override
+ public void onServiceConnected(int profile, BluetoothProfile proxy) {
+ List connectedDevices = proxy.getConnectedDevices();
+ for (BluetoothDevice device : connectedDevices) {
+ Log.d(TAG, "已连接: " + device.getName() + " - " + device.getAddress());
+ }
+ listener.onServiceConnected(connectedDevices);
+ }
+
+ @Override
+ public void onServiceDisconnected(int profile) {
+ Log.d(TAG, "HEADSET profile 已断开");
+ }
+ }, BluetoothProfile.HEADSET);
+
+ return pairedDevices;
+ }
+
+ public interface ServiceListener {
+ void onServiceConnected(List devices);
+ }
+}
diff --git a/app/src/main/java/com/serverapk/i9_bl_test/MainActivity.java b/app/src/main/java/com/serverapk/i9_bl_test/MainActivity.java
new file mode 100644
index 0000000..0c322a9
--- /dev/null
+++ b/app/src/main/java/com/serverapk/i9_bl_test/MainActivity.java
@@ -0,0 +1,143 @@
+package com.serverapk.i9_bl_test;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import androidx.activity.EdgeToEdge;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+
+import com.company.i9sslclient.bluetooth.DeviceInfo;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@SuppressLint("MissingPermission")
+public class MainActivity extends AppCompatActivity {
+ private static final String TAG = "MainActivity";
+ private static final long SCAN_PERIOD = 10000;
+ Handler handler = new Handler(Looper.getMainLooper());
+ private BluetoothAdapter mBluetoothAdapter;
+ private boolean mScanning;
+ private List devices;
+ private List deviceInfoList;
+ private String mac;
+ BluetoothAdapter.LeScanCallback leScanCallback = (device, rssi, scanRecord) -> setMac(device);
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ EdgeToEdge.enable(this);
+ setContentView(R.layout.activity_main);
+ ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
+ Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
+ return insets;
+ });
+
+ findViewById(R.id.test).setOnClickListener(view -> {
+ if (mac == null || mac.isEmpty() || mac.isBlank()) {
+ Log.e(TAG, "未找到读卡器");
+ return;
+ }
+ Intent intent = new Intent(MainActivity.this, ReadCardActivity.class);
+ intent.putExtra("mac", mac);
+ startActivity(intent);
+ });
+ init();
+ }
+
+ public void setMac(BluetoothDevice device) {
+ int size = devices.size();
+ if (size > 0) {
+ boolean tmp = false;
+
+ for (int i = 0; i < size; i++) {
+ if (device.getAddress().equalsIgnoreCase(devices.get(i))) {
+ tmp = true;
+ break;
+ }
+ }
+
+ if (!tmp) {
+ if (device.getName() != null) {
+ Log.d(TAG, "device.getName()==" + device.getName());
+ devices.add(device.getAddress());
+ deviceInfoList.add(new DeviceInfo(device.getName(), device.getAddress()));
+ for (DeviceInfo info : deviceInfoList) {
+ if (info.getName().contains("I9")) {
+ mac = info.getIdentifier();
+
+ }
+ }
+ }
+ }
+ } else {
+ if (device.getName() != null) {
+ Log.d(TAG, "device.getName()==" + device.getName());
+ devices.add(device.getAddress());
+ deviceInfoList.add(new DeviceInfo(device.getName(), device.getAddress()));
+
+ for (DeviceInfo info : deviceInfoList) {
+ if (info.getName().contains("I9")) {
+ mac = info.getIdentifier();
+ }
+ }
+ }
+ }
+ }
+
+
+ public void scanLeDevice(final boolean enable) {
+ Log.d(TAG, "扫描 LE 设备 " + enable);
+ BluetoothHelper.getConnectedBluetoothDevices(this, devices -> {
+ devices.forEach(this::setMac);
+ }).forEach(this::setMac);
+ if (enable) {
+ handler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ if ((mac == null || mac.isEmpty() || mac.isBlank()) && !mScanning) {
+ handler.post(this);
+ return;
+ }
+ Log.d(TAG, "停止扫描,已扫描到设备 " + mac);
+ mScanning = false;
+ mBluetoothAdapter.stopLeScan(leScanCallback);
+ }
+ }, SCAN_PERIOD);
+
+ mScanning = true;
+ mBluetoothAdapter.startLeScan(leScanCallback);
+ } else {
+ mScanning = false;
+ mBluetoothAdapter.stopLeScan(leScanCallback);
+ }
+ }
+
+ public boolean isScanning() {
+ return mScanning;
+ }
+
+ void init() {
+ final BluetoothManager bluetoothManager =
+ (BluetoothManager) getSystemService(BLUETOOTH_SERVICE);
+ mBluetoothAdapter = bluetoothManager.getAdapter();
+ if (mBluetoothAdapter == null) {
+ Log.e(TAG, "Bluetooth not supported");
+ return;
+ }
+ devices = new ArrayList<>();
+ deviceInfoList = new ArrayList<>();
+ scanLeDevice(true);
+ }
+}
diff --git a/app/src/main/java/com/serverapk/i9_bl_test/ReadCardActivity.java b/app/src/main/java/com/serverapk/i9_bl_test/ReadCardActivity.java
new file mode 100644
index 0000000..fe2794b
--- /dev/null
+++ b/app/src/main/java/com/serverapk/i9_bl_test/ReadCardActivity.java
@@ -0,0 +1,619 @@
+package com.serverapk.i9_bl_test;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothManager;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.activity.EdgeToEdge;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+
+import com.company.i9sslclient.TLVprocess.Field55Test;
+import com.company.i9sslclient.TLVprocess.TlvData;
+import com.company.i9sslclient.bean.CardInfo;
+import com.company.i9sslclient.bean.KernelInfo;
+import com.company.i9sslclient.bean.TerminalInfo;
+import com.company.i9sslclient.ble.BleController;
+import com.company.i9sslclient.lib.TradeTag;
+import com.company.i9sslclient.lib.Util;
+import com.company.i9sslclient.listener.DeviceConnectionListener;
+import com.company.i9sslclient.listener.TerminalInfoListstener;
+import com.company.i9sslclient.service.BluetoothLeService;
+import com.google.gson.Gson;
+import com.serverapk.i9_bl_test.model.CardData;
+import com.serverapk.i9_bl_test.model.CardQuery;
+
+import java.text.SimpleDateFormat;
+import java.util.List;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ *
+ */
+public class ReadCardActivity extends AppCompatActivity {
+ private static final String TAG = "MainActivity";
+ public String errMsg, readerErrMsg, mac, tusn, appVersion, kernelVersion, tradeData, money = "1", countryCode = "0702", currencyCode = "0702", cardNum;
+ Handler handler = new Handler(Looper.getMainLooper());
+ private BluetoothAdapter mBluetoothAdapter;
+ private BluetoothLeService mBluetoothLeService;
+ private MyTerminalInfoListstener mMyTerminalInfoListstener;
+ private BleController mBluetoothController;
+
+ private CardData cardData;
+ private int keyType = 0;
+ private final Runnable readRunnable = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ StringBuilder tradeDataStr = new StringBuilder();
+ tradeDataStr.append(TradeTag.TAG_1F01).append("01").append("71");
+
+ StringBuilder key = new StringBuilder();
+ key.append(Util.toHexString(keyType + 1));
+ String keyIndexStr = "0";
+ key.append(Util.toHexString(Integer.parseInt(keyIndexStr)));
+
+ tradeDataStr.append(TradeTag.TAG_1F04).append("06").append("020100000200");
+ tradeDataStr.append(TradeTag.TAG_1F02).append("04").append("10000000");
+ String date = Util.getCurrentDate() + Util.getCurrentTime();
+ tradeDataStr.append(TradeTag.TAG_1F03).append("07").append(date);
+ tradeDataStr.append(TradeTag.TAG_1F07).append("03").append("313233");
+
+ int amount = Integer.parseInt(money) * 100;
+ String amountStr = String.valueOf(amount);
+ String emvinfo = getString(amountStr, tradeDataStr);
+
+ tradeDataStr.append(TradeTag.TAG_1F09).append(Util.toHexString(emvinfo.length() / 2)).append(emvinfo);
+
+
+ Log.d(TAG, "tradeDataStr: " + tradeDataStr);
+ mBluetoothController.startEmvProcess(50, Util.HexToBin(tradeDataStr.toString()));
+
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @NonNull
+ private String getString(String amountStr, StringBuilder tradeDataStr) {
+ String lenStr = Util.toHexString(amountStr.length());
+
+ tradeDataStr.append(TradeTag.TAG_1F08).append(lenStr).append(Util.BinToHex(amountStr.getBytes()));
+ String transationType = "00";
+ return TradeTag.TAG_9C + "01" + transationType + TradeTag.TAG_9F1A + "02" + countryCode +
+ TradeTag.TAG_5F2A + "02" + currencyCode + "9a03" + Util.getCurrentDateYY() + "9f2103" + Util.getCurrentTime();
+ }
+ };
+ private ThreadPoolExecutor executorService;
+ private boolean isConnected = false;
+ private final ServiceConnection mServiceConnection = new ServiceConnection() {
+
+ @Override
+ public void onServiceConnected(ComponentName componentName, IBinder service) {
+ mBluetoothLeService = ((BluetoothLeService.LocalBinder) service).getService();
+ if (!mBluetoothLeService.initialize()) {
+ Log.e(TAG, "无法初始化蓝牙");
+ finish();
+ return;
+ }
+
+ mBluetoothController = BleController.getInstance(ReadCardActivity.this, null,
+ new DeviceConnectionListener() {
+ @Override
+ public void connected() {
+ isConnected = true;
+ Log.d(TAG, "connected1==========");
+ beginReadCard();
+ }
+
+ @Override
+ public void connectFail() {
+ isConnected = false;
+ Log.e(TAG, "connectFail==========");
+ }
+
+ @Override
+ public void disConnected() {
+ isConnected = false;
+ Log.e(TAG, "disConnected==========");
+ }
+ }, mMyTerminalInfoListstener, mBluetoothLeService);
+
+ if (mBluetoothController != null) {
+ Log.d(TAG, "连接设备\n" + mac);
+ if (mBluetoothController.isConnected()) {
+ beginReadCard();
+ return;
+ }
+ mBluetoothController.connectDevice(mac);
+ } else {
+ Log.e(TAG, "初始化 mBluetoothController 失败");
+ finish();
+ }
+ }
+
+
+ @Override
+ public void onServiceDisconnected(ComponentName componentName) {
+ mBluetoothLeService = null;
+ Log.d(TAG, "onServiceDisconnected=======");
+ }
+ };
+
+ public static String complement0(String str, int len, boolean bool) {
+ if (str.length() >= len) {
+ return str;
+ } else {
+ StringBuilder complementstr = new StringBuilder(str);
+ while (complementstr.length() < len) {
+ if (bool) {
+ complementstr.insert(0, "0");
+ } else {
+ complementstr.append("0");
+ }
+ }
+ return complementstr.toString();
+ }
+ }
+
+ private void beginReadCard() {
+ Log.d(TAG, "开始读卡流程");
+ currencyCode = complement0(currencyCode, 4, true);
+ countryCode = complement0(countryCode, 4, true);
+ if (executorService.getActiveCount() == 0 && executorService.getQueue().isEmpty() && !executorService.isShutdown() && !executorService.isTerminated()) {
+ executorService.execute(readRunnable);
+
+ }
+
+
+ }
+
+ private void handlerCard() {
+ CardQuery query = new CardQuery();
+ query.setCardInfo(cardData);
+ String json = new Gson().toJson(query);
+ Log.d(TAG, "卡信息\t" + json);
+
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ EdgeToEdge.enable(this);
+ setContentView(R.layout.activity_read_card);
+ ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
+ Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
+ return insets;
+ });
+ findViewById(R.id.test).setOnClickListener(v -> finish());
+
+ final BluetoothManager bluetoothManager =
+ (BluetoothManager) getSystemService(BLUETOOTH_SERVICE);
+ mBluetoothAdapter = bluetoothManager.getAdapter();
+
+ if (mBluetoothAdapter == null) {
+
+ Log.e(TAG, "不支持蓝牙");
+ return;
+ }
+
+ executorService = new ThreadPoolExecutor(
+ 1,
+ 1,
+ 60L, TimeUnit.SECONDS,
+ new LinkedBlockingQueue<>()
+ );
+ mMyTerminalInfoListstener = new MyTerminalInfoListstener();
+ mac = getIntent().getStringExtra("mac");
+ Log.d(TAG, "mac=" + mac);
+ Intent gattServiceIntent = new Intent(this, BluetoothLeService.class);
+ bindService(gattServiceIntent, mServiceConnection, BIND_AUTO_CREATE);
+
+ }
+
+ private final class MyTerminalInfoListstener implements TerminalInfoListstener {
+ @Override
+ public void onlineResponse(int code, byte[] reScript, byte[] data) {
+ Log.d(TAG, "在线响应 code: %d" + code);
+ String sb = "发送在线流程结果 code: " + code +
+ "reScript: " + Util.BinToHex(reScript) +
+ "data: " + Util.BinToHex(data);
+
+ Log.d(TAG, "在线响应\n" + sb);
+ }
+
+ @Override
+ public void onSendApdu(List lists) {
+ Log.d(TAG, "发送APDU数据");
+ StringBuilder sb = new StringBuilder();
+
+ if ((lists != null) && (!lists.isEmpty())) {
+ int size = lists.size();
+ for (int i = 0; i < size; i++) {
+ sb.append("\n").append(i).append(" : ").append(Util.BinToHex(lists.get(i)));
+ }
+ }
+ Log.d(TAG, "在 APDU 响应数据上\n" + sb);
+ }
+
+ @Override
+ public void onEncryptedData(byte[] data, String ksn) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("在加密数据上:").append(Util.BinToHex(data));
+ if (ksn != null) {
+ sb.append("ksn:").append(ksn);
+ }
+ Log.d(TAG, sb.toString());
+ }
+
+ @Override
+ public void onPowerOnAPDU(int i, byte[] bytes) {
+ String sb = "开机 APDU" +
+ "\ncardType: " + i +
+ "\ndata: " + Util.BinToHex(bytes);
+
+ Log.d(TAG, sb);
+ }
+
+ @Override
+ public void onGetBatteryPower(int i) {
+ Log.d(TAG, "电池电量: " + i);
+ }
+
+ @Override
+ public void onGetTerminalDateTime(String terminalTime) {
+ Log.d(TAG, "终止时间: " + terminalTime);
+ }
+
+ @Override
+ public void onGetTerminalStatus(int status) {
+ Log.d(TAG, "获取终端状态: " + status);
+ }
+
+ @Override
+ public void onUserImport(String s) {
+ Log.d(TAG, "用户导入: " + s);
+
+ }
+
+ @Override
+ public void onCardInfoImport(String s, String s1) {
+ String sb = "onCardInfoImport : " + s +
+ "onCardInfoImport : " + s1;
+
+ Log.d(TAG, "卡信息导入: " + sb);
+ }
+
+ @Override
+ public void onGetTerminalInfo(TerminalInfo devInfo) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("在“获取终端信息”上");
+ if (18 == devInfo.getTerminalType()) {
+ sb.append("\nModel Name: " + "i9");
+ } else {
+ sb.append("\nterminalType: ").append(devInfo.getTerminalType());
+ }
+ sb.append("\nTSN: ").append(devInfo.getTerminalNo());
+
+ if (devInfo.getKernelVer() != null) {
+ sb.append("\nKernel Version: ").append(devInfo.getKernelVer());
+ }
+ if (devInfo.getSoftVersion() != null) {
+ sb.append("\nApplicatin Version: ").append(devInfo.getSoftVersion());
+ appVersion = devInfo.getSoftVersion();
+ }
+ if (devInfo.getFirmwareVer() != null) {
+ sb.append("\nFirmware Version: ").append(devInfo.getFirmwareVer());
+ }
+ if (devInfo.getHardwareVer() != null) {
+ sb.append("\nHardware Version: ").append(devInfo.getHardwareVer());
+ }
+ if (devInfo.getSoftVersionDate() != null) {
+ sb.append("\nSoftware Date: ").append(devInfo.getSoftVersionDate());
+ }
+ if (devInfo.getTsn() != null) {
+ sb.append("\nSN: ").append(devInfo.getTsn().toLowerCase());
+ }
+
+ if (devInfo.getKernelVersion() != null) {
+ sb.append("\nkernelVersion: ").append(devInfo.getKernelVersion());
+ kernelVersion = devInfo.getKernelVersion();
+ }
+ if (devInfo.getBluetoothName() != null) {
+ sb.append("\nbluetoothName: ").append(devInfo.getBluetoothName());
+ }
+ if (devInfo.getBluetoothMAC() != null) {
+ sb.append("\nbluetoothMAC: ").append(devInfo.getBluetoothMAC());
+ }
+ if (devInfo.getBluetoothVersion() != null) {
+ sb.append("\nbluetoothVersion: ").append(devInfo.getBluetoothVersion());
+ }
+ if (devInfo.getProtocolType() != null) {
+ sb.append("\nprotocolType: ").append(devInfo.getProtocolType());
+ }
+ if (devInfo.getMerchaantNo() != null) {
+ sb.append("\nmerchaantNo: ").append(devInfo.getMerchaantNo());
+ }
+ if (devInfo.getTsn() != null) {
+ byte[] tsn = Util.hexStringToByteArray(devInfo.getTsn());
+ boolean isBcd = false;
+ for (byte b : tsn) {
+ if ((b <= 32) || (b > 126)) {
+ isBcd = true;
+ break;
+ }
+ }
+ if (isBcd) {
+ sb.append("\nSN: ").append(devInfo.getTsn().toLowerCase());
+ tusn = devInfo.getTsn().toLowerCase();
+ } else {
+ sb.append("\nSN: ").append(new String(tsn));
+ tusn = new String(tsn);
+ }
+ }
+
+ Log.d(TAG, String.valueOf(sb));
+ }
+
+ @Override
+ public void onGetKernelInfo(KernelInfo kernelInfo) {
+ Log.d(TAG, "获取内核信息:" + kernelInfo);
+
+ }
+
+ @Override
+ public void onTimeOut() {
+ Log.e(TAG, "超时================");
+ handler.removeCallbacks(readRunnable);
+ handler.post(readRunnable);
+ }
+
+ @Override
+ public void onGetCardNo(String cardNo) {
+ Log.d(TAG, "获取卡号:" + cardNo);
+ }
+
+ @Override
+ public void onCaculateMac(String mac, String ksn) {
+ Log.d(TAG, "mac = " + mac + ", ksn = " + ksn);
+
+ }
+
+ @Override
+ public void onGetMerchant(String merchant, String terminalNo) {
+ Log.d(TAG, "merchant = " + merchant + ", terminalNo = " + terminalNo);
+ }
+
+ @Override
+ public void onPINEntryResult(byte[] pin) {
+ Log.e(TAG, "onPINEntryResult: " + Util.BinToHex(pin));
+ }
+
+
+ @Override
+ public void onError(int code, String msg) {
+ String sb = new SimpleDateFormat("HH:mm:ss:SSS ").format(new java.util.Date()) + "onError" +
+ "\ncode: " + code +
+ "\nmsg: " + msg;
+ readerErrMsg = msg;
+
+ Log.e(TAG, "onError============\n" + sb);
+
+ }
+
+ @Override
+ public void onWaitingcard() {
+ Log.d(TAG, "onWaitingcard");
+ }
+
+ @Override
+ public void onWaitingPin() {
+ Log.e(TAG, "onWaitingPin");
+ }
+
+
+ @Override
+ public void onICCardInsertion() {
+ Log.e(TAG, "onICCardInsertion");
+ }
+
+ @Override
+ public void onNFCCardDetection() {
+
+ Log.d(TAG, "onNFCCardDetection");
+ }
+
+ @Override
+ public void onDecryptData(byte[] bytes) {
+ Log.e(TAG, "bytes:" + Util.BinToHex(bytes));
+ }
+
+ @Override
+ public void onReadCard(CardInfo cardInfo) {
+ Log.d(TAG, "读取卡信息: %s" + cardInfo.getCardNo());
+ tradeData = cardInfo.getEMVData();
+ Log.d(TAG, "onReadCard:" + new Gson().toJson(cardInfo));
+ cardNum = cardInfo.getCardNo();
+
+ cardData = new CardData();
+ StringBuilder sb = new StringBuilder();
+ sb.append("{");
+ if (cardInfo.getControlModel() != null) {
+ cardData.setControlModel(cardInfo.getControlModel());
+ sb.append("\ncontrolModel: ").append(cardInfo.getControlModel());
+ }
+ cardData.setCardType(cardInfo.getCardType() + "");
+ sb.append("\ncardType: ").append(cardInfo.getCardType());
+
+ if (cardInfo.getCardexpiryDate() != null) {
+ cardData.setCardexpiryDate(cardInfo.getCardexpiryDate());
+ sb.append("\ncardexpiryDate: ").append(cardInfo.getCardexpiryDate());
+ }
+ if (cardInfo.getTracks() != null) {
+ sb.append("\ntracks: ").append(Util.BinToHex(cardInfo.getTracks()));
+ }
+ if (cardInfo.getCardNo() != null) {
+ cardData.setCardNo(cardInfo.getCardNo());
+ sb.append("\ncardNo: ").append(cardInfo.getCardNo());
+ }
+ if (cardInfo.getCardName() != null) {
+ cardData.setCardName(cardInfo.getCardName());
+ sb.append("\nCardName: ").append(cardInfo.getCardName());
+ }
+ if (cardInfo.getPAN() != null) {
+ sb.append("\nPAN: ").append(cardInfo.getPAN());
+ }
+ if (cardInfo.getCardSerial() != null) {
+ cardData.setCardSerial(cardInfo.getCardSerial());
+ sb.append("\nCardSerial: ").append(cardInfo.getCardSerial());
+ }
+ if (cardInfo.getCVM() != null) {
+ cardData.setCVM(cardInfo.getCVM());
+ sb.append("\nCVM: ").append(cardInfo.getCVM());
+ }
+ if (cardInfo.getIcTracks() != null) {
+ cardData.setIcTracks(Util.BinToHex(cardInfo.getIcTracks()));
+ sb.append("\nicTracks: ").append(Util.BinToHex(cardInfo.getIcTracks()));
+ }
+ if (cardInfo.getTerimalNo() != null) {
+ sb.append("\nterimalNo: ").append(cardInfo.getTerimalNo());
+ }
+ cardData.setNfcTradeRespose(cardInfo.getNfcTradeRespose() + "");
+ sb.append("\nnfcTradeRespose: ").append(cardInfo.getNfcTradeRespose());
+
+ if (cardInfo.getTracklength() != null) {
+ cardData.setTracklength(Util.BinToHex(cardInfo.getTracklength()));
+ sb.append("\ntracklength: ").append(Util.BinToHex(cardInfo.getTracklength()));
+ }
+ if (cardInfo.getOriginalTrack() != null) {
+ cardData.setOriginalTrack(Util.BinToHex(cardInfo.getOriginalTrack()));
+ sb.append("\noriginalTrack: ").append(Util.BinToHex(cardInfo.getOriginalTrack()));
+ }
+ if (cardInfo.getKSN() != null) {
+ sb.append("\nKSN: ").append(cardInfo.getKSN());
+ }
+ if (cardInfo.getServiceCode() != null) {
+ cardData.setServiceCode(cardInfo.getServiceCode());
+ sb.append("\nServiceCode: ").append(cardInfo.getServiceCode());
+ }
+
+ if ((cardInfo.getOriginalTrack() != null) && (cardInfo.getTracklength() != null)) {
+ int trck1len = (cardInfo.getTracklength()[0] & 0xFF);
+ int trck2len = ((cardInfo.getTracklength()[1] & 0xFF) + 1) / 2;
+ int trck3len = ((cardInfo.getTracklength()[2] & 0xFF) + 1) / 2;
+ if (trck1len > 0) {
+
+ byte[] trck1 = new byte[trck1len];
+ System.arraycopy(cardInfo.getOriginalTrack(), 0, trck1, 0, trck1len);
+ sb.append("\ntrck1:").append(Util.BinToHex(trck1));
+ }
+
+ if (trck2len > 0) {
+ byte[] trck2 = new byte[trck2len];
+ System.arraycopy(cardInfo.getOriginalTrack(), trck1len, trck2, 0, trck2len);
+ sb.append("\ntrck2:").append(Util.BinToHex(trck2).substring(0, ((cardInfo.getTracklength()[1] & 0xFF))));
+ }
+
+ if (trck3len > 0) {
+ byte[] trck3 = new byte[trck3len];
+ System.arraycopy(cardInfo.getOriginalTrack(), trck1len + trck2len, trck3, 0, trck3len);
+ sb.append("\ntrck3:").append(Util.BinToHex(trck3).substring(0, ((cardInfo.getTracklength()[2] & 0xFF))));
+ }
+ }
+
+
+ if ("03".equals(cardInfo.getKernel_Type())) {
+ sb.append("\nKernel Type: ").append(cardInfo.getKernel_Type());
+ sb.append("\nOutcome Parameter Set: ").append(cardInfo.getOutcome_Parameter_Set());
+ sb.append("\nUser Interface Request_Data: ").append(cardInfo.getUser_Interface_Request_Data());
+ sb.append("\nError Indication: ").append(cardInfo.getError_Indication());
+ }
+
+ if (cardInfo.getIcTracks() != null) {
+ String tags = Util.BinToHex(cardInfo.getIcTracks());
+
+ if (!TextUtils.isEmpty(tags)) {
+
+ List tlvDataList = Field55Test.parseFiled55Data(tags);
+ for (TlvData tlvData : tlvDataList) {
+ if ("57".equals(tlvData.getTag())) {
+ Log.d(TAG, "tag_57 value: " + tlvData.getValue());
+ }
+ }
+ }
+ }
+ Log.d(TAG, "读取卡信息=======" + new Gson().toJson(cardData));
+
+ int cardType = cardInfo.getCardType();
+ if (cardType != -1) {
+ cardType = cardType & 0x0F;
+ switch (cardType) {
+ case 1:
+ case 13:
+ mBluetoothController.sendOnlineProcessResult(null);
+ break;
+ case 0:
+ case 2:
+ default:
+ break;
+ }
+
+ }
+
+ handlerCard();
+
+ }
+
+ @Override
+ public void onShowLog(String l) {
+ Log.d(TAG, new SimpleDateFormat("HH:mm:ss:SSS ").format(new java.util.Date()) + "onShowLog log: " + l);
+ }
+
+ @Override
+ public void onStopTradeSuccess() {
+ Log.e(TAG, "onStopTradeSuccess");
+ }
+
+ @Override
+ public void onExecuteSuccess(int code) {
+ Log.e(TAG, "onExecuteSuccess code: " + code);
+ if (code == 14) {
+ Log.e(TAG, "app download success");
+ } else if (code == 15) {
+ Log.e(TAG, "kernel download success");
+ } else if (code == 16) {
+ Log.e(TAG, "key download success");
+ }
+
+ }
+
+ @Override
+ public void onData(int type, int code, byte[]... pin) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("type: ").append(type);
+ sb.append("\ncode: ").append(code);
+ if (pin != null) {
+ int len = pin.length;
+ for (int i = 0; i < len; i++) {
+ sb.append("\n").append(i).append(": ").append(Util.BinToHex(pin[i]));
+ }
+ }
+
+ Log.d(TAG, sb.toString());
+ }
+ }
+}
diff --git a/app/src/main/java/com/serverapk/i9_bl_test/model/CardData.java b/app/src/main/java/com/serverapk/i9_bl_test/model/CardData.java
new file mode 100644
index 0000000..d6dd5f8
--- /dev/null
+++ b/app/src/main/java/com/serverapk/i9_bl_test/model/CardData.java
@@ -0,0 +1,149 @@
+package com.serverapk.i9_bl_test.model;
+
+import java.io.Serializable;
+
+/**
+ * 查询卡的使用扇区和密码保存扇区密码的请求实体
+ */
+
+public class CardData implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private String controlModel;
+ private String cardType;
+ private String cardexpiryDate;
+ private String cardNo;
+ private String sign;
+ private String cardName;
+ private String cardSerial;
+ private String CVM;
+ private String icTracks;
+ private String nfcTradeRespose;
+ private String tracklength;
+ private String originalTrack;
+ private String serviceCode;
+ private String trck2;
+
+ private String cvv;
+
+ public String getControlModel() {
+ return controlModel;
+ }
+
+ public void setControlModel(String controlModel) {
+ this.controlModel = controlModel;
+ }
+
+
+ public String getCardexpiryDate() {
+ return cardexpiryDate;
+ }
+
+ public void setCardexpiryDate(String cardexpiryDate) {
+ this.cardexpiryDate = cardexpiryDate;
+ }
+
+ public String getCardNo() {
+ return cardNo;
+ }
+
+ public void setCardNo(String cardNo) {
+ this.cardNo = cardNo;
+ }
+
+ public String getSign() {
+ return sign;
+ }
+
+ public void setSign(String sign) {
+ this.sign = sign;
+ }
+
+ public String getCardName() {
+ return cardName;
+ }
+
+ public void setCardName(String cardName) {
+ this.cardName = cardName;
+ }
+
+ public String getCardSerial() {
+ return cardSerial;
+ }
+
+ public void setCardSerial(String cardSerial) {
+ this.cardSerial = cardSerial;
+ }
+
+ public String getCVM() {
+ return CVM;
+ }
+
+ public void setCVM(String CVM) {
+ this.CVM = CVM;
+ }
+
+ public String getIcTracks() {
+ return icTracks;
+ }
+
+ public void setIcTracks(String icTracks) {
+ this.icTracks = icTracks;
+ }
+
+ public String getCardType() {
+ return cardType;
+ }
+
+ public void setCardType(String cardType) {
+ this.cardType = cardType;
+ }
+
+ public String getNfcTradeRespose() {
+ return nfcTradeRespose;
+ }
+
+ public void setNfcTradeRespose(String nfcTradeRespose) {
+ this.nfcTradeRespose = nfcTradeRespose;
+ }
+
+ public String getTracklength() {
+ return tracklength;
+ }
+
+ public void setTracklength(String tracklength) {
+ this.tracklength = tracklength;
+ }
+
+ public String getOriginalTrack() {
+ return originalTrack;
+ }
+
+ public void setOriginalTrack(String originalTrack) {
+ this.originalTrack = originalTrack;
+ }
+
+ public String getServiceCode() {
+ return serviceCode;
+ }
+
+ public void setServiceCode(String serviceCode) {
+ this.serviceCode = serviceCode;
+ }
+
+ public String getTrck2() {
+ return trck2;
+ }
+
+ public void setTrck2(String trck2) {
+ this.trck2 = trck2;
+ }
+
+ public String getCvv() {
+ return cvv;
+ }
+
+ public void setCvv(String cvv) {
+ this.cvv = cvv;
+ }
+}
diff --git a/app/src/main/java/com/serverapk/i9_bl_test/model/CardQuery.java b/app/src/main/java/com/serverapk/i9_bl_test/model/CardQuery.java
new file mode 100644
index 0000000..d568238
--- /dev/null
+++ b/app/src/main/java/com/serverapk/i9_bl_test/model/CardQuery.java
@@ -0,0 +1,16 @@
+package com.serverapk.i9_bl_test.model;
+
+import java.io.Serializable;
+
+public class CardQuery implements Serializable {
+ private static final long serialVersionUID = 111L;
+ private CardData cardInfo;
+
+ public CardData getCardInfo() {
+ return cardInfo;
+ }
+
+ public void setCardInfo(CardData cardInfo) {
+ this.cardInfo = cardInfo;
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..7706ab9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..51fee18
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_read_card.xml b/app/src/main/res/layout/activity_read_card.xml
new file mode 100644
index 0000000..10c35ec
--- /dev/null
+++ b/app/src/main/res/layout/activity_read_card.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..b3e26b4
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..b3e26b4
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..5323577
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..768b058
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #FF000000
+ #FFFFFFFF
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..e0b84b4
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ i9-bl-test
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..587b95c
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..90529f8
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..0c4f95c
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/serverapk/i9_bl_test/ExampleUnitTest.java b/app/src/test/java/com/serverapk/i9_bl_test/ExampleUnitTest.java
new file mode 100644
index 0000000..f58e8a1
--- /dev/null
+++ b/app/src/test/java/com/serverapk/i9_bl_test/ExampleUnitTest.java
@@ -0,0 +1,17 @@
+package com.serverapk.i9_bl_test;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() {
+ assertEquals(4, 2 + 2);
+ }
+}
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..c9bd2b2
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,4 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+alias(libs.plugins.android.application) apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..534063a
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,21 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..a0883e2
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,21 @@
+[versions]
+agp = "8.11.0"
+junit = "4.13.2"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
+appcompat = "1.6.1"
+material = "1.10.0"
+activity = "1.8.0"
+constraintlayout = "2.1.4"
+
+[libraries]
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
+constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e01e9be
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Jul 04 16:31:58 CST 2025
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.13-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..1709862
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "i9-bl-test"
+include ':app'