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.
This commit is contained in:
@@ -124,4 +124,7 @@ dependencies {
|
|||||||
|
|
||||||
// QR Code generation
|
// QR Code generation
|
||||||
implementation 'com.google.zxing:core:3.5.1'
|
implementation 'com.google.zxing:core:3.5.1'
|
||||||
|
|
||||||
|
// iCameraSDK integration
|
||||||
|
implementation(project(':sdk'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext {
|
ext {
|
||||||
buildToolsVersion = "36.0.0"
|
buildToolsVersion = "36.0.0"
|
||||||
minSdkVersion = 24
|
minSdkVersion = 26
|
||||||
compileSdkVersion = 36
|
compileSdkVersion = 36
|
||||||
targetSdkVersion = 36
|
targetSdkVersion = 36
|
||||||
ndkVersion = "27.1.12297006"
|
ndkVersion = "27.1.12297006"
|
||||||
|
|||||||
1
android/sdk/.gitignore
vendored
Normal file
1
android/sdk/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
71
android/sdk/build.gradle.kts
Normal file
71
android/sdk/build.gradle.kts
Normal file
@@ -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")
|
||||||
|
}
|
||||||
0
android/sdk/consumer-rules.pro
Normal file
0
android/sdk/consumer-rules.pro
Normal file
21
android/sdk/proguard-rules.pro
vendored
Normal file
21
android/sdk/proguard-rules.pro
vendored
Normal file
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
android/sdk/src/main/AndroidManifest.xml
Normal file
16
android/sdk/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_PRIVILEGED_PHONE_STATE"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
</manifest>
|
||||||
@@ -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<CaptureFeature>,
|
||||||
|
val presignedUploadURL: URL
|
||||||
|
)
|
||||||
|
|
||||||
|
class CoreCaptureEngine(configuration: CoreCaptureConfiguration, context: Context, lifecycleOwner: LifecycleOwner) {
|
||||||
|
|
||||||
|
private val uploadEngine: UploadEngine = UploadEngine(configuration.presignedUploadURL)
|
||||||
|
private var featureEngines: MutableMap<CaptureFeature, CaptureFeatureEngine> = 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 -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.lynkedup.icamera.sdk.ScannerEngine
|
||||||
|
|
||||||
|
interface ScannerDelegate {
|
||||||
|
fun onScanned(code: String, format: Int)
|
||||||
|
fun onScanFailed(error: Exception)
|
||||||
|
}
|
||||||
@@ -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<Int> = 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<ScannerDelegate?> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.lynkedup.icamera.sdk.UploadEngine
|
||||||
|
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class UploadEngine(uploadURL: URL) {
|
||||||
|
}
|
||||||
@@ -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<CaptureFeature>) {
|
||||||
|
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<CaptureFeature>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.lynkedup.icamera.sdk.idp_kit
|
||||||
|
|
||||||
|
|
||||||
|
interface TelemeteryReporter {
|
||||||
|
fun onReport(json: String)
|
||||||
|
}
|
||||||
@@ -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": "<unknown ssid>",
|
||||||
|
* "bssid": "02:00:00:00:00:00",
|
||||||
|
* "rssi": -50,
|
||||||
|
* "link_speed_mbps": 11,
|
||||||
|
* "local_ip": "10.0.2.16"
|
||||||
|
* },
|
||||||
|
* "wifi_ssid": "<unknown 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<String>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.lynkedup.icamera.sdk.utils
|
||||||
|
|
||||||
|
class iCameraSdkConfiguration {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ plugins { id("com.facebook.react.settings") }
|
|||||||
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
|
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
|
||||||
rootProject.name = 'LynkedUpPro'
|
rootProject.name = 'LynkedUpPro'
|
||||||
include ':app'
|
include ':app'
|
||||||
|
include ':sdk'
|
||||||
includeBuild('../node_modules/@react-native/gradle-plugin')
|
includeBuild('../node_modules/@react-native/gradle-plugin')
|
||||||
|
|||||||
Reference in New Issue
Block a user