From 4461019a42cef722b9d31c9992e11a414fc2db81 Mon Sep 17 00:00:00 2001 From: Mansi Date: Tue, 16 Dec 2025 23:26:25 +0530 Subject: [PATCH] feat: Implement CoreCaptureEngine and ScannerEngine for barcode scanning functionality - Added CoreCaptureEngine to manage capture features and configurations. - Introduced CaptureFeature enum to define available capture features. - Created CaptureFeatureEngine interface for feature-specific implementations. - Developed ScannerEngine to handle QR and barcode scanning using ML Kit. - Implemented ScannerDelegate interface for handling scan results and errors. - Added ScannerEngineActivity to provide a UI for scanning with Compose. - Created UploadEngine class for handling uploads with presigned URLs. - Established iCameraSDK for SDK initialization and telemetry reporting. - Added TelemetryDetector for monitoring device and app telemetry. - Defined ICameraSDKError enum for error handling within the SDK. - Included example unit test for basic functionality verification. - Updated settings.gradle to include the new SDK module. --- android/app/build.gradle | 3 + android/build.gradle | 2 +- android/sdk/.gitignore | 1 + android/sdk/build.gradle.kts | 71 ++ android/sdk/consumer-rules.pro | 0 android/sdk/proguard-rules.pro | 21 + .../icamera/sdk/ExampleInstrumentedTest.kt | 24 + android/sdk/src/main/AndroidManifest.xml | 16 + .../CoreCaptureEngine/CoreCaptureEngine.kt | 59 ++ .../sdk/ScannerEngine/ScannerDelegate.kt | 6 + .../sdk/ScannerEngine/ScannerEngine.kt | 147 ++++ .../ScannerEngine/ScannerEngineActivity.kt | 72 ++ .../icamera/sdk/UploadEngine/UploadEngine.kt | 6 + .../com/lynkedup/icamera/sdk/iCameraSDK.kt | 89 ++ .../icamera/sdk/idp_kit/TelemeteryReporter.kt | 6 + .../icamera/sdk/idp_kit/TelemetryDetector.kt | 833 ++++++++++++++++++ .../sdk/utils/iCameraSdkConfiguration.kt | 4 + .../icamera/sdk/utils/iCameraSdkError.kt | 27 + .../lynkedup/icamera/sdk/ExampleUnitTest.kt | 17 + android/settings.gradle | 1 + 20 files changed, 1404 insertions(+), 1 deletion(-) create mode 100644 android/sdk/.gitignore create mode 100644 android/sdk/build.gradle.kts create mode 100644 android/sdk/consumer-rules.pro create mode 100644 android/sdk/proguard-rules.pro create mode 100644 android/sdk/src/androidTest/java/com/lynkedup/icamera/sdk/ExampleInstrumentedTest.kt create mode 100644 android/sdk/src/main/AndroidManifest.xml create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/CoreCaptureEngine/CoreCaptureEngine.kt create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerDelegate.kt create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerEngine.kt create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/ScannerEngine/ScannerEngineActivity.kt create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/UploadEngine/UploadEngine.kt create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/iCameraSDK.kt create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/idp_kit/TelemeteryReporter.kt create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/idp_kit/TelemetryDetector.kt create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/utils/iCameraSdkConfiguration.kt create mode 100644 android/sdk/src/main/java/com/lynkedup/icamera/sdk/utils/iCameraSdkError.kt create mode 100644 android/sdk/src/test/java/com/lynkedup/icamera/sdk/ExampleUnitTest.kt 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')