diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml new file mode 100644 index 0000000..356d457 --- /dev/null +++ b/.idea/planningMode.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a90b06a --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# SAIpp - Barcode Scanner & MariaDB Connector + +Android application developed in Kotlin using Jetpack Compose that scans barcodes and saves them directly to a self-hosted MariaDB database. + +## 🚀 Features + +- **Instant Scan**: The camera opens automatically upon application startup. +- **ML Kit Integration**: High-performance barcode detection using Google ML Kit. +- **MariaDB Connectivity**: Direct connection to a remote database via JDBC. +- **Modern UI**: Built with Jetpack Compose and Material 3. +- **Permission Handling**: Automatic runtime camera permission requests. + +## 🛠️ Requirements + +- Android device with API 34+ (Android 14+). +- MariaDB Server accessible from the mobile device network. +- Camera hardware. + +## ⚙️ Configuration + +### 1. Database Setup +Ensure your MariaDB database has a table named `scans`. You can use the following SQL: + +```sql +CREATE TABLE scans ( + id INT AUTO_INCREMENT PRIMARY KEY, + barcode VARCHAR(255) NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +### 2. App Credentials +Update your database connection details in: +`app/src/main/java/com/example/saipp/data/MariaDbConnection.kt` + +```kotlin +private const val HOST = "YOUR_SERVER_IP" +private const val DATABASE = "YOUR_DB_NAME" +private const val USER = "YOUR_USER" +private const val PASSWORD = "YOUR_PASSWORD" +``` + +### 3. Network Access +- Ensure the MariaDB port (default **3306**) is open in your firewall. +- Your MariaDB user must be allowed to connect from remote IPs: + ```sql + GRANT ALL PRIVILEGES ON YOUR_DB_NAME.* TO 'YOUR_USER'@'%' IDENTIFIED BY 'YOUR_PASSWORD'; + FLUSH PRIVILEGES; + ``` + +## 📦 Dependencies + +- **CameraX**: For camera preview and frame capture. +- **ML Kit Barcode Scanning**: For processing barcodes. +- **MariaDB Java Client**: JDBC driver for database communication. +- **Coroutines**: For asynchronous database operations. + +## ⚠️ Security Warning + +> [!CAUTION] +> This project uses a **direct JDBC connection** from the mobile app to a remote database. This is suitable for personal projects or internal tools, but is **not recommended for production apps** exposed to the public, as it requires storing database credentials in the code and exposing database ports to the internet. + +## 📄 License +This project is for educational/personal use. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8c4087..c853d6e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,11 @@ android { buildFeatures { compose = true } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } } dependencies { @@ -46,6 +51,20 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") + + // CameraX + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + + // ML Kit Barcode Scanning + implementation(libs.mlkit.barcode.scanning) + + // MariaDB JDBC Driver + implementation(libs.mariadb.java.client) + testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 899629c..d556a51 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + MainScreen() } } } } @Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - SAIppTheme { - Greeting("Android") +fun MainScreen() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val repository = remember { ScannerRepository() } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) } -} \ No newline at end of file + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { granted -> + hasCameraPermission = granted + } + ) + + LaunchedEffect(key1 = true) { + if (!hasCameraPermission) { + launcher.launch(Manifest.permission.CAMERA) + } + } + + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + if (hasCameraPermission) { + var isProcessing by remember { mutableStateOf(false) } + + BarcodeScannerView(onBarcodeDetected = { barcode -> + if (!isProcessing) { + isProcessing = true + scope.launch { + val result = repository.saveBarcode(barcode) + result.onSuccess { + Toast.makeText(context, "Guardado: $barcode", Toast.LENGTH_SHORT).show() + } + result.onFailure { e -> + Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + // Add a small delay to avoid multiple triggers for the same barcode + kotlinx.coroutines.delay(2000) + isProcessing = false + } + } + }) + + if (isProcessing) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } else { + Text( + text = "Se requiere permiso de cámara para escanear.", + modifier = Modifier.align(Alignment.Center) + ) + } + } + } +} diff --git a/app/src/main/java/com/example/saipp/data/MariaDbConnection.kt b/app/src/main/java/com/example/saipp/data/MariaDbConnection.kt new file mode 100644 index 0000000..908a1df --- /dev/null +++ b/app/src/main/java/com/example/saipp/data/MariaDbConnection.kt @@ -0,0 +1,37 @@ +package com.example.saipp.data + +import android.util.Log +import java.sql.Connection +import java.sql.DriverManager +import java.sql.SQLException +import java.util.Properties + +object MariaDbConnection { + private const val TAG = "MariaDbConnection" + + // TODO: Replace with your actual database details + private const val HOST = "YOUR_HOST_IP" + private const val PORT = "3306" + private const val DATABASE = "YOUR_DATABASE_NAME" + private const val USER = "YOUR_USER" + private const val PASSWORD = "YOUR_PASSWORD" + + private const val URL = "jdbc:mariadb://$HOST:$PORT/$DATABASE" + + fun getConnection(): Connection? { + return try { + // Load the driver explicitly if needed + Class.forName("org.mariadb.jdbc.Driver") + + val props = Properties() + props.setProperty("user", USER) + props.setProperty("password", PASSWORD) + props.setProperty("connectTimeout", "5000") // 5 seconds timeout + + DriverManager.getConnection(URL, props) + } catch (e: Exception) { + Log.e(TAG, "Error connecting to MariaDB: ${e.message}", e) + null + } + } +} diff --git a/app/src/main/java/com/example/saipp/data/ScannerRepository.kt b/app/src/main/java/com/example/saipp/data/ScannerRepository.kt new file mode 100644 index 0000000..eb8097c --- /dev/null +++ b/app/src/main/java/com/example/saipp/data/ScannerRepository.kt @@ -0,0 +1,43 @@ +package com.example.saipp.data + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.sql.SQLException + +class ScannerRepository { + companion object { + private const val TAG = "ScannerRepository" + } + + suspend fun saveBarcode(barcode: String): Result = withContext(Dispatchers.IO) { + val connection = MariaDbConnection.getConnection() + if (connection == null) { + return@withContext Result.failure(Exception("Failed to connect to database")) + } + + try { + // TODO: Customize table and column names + val sql = "INSERT INTO scans (barcode, timestamp) VALUES (?, CURRENT_TIMESTAMP)" + val statement = connection.prepareStatement(sql) + statement.setString(1, barcode) + + val rowsInserted = statement.executeUpdate() + if (rowsInserted > 0) { + Log.d(TAG, "Barcode saved successfully: $barcode") + Result.success(Unit) + } else { + Result.failure(Exception("No rows inserted")) + } + } catch (e: SQLException) { + Log.e(TAG, "Database error: ${e.message}", e) + Result.failure(e) + } finally { + try { + connection.close() + } catch (e: SQLException) { + Log.e(TAG, "Error closing connection: ${e.message}", e) + } + } + } +} diff --git a/app/src/main/java/com/example/saipp/ui/scanner/BarcodeScannerView.kt b/app/src/main/java/com/example/saipp/ui/scanner/BarcodeScannerView.kt new file mode 100644 index 0000000..1d69dbb --- /dev/null +++ b/app/src/main/java/com/example/saipp/ui/scanner/BarcodeScannerView.kt @@ -0,0 +1,103 @@ +package com.example.saipp.ui.scanner + +import android.util.Log +import android.view.ViewGroup +import androidx.annotation.OptIn +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.common.InputImage +import java.util.concurrent.Executors + +@OptIn(ExperimentalGetImage::class) +@Composable +fun BarcodeScannerView( + onBarcodeDetected: (String) -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + val barcodeScanner = remember { BarcodeScanning.getClient() } + + AndroidView( + factory = { ctx -> + PreviewView(ctx).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + }, + modifier = Modifier.fillMaxSize(), + update = { previewView -> + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { analysis -> + analysis.setAnalyzer(cameraExecutor) { imageProxy -> + val mediaImage = imageProxy.image + if (mediaImage != null) { + val image = InputImage.fromMediaImage( + mediaImage, + imageProxy.imageInfo.rotationDegrees + ) + + barcodeScanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + barcode.rawValue?.let { value -> + onBarcodeDetected(value) + } + } + } + .addOnFailureListener { e -> + Log.e("BarcodeScanner", "Scanning failed", e) + } + .addOnCompleteListener { + imageProxy.close() + } + } else { + imageProxy.close() + } + } + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageAnalysis + ) + } catch (e: Exception) { + Log.e("BarcodeScanner", "Use case binding failed", e) + } + }, ContextCompat.getMainExecutor(context)) + } + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0549c8a..c0c4bf5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,9 @@ lifecycleRuntimeKtx = "2.6.1" activityCompose = "1.8.0" kotlin = "2.2.10" composeBom = "2026.02.01" +cameraX = "1.6.1" +mlkitBarcode = "17.3.0" +mariadbDriver = "3.5.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,6 +27,12 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" } +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } +mariadb-java-client = { group = "org.mariadb.jdbc", name = "mariadb-java-client", version.ref = "mariadbDriver" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }