diff --git a/android/app/build.gradle b/android/app/build.gradle
index 4ab006f..3480902 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -124,4 +124,7 @@ dependencies {
// QR Code generation
implementation 'com.google.zxing:core:3.5.1'
+
+ // iCameraSDK integration
+ implementation(project(':sdk'))
}
diff --git a/android/build.gradle b/android/build.gradle
index dad99b0..859f764 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -1,7 +1,7 @@
buildscript {
ext {
buildToolsVersion = "36.0.0"
- minSdkVersion = 24
+ minSdkVersion = 26
compileSdkVersion = 36
targetSdkVersion = 36
ndkVersion = "27.1.12297006"
diff --git a/android/sdk/.gitignore b/android/sdk/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/android/sdk/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/sdk/build.gradle.kts b/android/sdk/build.gradle.kts
new file mode 100644
index 0000000..c5a7c2b
--- /dev/null
+++ b/android/sdk/build.gradle.kts
@@ -0,0 +1,71 @@
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21"
+}
+
+android {
+ namespace = "com.lynkedup.icamera.sdk"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 26
+ targetSdk = 36
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ // AndroidX
+ implementation("androidx.core:core-ktx:1.17.0")
+ implementation("androidx.appcompat:appcompat:1.7.1")
+ implementation("com.google.android.material:material:1.13.0")
+ implementation("androidx.activity:activity:1.11.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.2.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
+
+ // Camera dependencies
+ implementation("androidx.camera:camera-view:1.5.1")
+ implementation("androidx.camera:camera-lifecycle:1.5.1")
+ implementation("androidx.camera:camera-camera2:1.5.1")
+
+ // ML Kit Barcode Scanning
+ implementation("com.google.mlkit:barcode-scanning-common:17.0.0")
+ implementation("com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1")
+
+ // Compose dependencies
+ implementation("androidx.activity:activity-compose:1.11.0")
+ implementation(platform("androidx.compose:compose-bom:2024.09.00"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3:1.4.0")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ // Testing
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.3.0")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2024.09.00"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
\ No newline at end of file
diff --git a/android/sdk/consumer-rules.pro b/android/sdk/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/android/sdk/proguard-rules.pro b/android/sdk/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/android/sdk/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/android/sdk/src/androidTest/java/com/lynkedup/icamera/sdk/ExampleInstrumentedTest.kt b/android/sdk/src/androidTest/java/com/lynkedup/icamera/sdk/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..38030c9
--- /dev/null
+++ b/android/sdk/src/androidTest/java/com/lynkedup/icamera/sdk/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.lynkedup.icamera.sdk
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.lynkedup.icamera.sdk.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/android/sdk/src/main/AndroidManifest.xml b/android/sdk/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6fffaf1
--- /dev/null
+++ b/android/sdk/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/CoreCaptureEngine/CoreCaptureEngine.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/CoreCaptureEngine/CoreCaptureEngine.kt
new file mode 100644
index 0000000..84039bb
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/CoreCaptureEngine/CoreCaptureEngine.kt
@@ -0,0 +1,59 @@
+package com.lynkedup.icamera.sdk.CoreCaptureEngine
+
+import android.content.Context
+import android.widget.ImageView
+import androidx.camera.view.PreviewView
+import androidx.lifecycle.LifecycleOwner
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.lynkedup.icamera.captureengine.scanner.ScannerEngine
+import com.lynkedup.icamera.sdk.ScannerEngine.ScannerDelegate
+import com.lynkedup.icamera.sdk.UploadEngine.UploadEngine
+import java.lang.ref.WeakReference
+import java.net.URL
+
+public enum class CaptureFeature {
+ CAPTURE_3D,
+ QR_CODE,
+ BAR_CODE,
+ LENS
+}
+
+interface CaptureFeatureEngine{
+ fun start()
+ fun stop()
+ fun makePreview(imageView : PreviewView)
+}
+
+data class CoreCaptureConfiguration(
+ val enabledFeatures: List,
+ val presignedUploadURL: URL
+)
+
+class CoreCaptureEngine(configuration: CoreCaptureConfiguration, context: Context, lifecycleOwner: LifecycleOwner) {
+
+ private val uploadEngine: UploadEngine = UploadEngine(configuration.presignedUploadURL)
+ private var featureEngines: MutableMap = mutableMapOf()
+
+ var scannerDelegate: ScannerDelegate? = null
+
+ init {
+
+ for (feature in configuration.enabledFeatures) {
+ when (feature) {
+// CaptureFeature.CAPTURE_3D -> featureEngines[CaptureFeature.CAPTURE_3D] = CaptureEngine()
+ CaptureFeature.QR_CODE -> featureEngines[CaptureFeature.QR_CODE] = ScannerEngine(
+ context = context,
+ lifecycleOwner = lifecycleOwner,
+ supportedFormats = listOf(Barcode.FORMAT_QR_CODE),
+ delegate = scannerDelegate)
+ CaptureFeature.BAR_CODE -> featureEngines[CaptureFeature.BAR_CODE] = ScannerEngine(
+ context = context,
+ lifecycleOwner=lifecycleOwner,
+ supportedFormats = listOf(Barcode.FORMAT_QR_CODE),
+ delegate = scannerDelegate)
+// CaptureFeature.LENS -> featureEngines[CaptureFeature.LENS] = LensEngine()
+ else -> {}
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerDelegate.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerDelegate.kt
new file mode 100644
index 0000000..d853242
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerDelegate.kt
@@ -0,0 +1,6 @@
+package com.lynkedup.icamera.sdk.ScannerEngine
+
+interface ScannerDelegate {
+ fun onScanned(code: String, format: Int)
+ fun onScanFailed(error: Exception)
+}
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerEngine.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerEngine.kt
new file mode 100644
index 0000000..f61859f
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerEngine.kt
@@ -0,0 +1,147 @@
+package com.lynkedup.icamera.captureengine.scanner
+
+import android.content.Context
+import androidx.annotation.OptIn
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ExperimentalGetImage
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import com.google.mlkit.vision.barcode.BarcodeScanner
+import com.google.mlkit.vision.barcode.BarcodeScannerOptions
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+import com.lynkedup.icamera.sdk.CoreCaptureEngine.CaptureFeatureEngine
+import com.lynkedup.icamera.sdk.ScannerEngine.ScannerDelegate
+import java.lang.ref.WeakReference
+import java.util.concurrent.Executors
+
+class ScannerEngine(
+ private val context: Context,
+ private val lifecycleOwner: LifecycleOwner,
+ private val supportedFormats: List = listOf(Barcode.FORMAT_QR_CODE),
+ delegate: ScannerDelegate?
+) : CaptureFeatureEngine {
+
+ private var previewView: PreviewView? = null
+ private val cameraExecutor = Executors.newSingleThreadExecutor()
+ private var cameraProvider: ProcessCameraProvider? = null
+ private var bound = false
+ private val delegate: WeakReference = WeakReference(delegate)
+
+ override fun makePreview(imageView: PreviewView) {
+ if (imageView is PreviewView) {
+ previewView = imageView
+ } else {
+ // Or handle the error appropriately, maybe by logging or throwing an exception
+ // if PreviewView is strictly required.
+ previewView = PreviewView(imageView.context).also {
+ // You might need to add this new PreviewView to the layout manually.
+ // This part depends on how you want to handle a generic ImageView.
+ }
+ }
+ }
+
+ override fun start() {
+ startScanner()
+ }
+
+ private fun startScanner() {
+ val providerFuture = ProcessCameraProvider.getInstance(context)
+ providerFuture.addListener({
+ try {
+ cameraProvider = providerFuture.get()
+ bindCameraUseCases()
+ } catch (e: Exception) {
+ delegate.get()?.onScanFailed(e)
+ }
+ }, ContextCompat.getMainExecutor(context))
+ }
+
+ private fun bindCameraUseCases() {
+ val provider = cameraProvider ?: run {
+ delegate.get()?.onScanFailed(IllegalStateException("CameraProvider not initialized"))
+ return
+ }
+
+ // Unbind previous use cases
+ provider.unbindAll()
+
+ val preview = Preview.Builder().build().also { p ->
+ previewView?.let { p.surfaceProvider = it.surfaceProvider }
+ }
+
+ val options = BarcodeScannerOptions.Builder().apply {
+ if (supportedFormats.isEmpty()) {
+ setBarcodeFormats(Barcode.FORMAT_QR_CODE)
+ } else {
+ val first = supportedFormats.first()
+ val rest = if (supportedFormats.size > 1) supportedFormats.drop(1).toIntArray() else intArrayOf()
+ setBarcodeFormats(first, *rest)
+ }
+ }.build()
+ val scanner = BarcodeScanning.getClient(options)
+
+ val analysis = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+
+ analysis.setAnalyzer(cameraExecutor) { imageProxy: ImageProxy ->
+ processImageProxy(scanner, imageProxy)
+ }
+
+ try {
+ provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
+ bound = true
+ } catch (ex: Exception) {
+ delegate.get()?.onScanFailed(ex)
+ }
+ }
+
+ @OptIn(ExperimentalGetImage::class)
+ private fun processImageProxy(scanner: BarcodeScanner, imageProxy: ImageProxy) {
+ val mediaImage = imageProxy.image
+ if (mediaImage == null) {
+ imageProxy.close()
+ return
+ }
+
+ val rotationDegrees = imageProxy.imageInfo.rotationDegrees
+ val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
+
+ scanner.process(inputImage)
+ .addOnSuccessListener { barcodes ->
+ if (barcodes.isNotEmpty()) {
+ val barcode = barcodes.first()
+ val raw = barcode.rawValue
+ val format = barcode.format
+ if (!raw.isNullOrEmpty()) {
+ delegate.get()?.onScanned(raw, format)
+ }
+ }
+ }
+ .addOnFailureListener { e ->
+ delegate.get()?.onScanFailed(Exception(e))
+ }
+ .addOnCompleteListener {
+ imageProxy.close()
+ }
+ }
+
+ override fun stop() {
+ try {
+ cameraProvider?.unbindAll()
+ } catch (_: Exception) {
+ }
+ bound = false
+ // Do not shut down executor immediately if you plan to restart quickly; otherwise:
+ if (!cameraExecutor.isShutdown) {
+ cameraExecutor.shutdown()
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerEngineActivity.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerEngineActivity.kt
new file mode 100644
index 0000000..a7cb726
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerEngineActivity.kt
@@ -0,0 +1,72 @@
+package com.lynkedup.icamera.sdk.ScannerEngine
+
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.LifecycleOwner
+import com.lynkedup.icamera.captureengine.scanner.ScannerEngine
+
+class ScannerEngineActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (!com.lynkedup.icamera.sdk.iCameraSDK.isInitialized) {
+ throw Exception("iCameraSDK is not initialized. Call initialize(...) first.")
+ }
+ setContent {
+ MaterialTheme {
+ Surface {
+ ScannerEngineScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ScannerEngineScreen() {
+ val context = LocalContext.current
+ val lifecycleOwner = context as LifecycleOwner
+ val previewView = remember { PreviewView(context) }
+ val scannerEngine = remember {
+ ScannerEngine(
+ context = context,
+ lifecycleOwner = lifecycleOwner,
+ supportedFormats = listOf(), // Add supported formats as needed
+ delegate = object : ScannerDelegate {
+ override fun onScanned(code: String, format: Int) {
+ Toast.makeText(context, "Scanned: $code", Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onScanFailed(error: Exception) {
+ Toast.makeText(context, "Scan failed: ${'$'}{error.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ ).apply {
+ makePreview(previewView)
+ }
+ }
+
+ DisposableEffect(Unit) {
+ scannerEngine.start()
+ onDispose {
+ scannerEngine.stop()
+ }
+ }
+
+ AndroidView(
+ factory = { previewView },
+ modifier = Modifier.fillMaxSize()
+ )
+}
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/UploadEngine/UploadEngine.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/UploadEngine/UploadEngine.kt
new file mode 100644
index 0000000..d3962e9
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/UploadEngine/UploadEngine.kt
@@ -0,0 +1,6 @@
+package com.lynkedup.icamera.sdk.UploadEngine
+
+import java.net.URL
+
+class UploadEngine(uploadURL: URL) {
+}
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/iCameraSDK.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/iCameraSDK.kt
new file mode 100644
index 0000000..6ce91f8
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/iCameraSDK.kt
@@ -0,0 +1,89 @@
+package com.lynkedup.icamera.sdk
+
+import android.content.Context
+import android.util.Log
+import com.lynkedup.icamera.sdk.CoreCaptureEngine.CaptureFeature
+import com.lynkedup.icamera.sdk.idp_kit.TelemeteryReporter
+import com.lynkedup.icamera.sdk.idp_kit.TelemetryDetector
+
+
+
+object iCameraSDK {
+
+ @Volatile
+ private var config: SDKConfig? = null
+
+ @Volatile
+ private var telemetry: TelemetryDetector? = null
+
+
+ private val telemetryListener = object : TelemeteryReporter {
+ override fun onReport(json: String) {
+ Log.d("iCameraSDK.Telemetry", json)
+ }
+ }
+
+ val isInitialized: Boolean
+ get() = config != null
+
+ /**
+ * Initialize the SDK. Safe to call from any thread. Uses application context.
+ * Throws IllegalArgumentException on invalid inputs.
+ */
+ fun initialize(context: Context, presignedUrl: String, features: List) {
+ require(presignedUrl.isNotBlank()) { "presignedUrl must not be empty" }
+ require(features.isNotEmpty()) { "features must contain at least one CaptureFeature" }
+
+ val appCtx = context.applicationContext
+ val cfg = SDKConfig(appContext = appCtx, presignedUrl = presignedUrl.trim(), features = features.toSet())
+ config = cfg
+
+ // start telemetry after config is set
+ startTelemetryIfNeeded(appCtx)
+ }
+
+ fun initialize(context: Context, presignedUrl: String, vararg features: CaptureFeature) {
+ initialize(context, presignedUrl, features.toList())
+ }
+
+ fun getConfig(): SDKConfig {
+ return config ?: throw IllegalStateException("iCameraSDK is not initialized. Call initialize(...) first.")
+ }
+
+ /**
+ * Clears SDK state. Use carefully (for tests or app shutdown).
+ */
+ fun shutdown() {
+ stopTelemetry()
+ config = null
+ }
+
+ private fun startTelemetryIfNeeded(appCtx: Context) {
+ if (telemetry != null) return
+ try {
+ val td = TelemetryDetector(appCtx, telemetryListener)
+ td.start()
+ telemetry = td
+ Log.d("iCameraSDK", "Telemetry started")
+ } catch (e: Exception) {
+ Log.w("iCameraSDK", "Failed to start telemetry: ${e.message}")
+ }
+ }
+
+ private fun stopTelemetry() {
+ try {
+ telemetry?.stop()
+ telemetry = null
+ Log.d("iCameraSDK", "Telemetry stopped")
+ } catch (e: Exception) {
+ Log.w("iCameraSDK", "Failed to stop telemetry: ${e.message}")
+ }
+ }
+
+
+ data class SDKConfig(
+ val appContext: Context,
+ val presignedUrl: String,
+ val features: Set
+ )
+}
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/idp_kit/TelemeteryReporter.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/idp_kit/TelemeteryReporter.kt
new file mode 100644
index 0000000..48aa3b5
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/idp_kit/TelemeteryReporter.kt
@@ -0,0 +1,6 @@
+package com.lynkedup.icamera.sdk.idp_kit
+
+
+interface TelemeteryReporter {
+ fun onReport(json: String)
+}
\ No newline at end of file
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/idp_kit/TelemetryDetector.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/idp_kit/TelemetryDetector.kt
new file mode 100644
index 0000000..03038fb
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/idp_kit/TelemetryDetector.kt
@@ -0,0 +1,833 @@
+package com.lynkedup.icamera.sdk.idp_kit
+
+import android.Manifest
+import android.app.ActivityManager
+import android.app.ActivityManager.MemoryInfo
+import android.app.AppOpsManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.wifi.WifiManager
+import android.os.BatteryManager
+import android.os.Binder
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.StatFs
+import android.os.SystemClock
+import android.provider.Settings
+import android.telephony.PhoneStateListener
+import android.telephony.TelephonyManager
+import android.text.format.Formatter
+import android.util.Log
+import androidx.core.content.ContextCompat
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.BufferedReader
+import java.io.File
+import java.io.InputStreamReader
+
+/**
+ *{
+ * "trigger": "wifi_change",
+ * "timestamp": 1764607091305,
+ * "uptime_millis": 560764,
+ * "sdk_int": 35,
+ * "package_name": "com.lynkedup.icamera.app",
+ * "app": {
+ * "version_name": "1.0",
+ * "version_code": 1,
+ * "first_install_time": 1764443073758,
+ * "last_update_time": 1764443073758,
+ * "requested_permissions": [
+ * {
+ * "name": "android.permission.INTERNET",
+ * "granted": true
+ * },
+ * {
+ * "name": "android.permission.ACCESS_NETWORK_STATE",
+ * "granted": true
+ * },
+ * {
+ * "name": "android.permission.READ_PRIVILEGED_PHONE_STATE",
+ * "granted": false
+ * },
+ * {
+ * "name": "android.permission.READ_PHONE_STATE",
+ * "granted": false
+ * },
+ * {
+ * "name": "android.permission.ACCESS_WIFI_STATE",
+ * "granted": true
+ * },
+ * {
+ * "name": "android.permission.WAKE_LOCK",
+ * "granted": true
+ * },
+ * {
+ * "name": "android.permission.RECEIVE_BOOT_COMPLETED",
+ * "granted": true
+ * },
+ * {
+ * "name": "android.permission.ACCESS_COARSE_LOCATION",
+ * "granted": false
+ * },
+ * {
+ * "name": "com.lynkedup.icamera.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
+ * "granted": true
+ * }
+ * ]
+ * },
+ * "permissions": {
+ * "android.permission.ACCESS_NETWORK_STATE": true,
+ * "android.permission.INTERNET": true,
+ * "android.permission.WAKE_LOCK": true,
+ * "android.permission.QUERY_ALL_PACKAGES": false,
+ * "android.permission.READ_PHONE_STATE": false,
+ * "android.permission.ACTIVITY_RECOGNITION": false,
+ * "android.permission.RECEIVE_BOOT_COMPLETED": true,
+ * "android.permission.ACCESS_WIFI_STATE": true,
+ * "android.permission.PACKAGE_USAGE_STATS": false,
+ * "android.permission.FOREGROUND_SERVICE_DATA_SYNC": false
+ * },
+ * "usage_stats_allowed": false,
+ * "network": {
+ * "isConnected": true,
+ * "type": "WIFI",
+ * "type_code": null,
+ * "down_kbps": 30000,
+ * "up_kbps": 12000,
+ * "subtype_name": null,
+ * "subtype": null
+ * },
+ * "wifi": {
+ * "ssid": "",
+ * "bssid": "02:00:00:00:00:00",
+ * "rssi": -50,
+ * "link_speed_mbps": 11,
+ * "local_ip": "10.0.2.16"
+ * },
+ * "wifi_ssid": "",
+ * "device_id": null,
+ * "battery": {
+ * "level_percent": 100,
+ * "status": "not_charging"
+ * },
+ * "memory": {
+ * "avail_mem": 888184832,
+ * "total_mem": 2070986752,
+ * "low_memory": false
+ * },
+ * "storage": {
+ * "total_bytes": 6228115456,
+ * "free_bytes": 996352000
+ * },
+ * "developer_options_enabled": false,
+ * "is_rooted": false,
+ * "root_checks": [
+ *
+ * ],
+ * "is_emulator": true,
+ * "device_type": "EMULATOR",
+ * "emulator_checks": [
+ * "product_indicates_emulator",
+ * "hardware_indicates_emulator"
+ * ]
+ * }
+ *
+ *
+ * */
+
+class TelemetryDetector(private val context: Context, private val listener: TelemeteryReporter) {
+
+ private val appContext = context.applicationContext
+ private val connectivity =
+ appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ private val telephony =
+ appContext.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
+
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
+ private var wifiReceiver: BroadcastReceiver? = null
+ private var packageReceiver: BroadcastReceiver? = null
+ private var phoneListener: PhoneStateListener? = null
+ private var running = false
+
+ // periodic reporting
+ private val reportIntervalMs = 15000L
+ private val reportHandler = Handler(Looper.getMainLooper())
+ private var reportRunnable: Runnable? = null
+
+ fun start() {
+ if (running) return
+ running = true
+ registerNetworkCallback()
+ registerWifiReceiver()
+ registerPackageReceiver()
+ registerPhoneListener()
+ // emit initial snapshot
+ emitReport("initial")
+ // schedule periodic reports
+ schedulePeriodicReports()
+ }
+
+ fun stop() {
+ if (!running) return
+ running = false
+ unregisterNetworkCallback()
+ unregisterWifiReceiver()
+ unregisterPackageReceiver()
+ unregisterPhoneListener()
+ cancelPeriodicReports()
+ }
+
+ private fun schedulePeriodicReports() {
+ // ensure previous runnable removed
+ cancelPeriodicReports()
+ val r = object : Runnable {
+ override fun run() {
+ try {
+ if (!running) return
+ emitReport("periodic")
+ } finally {
+ // re-post only if still running
+ if (running) {
+ reportHandler.postDelayed(this, reportIntervalMs)
+ }
+ }
+ }
+ }
+ reportRunnable = r
+ reportHandler.postDelayed(r, reportIntervalMs)
+ }
+
+ private fun cancelPeriodicReports() {
+ reportRunnable?.let {
+ reportHandler.removeCallbacks(it)
+ }
+ reportRunnable = null
+ }
+
+ private fun registerNetworkCallback() {
+ try {
+ val cb = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ emitReport("network_available")
+ }
+
+ override fun onLost(network: Network) {
+ emitReport("network_lost")
+ }
+ }
+ val req = NetworkRequest.Builder().build()
+ connectivity.registerNetworkCallback(req, cb)
+ networkCallback = cb
+ } catch (e: Exception) {
+ Log.w("TelemetryDetector", "network callback failed: ${e.message}")
+ }
+ }
+
+ private fun unregisterNetworkCallback() {
+ try {
+ networkCallback?.let { connectivity.unregisterNetworkCallback(it) }
+ } catch (_: Exception) {
+ }
+ networkCallback = null
+ }
+
+ private fun registerWifiReceiver() {
+ try {
+ val rx = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action
+ if (action == WifiManager.NETWORK_STATE_CHANGED_ACTION || action == WifiManager.WIFI_STATE_CHANGED_ACTION) {
+ emitReport("wifi_change")
+ }
+ }
+ }
+ val filter = IntentFilter().apply {
+ addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
+ addAction(WifiManager.WIFI_STATE_CHANGED_ACTION)
+ }
+ appContext.registerReceiver(rx, filter)
+ wifiReceiver = rx
+ } catch (e: Exception) {
+ Log.w("TelemetryDetector", "wifi receiver failed: ${e.message}")
+ }
+ }
+
+ private fun unregisterWifiReceiver() {
+ try {
+ wifiReceiver?.let { appContext.unregisterReceiver(it) }
+ } catch (_: Exception) {
+ }
+ wifiReceiver = null
+ }
+
+ private fun registerPackageReceiver() {
+ try {
+ val rx = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action
+ if (action == Intent.ACTION_PACKAGE_ADDED || action == Intent.ACTION_PACKAGE_REMOVED || action == Intent.ACTION_PACKAGE_CHANGED) {
+ emitReport("package_change")
+ }
+ }
+ }
+ val filter = IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REMOVED)
+ addAction(Intent.ACTION_PACKAGE_CHANGED)
+ addDataScheme("package")
+ }
+ appContext.registerReceiver(rx, filter)
+ packageReceiver = rx
+ } catch (e: Exception) {
+ Log.w("TelemetryDetector", "package receiver failed: ${e.message}")
+ }
+ }
+
+ private fun unregisterPackageReceiver() {
+ try {
+ packageReceiver?.let { appContext.unregisterReceiver(it) }
+ } catch (_: Exception) {
+ }
+ packageReceiver = null
+ }
+
+ private fun registerPhoneListener() {
+ try {
+ val pl = object : PhoneStateListener() {
+ override fun onCallStateChanged(state: Int, phoneNumber: String?) {
+ emitReport("phone_state_change")
+ }
+ }
+ telephony.listen(pl, PhoneStateListener.LISTEN_CALL_STATE)
+ phoneListener = pl
+ } catch (e: Exception) {
+ Log.w("TelemetryDetector", "phone listener failed: ${e.message}")
+ }
+ }
+
+ private fun unregisterPhoneListener() {
+ try {
+ phoneListener?.let { telephony.listen(it, PhoneStateListener.LISTEN_NONE) }
+ } catch (_: Exception) {
+ }
+ phoneListener = null
+ }
+
+ private fun emitReport(trigger: String) {
+ try {
+ val pretty = buildReport(trigger)
+ Log.d("TelemetryDetector", pretty) // pretty printed JSON
+ listener.onReport(pretty)
+ } catch (e: Exception) {
+ Log.w("TelemetryDetector", "emit failed: ${e.message}")
+ }
+ }
+
+ private fun buildReport(trigger: String): String {
+ val report = JSONObject()
+
+ report.put("trigger", trigger)
+ report.put("timestamp", System.currentTimeMillis())
+ report.put("uptime_millis", SystemClock.elapsedRealtime())
+ report.put("sdk_int", Build.VERSION.SDK_INT)
+ report.put("package_name", appContext.packageName)
+
+ // app / package info
+ try {
+ val pm = appContext.packageManager
+
+ @Suppress("DEPRECATION")
+ val pkgInfo: PackageInfo? = try {
+ pm.getPackageInfo(appContext.packageName, PackageManager.GET_PERMISSIONS)
+ } catch (e: Exception) {
+ try {
+ pm.getPackageInfo(appContext.packageName, 0)
+ } catch (_: Exception) {
+ null
+ }
+ }
+ pkgInfo?.let {
+ val appJson = JSONObject()
+ val versionName = try {
+ it.versionName
+ } catch (_: Exception) {
+ null
+ }
+ val versionCode = try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) it.longVersionCode else it.versionCode.toLong()
+ } catch (_: Exception) {
+ null
+ }
+ appJson.put("version_name", versionName ?: JSONObject.NULL)
+ appJson.put("version_code", versionCode ?: JSONObject.NULL)
+ appJson.put("first_install_time", it.firstInstallTime)
+ appJson.put("last_update_time", it.lastUpdateTime)
+ // requested permissions + grant state
+ try {
+ val permsArr = JSONArray()
+ val requested = it.requestedPermissions
+ if (requested != null) {
+ for (p in requested) {
+ val pJson = JSONObject()
+ pJson.put("name", p)
+ val granted = try {
+ ContextCompat.checkSelfPermission(
+ appContext,
+ p
+ ) == PackageManager.PERMISSION_GRANTED
+ } catch (_: Exception) {
+ false
+ }
+ pJson.put("granted", granted)
+ permsArr.put(pJson)
+ }
+ }
+ appJson.put("requested_permissions", permsArr)
+ } catch (_: Exception) {
+ // ignore
+ }
+ report.put("app", appJson)
+ }
+ } catch (e: Exception) {
+ report.put("app_info_error", e.javaClass.simpleName)
+ }
+
+ // permissions of interest (explicit list)
+ try {
+ val permissionList = listOf(
+ Manifest.permission.ACCESS_NETWORK_STATE,
+ Manifest.permission.INTERNET,
+ Manifest.permission.WAKE_LOCK,
+ "android.permission.QUERY_ALL_PACKAGES",
+ Manifest.permission.READ_PHONE_STATE,
+ "android.permission.ACTIVITY_RECOGNITION",
+ Manifest.permission.RECEIVE_BOOT_COMPLETED,
+ Manifest.permission.ACCESS_WIFI_STATE,
+ "android.permission.PACKAGE_USAGE_STATS",
+ "android.permission.FOREGROUND_SERVICE_DATA_SYNC"
+ )
+ val permsJson = JSONObject()
+ for (perm in permissionList) {
+ val granted = try {
+ ContextCompat.checkSelfPermission(
+ appContext,
+ perm
+ ) == PackageManager.PERMISSION_GRANTED
+ } catch (_: Exception) {
+ false
+ }
+ permsJson.put(perm, granted)
+ }
+ report.put("permissions", permsJson)
+ } catch (e: Exception) {
+ report.put("permissions_error", e.javaClass.simpleName)
+ }
+
+ // usage access check
+ try {
+ val appOps = appContext.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
+ val hasUsageAccess = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ appOps.checkOpNoThrow(
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
+ Binder.getCallingUid(),
+ appContext.packageName
+ ) == AppOpsManager.MODE_ALLOWED
+ } else {
+ false
+ }
+ report.put("usage_stats_allowed", hasUsageAccess)
+ } catch (e: Exception) {
+ report.put("usage_stats_error", e.javaClass.simpleName)
+ }
+
+ // network basic
+ try {
+ val networkJson = JSONObject()
+ val activeNetwork = connectivity.activeNetwork
+ if (activeNetwork != null) {
+ val caps = try {
+ connectivity.getNetworkCapabilities(activeNetwork)
+ } catch (_: kotlin.Exception) {
+ null
+ }
+
+ val isConnected = caps != null && (
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ||
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)
+ )
+ networkJson.put("isConnected", isConnected)
+
+ val type = when {
+ caps == null -> JSONObject.NULL
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WIFI"
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "CELLULAR"
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ETHERNET"
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> "BLUETOOTH"
+ else -> "OTHER"
+ }
+ networkJson.put("type", type)
+
+ // no direct type_code with new API; keep numeric fields null for compatibility
+ networkJson.put("type_code", JSONObject.NULL)
+
+ // bandwidth (kbps) if available
+ networkJson.put("down_kbps", caps?.linkDownstreamBandwidthKbps ?: JSONObject.NULL)
+ networkJson.put("up_kbps", caps?.linkUpstreamBandwidthKbps ?: JSONObject.NULL)
+
+ // attempt to include telephony subtype info when on cellular (numeric values)
+ val subtypeName: Any = try {
+ if (caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true) {
+ // numeric network type; caller can map if needed
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) telephony.dataNetworkType else telephony.networkType
+ } else JSONObject.NULL
+ } catch (_: kotlin.Exception) {
+ JSONObject.NULL
+ }
+
+ networkJson.put("subtype_name", subtypeName)
+ networkJson.put("subtype", JSONObject.NULL) // keep for compatibility
+ } else {
+ networkJson.put("isConnected", false)
+ networkJson.put("type", JSONObject.NULL)
+ networkJson.put("type_code", JSONObject.NULL)
+ networkJson.put("subtype_name", JSONObject.NULL)
+ networkJson.put("subtype", JSONObject.NULL)
+ networkJson.put("down_kbps", JSONObject.NULL)
+ networkJson.put("up_kbps", JSONObject.NULL)
+ }
+ report.put("network", networkJson)
+ } catch (e: Exception) {
+ report.put("network_error", e.javaClass.simpleName)
+ }
+
+ // wifi details
+ try {
+ val wifiJson = JSONObject()
+
+ @Suppress("DEPRECATION")
+ val info = wifiManager.connectionInfo
+ if (info != null) {
+ val ssid = try {
+ info.ssid
+ } catch (_: Exception) {
+ null
+ }
+ val bssid = try {
+ info.bssid
+ } catch (_: Exception) {
+ null
+ }
+ val rssi = try {
+ info.rssi
+ } catch (_: Exception) {
+ null
+ }
+ val linkSpeed = try {
+ info.linkSpeed
+ } catch (_: Exception) {
+ null
+ }
+ val ipInt = try {
+ info.ipAddress
+ } catch (_: Exception) {
+ 0
+ }
+ val ip = try {
+ if (ipInt != 0) Formatter.formatIpAddress(ipInt) else JSONObject.NULL
+ } catch (_: Exception) {
+ JSONObject.NULL
+ }
+
+ wifiJson.put("ssid", ssid ?: JSONObject.NULL)
+ wifiJson.put("bssid", bssid ?: JSONObject.NULL)
+ wifiJson.put("rssi", rssi ?: JSONObject.NULL)
+ wifiJson.put("link_speed_mbps", linkSpeed ?: JSONObject.NULL)
+ wifiJson.put("local_ip", ip)
+ } else {
+ wifiJson.put("connected", false)
+ }
+ report.put("wifi", wifiJson)
+ report.put("wifi_ssid", (info?.ssid ?: JSONObject.NULL))
+ } catch (e: Exception) {
+ report.put("wifi_error", e.javaClass.simpleName)
+ }
+
+ // device id only if permission granted
+ try {
+ var deviceId: Any = JSONObject.NULL
+ if (ContextCompat.checkSelfPermission(
+ appContext,
+ Manifest.permission.READ_PHONE_STATE
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ deviceId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ try {
+ telephony.imei ?: JSONObject.NULL
+ } catch (_: Throwable) {
+ JSONObject.NULL
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ telephony.deviceId ?: JSONObject.NULL
+ }
+ }
+ report.put("device_id", deviceId)
+ } catch (e: Exception) {
+ report.put("device_id_error", e.javaClass.simpleName)
+ }
+
+ // battery info
+ try {
+ val battIntent =
+ appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
+ val battJson = JSONObject()
+ if (battIntent != null) {
+ val level = battIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
+ val scale = battIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
+ val pct = if (level >= 0 && scale > 0) (level * 100) / scale else JSONObject.NULL
+ val status = battIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
+ val statusStr = when (status) {
+ BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
+ BatteryManager.BATTERY_STATUS_FULL -> "full"
+ BatteryManager.BATTERY_STATUS_DISCHARGING -> "discharging"
+ BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "not_charging"
+ BatteryManager.BATTERY_STATUS_UNKNOWN -> "unknown"
+ else -> "unknown"
+ }
+ battJson.put("level_percent", pct)
+ battJson.put("status", statusStr)
+ }
+ report.put("battery", battJson)
+ } catch (e: Exception) {
+ report.put("battery_error", e.javaClass.simpleName)
+ }
+
+ // memory info
+ try {
+ val am = appContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ val mi = MemoryInfo()
+ am.getMemoryInfo(mi)
+ val memJson = JSONObject()
+ memJson.put("avail_mem", mi.availMem)
+ memJson.put(
+ "total_mem",
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) mi.totalMem else JSONObject.NULL
+ )
+ memJson.put("low_memory", mi.lowMemory)
+ report.put("memory", memJson)
+ } catch (e: Exception) {
+ report.put("memory_error", e.javaClass.simpleName)
+ }
+
+ // storage info (app data dir)
+ try {
+ val dataDir = appContext.filesDir
+ val st = StatFs(dataDir.absolutePath)
+ val blockSize = st.blockSizeLong
+ val total = st.blockCountLong * blockSize
+ val free = st.availableBlocksLong * blockSize
+ val storageJson = JSONObject()
+ storageJson.put("total_bytes", total)
+ storageJson.put("free_bytes", free)
+ report.put("storage", storageJson)
+ } catch (e: Exception) {
+ report.put("storage_error", e.javaClass.simpleName)
+ }
+
+ // developer options enabled
+ try {
+ val devEnabled = isDeveloperOptionsEnabled()
+ report.put("developer_options_enabled", devEnabled)
+ } catch (e: Exception) {
+ report.put("developer_options_error", e.javaClass.simpleName)
+ }
+
+ // root detection
+ try {
+ val rootChecks = JSONArray()
+ val isRooted = isDeviceRooted(rootChecks)
+ report.put("is_rooted", isRooted)
+ report.put("root_checks", rootChecks)
+ } catch (e: Exception) {
+ report.put("root_detection_error", e.javaClass.simpleName)
+ }
+
+
+ try {
+ val emuChecks = JSONArray()
+ val isEmu = detectEmulator(emuChecks)
+ report.put("is_emulator", isEmu)
+ report.put("device_type", if (isEmu) "EMULATOR" else "PHYSICAL")
+ if (emuChecks.length() > 0) report.put("emulator_checks", emuChecks)
+ } catch (e: Exception) {
+ report.put("emulator_detection_error", e.javaClass.simpleName)
+ }
+ // final pretty string (indent = 2)
+ return try {
+ report.toString(2)
+ } catch (e: Exception) {
+ // fallback to compact
+ report.toString()
+ }
+ }
+
+ private fun isDeveloperOptionsEnabled(): Boolean {
+ return try {
+ val resolver = appContext.contentResolver
+ val enabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ Settings.Global.getInt(resolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0)
+ } else {
+ @Suppress("DEPRECATION")
+ Settings.Secure.getInt(resolver, Settings.Secure.DEVELOPMENT_SETTINGS_ENABLED, 0)
+ }
+ enabled == 1
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ private fun isDeviceRooted(outReasons: JSONArray): Boolean {
+ try {
+ // 1) Build tags
+ val tags = Build.TAGS
+ if (!tags.isNullOrEmpty() && (tags.contains("test-keys") || tags.contains("dev-keys"))) {
+ outReasons.put("build_tags_contains_test_or_dev_keys")
+ }
+
+ // 2) Common su locations
+ val suPaths = arrayOf(
+ "/system/app/Superuser.apk",
+ "/sbin/su",
+ "/system/bin/su",
+ "/system/xbin/su",
+ "/data/local/xbin/su",
+ "/data/local/bin/su",
+ "/system/sd/xbin/su",
+ "/system/bin/failsafe/su",
+ "/data/local/su"
+ )
+ for (p in suPaths) {
+ try {
+ if (File(p).exists()) {
+ outReasons.put("su_binary_found:$p")
+ }
+ } catch (_: Exception) { /* ignore */
+ }
+ }
+
+ // 3) Presence of su in PATH via `which su`
+ try {
+ val proc = Runtime.getRuntime().exec(arrayOf("/system/bin/sh", "-c", "which su"))
+ BufferedReader(InputStreamReader(proc.inputStream)).use { br ->
+ val line = br.readLine()
+ if (!line.isNullOrEmpty()) {
+ outReasons.put("which_su_found:$line")
+ }
+ }
+ } catch (_: Exception) {
+ // ignore failures
+ }
+
+ // 4) Superuser.apk in package manager (loosely)
+ try {
+ val pm = appContext.packageManager
+ val packages = pm.getInstalledPackages(0)
+ for (pkg in packages) {
+ val name = pkg.packageName
+ if (name.equals("com.noshufou.android.su", ignoreCase = true) ||
+ name.equals("eu.chainfire.supersu", ignoreCase = true) ||
+ name.equals("com.koushikdutta.superuser", ignoreCase = true) ||
+ name.contains("super", ignoreCase = true) && name.contains(
+ "user",
+ ignoreCase = true
+ )
+ ) {
+ outReasons.put("suspicious_superuser_package:$name")
+ break
+ }
+ }
+ } catch (_: Exception) {
+ // ignore
+ }
+
+ return outReasons.length() > 0
+ } catch (_: Exception) {
+ return false
+ }
+ }
+
+ private fun detectEmulator(outReasons: JSONArray): Boolean {
+ try {
+ val fingerprint = Build.FINGERPRINT ?: ""
+ val model = Build.MODEL ?: ""
+ val manufacturer = Build.MANUFACTURER ?: ""
+ val brand = Build.BRAND ?: ""
+ val device = Build.DEVICE ?: ""
+ val product = Build.PRODUCT ?: ""
+ val hardware = Build.HARDWARE ?: ""
+ val board = Build.BOARD ?: ""
+
+ val reasons = mutableListOf()
+
+ if (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown")) {
+ reasons.add("fingerprint_generic_or_unknown")
+ }
+ if (model.contains("Emulator", ignoreCase = true) ||
+ model.contains("Android SDK built for", ignoreCase = true) ||
+ model.contains("google_sdk", ignoreCase = true)
+ ) {
+ reasons.add("model_indicates_emulator")
+ }
+ if (manufacturer.contains("Genymotion", ignoreCase = true)) {
+ reasons.add("manufacturer_genymotion")
+ }
+ if (brand.startsWith("generic") && device.startsWith("generic")) {
+ reasons.add("brand_and_device_generic")
+ }
+ if (product.contains("sdk", ignoreCase = true) ||
+ product.contains("vbox86p", ignoreCase = true) ||
+ product.contains("emulator", ignoreCase = true)
+ ) {
+ reasons.add("product_indicates_emulator")
+ }
+ if (hardware.contains("goldfish", ignoreCase = true) ||
+ hardware.contains("ranchu", ignoreCase = true) ||
+ hardware.contains("qemu", ignoreCase = true)
+ ) {
+ reasons.add("hardware_indicates_emulator")
+ }
+ if (board.equals("unknown", ignoreCase = true)) {
+ reasons.add("board_unknown")
+ }
+
+ // common emulator files / sockets
+ try {
+ if (File("/dev/socket/qemu_pipe").exists()) reasons.add("qemu_pipe_present")
+ if (File("/system/lib/libc_malloc_debug_qemu.so").exists()) reasons.add("qemu_so_present")
+ if (File("/system/bin/qemu-props").exists()) reasons.add("qemu_props_present")
+ } catch (_: kotlin.Exception) { /* ignore filesystem access errors */
+ }
+
+ if (reasons.isNotEmpty()) {
+ for (r in reasons) outReasons.put(r)
+ return true
+ }
+ return false
+ } catch (_: kotlin.Exception) {
+ return false
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/utils/iCameraSdkConfiguration.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/utils/iCameraSdkConfiguration.kt
new file mode 100644
index 0000000..2870e27
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/utils/iCameraSdkConfiguration.kt
@@ -0,0 +1,4 @@
+package com.lynkedup.icamera.sdk.utils
+
+class iCameraSdkConfiguration {
+}
\ No newline at end of file
diff --git a/android/sdk/src/main/java/com/lynkedup/icamera/sdk/utils/iCameraSdkError.kt b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/utils/iCameraSdkError.kt
new file mode 100644
index 0000000..586fca1
--- /dev/null
+++ b/android/sdk/src/main/java/com/lynkedup/icamera/sdk/utils/iCameraSdkError.kt
@@ -0,0 +1,27 @@
+package com.lynkedup.icamera.sdk.utils
+
+
+/**
+ * Represents a set of possible errors that can occur within the iCameraSDK.
+ */
+public enum class ICameraSDKError {
+ /**
+ * The user has denied access to the camera.
+ */
+ CAMERA_ACCESS_DENIED,
+
+ /**
+ * The configuration provided to the SDK is invalid.
+ */
+ INVALID_CONFIGURATION,
+
+ /**
+ * The file upload process failed.
+ */
+ UPLOAD_FAILED,
+
+ /**
+ * An unknown or unspecified error occurred.
+ */
+ UNKNOWN
+}
diff --git a/android/sdk/src/test/java/com/lynkedup/icamera/sdk/ExampleUnitTest.kt b/android/sdk/src/test/java/com/lynkedup/icamera/sdk/ExampleUnitTest.kt
new file mode 100644
index 0000000..7adbac1
--- /dev/null
+++ b/android/sdk/src/test/java/com/lynkedup/icamera/sdk/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.lynkedup.icamera.sdk
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/android/settings.gradle b/android/settings.gradle
index 6f4e766..8e67f2c 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -3,4 +3,5 @@ plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
rootProject.name = 'LynkedUpPro'
include ':app'
+include ':sdk'
includeBuild('../node_modules/@react-native/gradle-plugin')