Android: JPEG canvas snapshots + camera permission prompts
This commit is contained in:
@@ -24,6 +24,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val viewModel: MainViewModel by viewModels()
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
private lateinit var permissionRequester: PermissionRequester
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -33,7 +34,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
requestDiscoveryPermissionsIfNeeded()
|
requestDiscoveryPermissionsIfNeeded()
|
||||||
requestNotificationPermissionIfNeeded()
|
requestNotificationPermissionIfNeeded()
|
||||||
NodeForegroundService.start(this)
|
NodeForegroundService.start(this)
|
||||||
|
permissionRequester = PermissionRequester(this)
|
||||||
viewModel.camera.attachLifecycleOwner(this)
|
viewModel.camera.attachLifecycleOwner(this)
|
||||||
|
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
|||||||
@@ -576,17 +576,21 @@ class NodeRuntime(context: Context) {
|
|||||||
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||||
}
|
}
|
||||||
ClawdisCanvasCommand.Snapshot.rawValue -> {
|
ClawdisCanvasCommand.Snapshot.rawValue -> {
|
||||||
val maxWidth = CanvasController.parseSnapshotMaxWidth(paramsJson)
|
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||||
val base64 =
|
val base64 =
|
||||||
try {
|
try {
|
||||||
canvas.snapshotPngBase64(maxWidth = maxWidth)
|
canvas.snapshotBase64(
|
||||||
|
format = snapshotParams.format,
|
||||||
|
quality = snapshotParams.quality,
|
||||||
|
maxWidth = snapshotParams.maxWidth,
|
||||||
|
)
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return BridgeSession.InvokeResult.error(
|
||||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
BridgeSession.InvokeResult.ok("""{"format":"png","base64":"$base64"}""")
|
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||||
}
|
}
|
||||||
ClawdisCanvasA2UICommand.Reset.rawValue -> {
|
ClawdisCanvasA2UICommand.Reset.rawValue -> {
|
||||||
val ready = ensureA2uiReady()
|
val ready = ensureA2uiReady()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class PermissionRequester(private val activity: ComponentActivity) {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
||||||
|
|
||||||
|
private val launcher: ActivityResultLauncher<Array<String>> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||||
|
val p = pending
|
||||||
|
pending = null
|
||||||
|
p?.complete(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun requestIfMissing(
|
||||||
|
permissions: List<String>,
|
||||||
|
timeoutMs: Long = 20_000,
|
||||||
|
): Map<String, Boolean> =
|
||||||
|
mutex.withLock {
|
||||||
|
val missing =
|
||||||
|
permissions.filter { perm ->
|
||||||
|
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missing.isEmpty()) {
|
||||||
|
return permissions.associateWith { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||||
|
pending = deferred
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
launcher.launch(missing.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
||||||
|
return permissions.associateWith { perm ->
|
||||||
|
val nowGranted =
|
||||||
|
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||||
|
result[perm] == true || nowGranted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ import androidx.camera.video.VideoCapture
|
|||||||
import androidx.camera.video.VideoRecordEvent
|
import androidx.camera.video.VideoRecordEvent
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.ContextCompat.checkSelfPermission
|
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||||
|
import com.steipete.clawdis.node.PermissionRequester
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -32,24 +33,43 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
data class Payload(val payloadJson: String)
|
data class Payload(val payloadJson: String)
|
||||||
|
|
||||||
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||||
|
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||||
|
|
||||||
fun attachLifecycleOwner(owner: LifecycleOwner) {
|
fun attachLifecycleOwner(owner: LifecycleOwner) {
|
||||||
lifecycleOwner = owner
|
lifecycleOwner = owner
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requireCameraPermission() {
|
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||||
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
permissionRequester = requester
|
||||||
if (!granted) throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requireMicPermission() {
|
private suspend fun ensureCameraPermission() {
|
||||||
|
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) return
|
||||||
|
|
||||||
|
val requester = permissionRequester
|
||||||
|
?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||||
|
val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA))
|
||||||
|
if (results[Manifest.permission.CAMERA] != true) {
|
||||||
|
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureMicPermission() {
|
||||||
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
if (!granted) throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
if (granted) return
|
||||||
|
|
||||||
|
val requester = permissionRequester
|
||||||
|
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||||
|
val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO))
|
||||||
|
if (results[Manifest.permission.RECORD_AUDIO] != true) {
|
||||||
|
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun snap(paramsJson: String?): Payload =
|
suspend fun snap(paramsJson: String?): Payload =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
requireCameraPermission()
|
ensureCameraPermission()
|
||||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||||
val facing = parseFacing(paramsJson) ?: "front"
|
val facing = parseFacing(paramsJson) ?: "front"
|
||||||
val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0)
|
val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0)
|
||||||
@@ -90,12 +110,12 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
|
|
||||||
suspend fun clip(paramsJson: String?): Payload =
|
suspend fun clip(paramsJson: String?): Payload =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
requireCameraPermission()
|
ensureCameraPermission()
|
||||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||||
val facing = parseFacing(paramsJson) ?: "front"
|
val facing = parseFacing(paramsJson) ?: "front"
|
||||||
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 45_000)
|
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000)
|
||||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||||
if (includeAudio) requireMicPermission()
|
if (includeAudio) ensureMicPermission()
|
||||||
|
|
||||||
val provider = context.cameraProvider()
|
val provider = context.cameraProvider()
|
||||||
val recorder = Recorder.Builder().build()
|
val recorder = Recorder.Builder().build()
|
||||||
|
|||||||
@@ -5,20 +5,33 @@ import android.os.Build
|
|||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import org.json.JSONObject
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
class CanvasController {
|
class CanvasController {
|
||||||
|
enum class SnapshotFormat(val rawValue: String) {
|
||||||
|
Png("png"),
|
||||||
|
Jpeg("jpeg"),
|
||||||
|
}
|
||||||
|
|
||||||
@Volatile private var webView: WebView? = null
|
@Volatile private var webView: WebView? = null
|
||||||
@Volatile private var url: String? = null
|
@Volatile private var url: String? = null
|
||||||
|
|
||||||
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||||
|
|
||||||
|
private fun clampJpegQuality(quality: Double?): Int {
|
||||||
|
val q = (quality ?: 0.82).coerceIn(0.1, 1.0)
|
||||||
|
return (q * 100.0).toInt().coerceIn(1, 100)
|
||||||
|
}
|
||||||
|
|
||||||
fun attach(webView: WebView) {
|
fun attach(webView: WebView) {
|
||||||
this.webView = webView
|
this.webView = webView
|
||||||
reload()
|
reload()
|
||||||
@@ -77,6 +90,28 @@ class CanvasController {
|
|||||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val wv = webView ?: throw IllegalStateException("no webview")
|
||||||
|
val bmp = wv.captureBitmap()
|
||||||
|
val scaled =
|
||||||
|
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||||
|
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||||
|
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||||
|
} else {
|
||||||
|
bmp
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val (compressFormat, compressQuality) =
|
||||||
|
when (format) {
|
||||||
|
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
|
||||||
|
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
|
||||||
|
}
|
||||||
|
scaled.compress(compressFormat, compressQuality, out)
|
||||||
|
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun WebView.captureBitmap(): Bitmap =
|
private suspend fun WebView.captureBitmap(): Bitmap =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
val width = width.coerceAtLeast(1)
|
val width = width.coerceAtLeast(1)
|
||||||
@@ -90,32 +125,81 @@ class CanvasController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?)
|
||||||
|
|
||||||
fun parseNavigateUrl(paramsJson: String?): String {
|
fun parseNavigateUrl(paramsJson: String?): String {
|
||||||
val obj = parseParamsObject(paramsJson)
|
val obj = parseParamsObject(paramsJson) ?: return ""
|
||||||
return obj?.optString("url", "")?.trim().orEmpty()
|
return obj.string("url").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseEvalJs(paramsJson: String?): String? {
|
fun parseEvalJs(paramsJson: String?): String? {
|
||||||
val obj = parseParamsObject(paramsJson) ?: return null
|
val obj = parseParamsObject(paramsJson) ?: return null
|
||||||
val js = obj.optString("javaScript", "")
|
val js = obj.string("javaScript").trim()
|
||||||
return js.takeIf { it.isNotBlank() }
|
return js.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseSnapshotMaxWidth(paramsJson: String?): Int? {
|
fun parseSnapshotMaxWidth(paramsJson: String?): Int? {
|
||||||
val obj = parseParamsObject(paramsJson) ?: return null
|
val obj = parseParamsObject(paramsJson) ?: return null
|
||||||
if (!obj.has("maxWidth")) return null
|
if (!obj.containsKey("maxWidth")) return null
|
||||||
val width = obj.optInt("maxWidth", 0)
|
val width = obj.int("maxWidth") ?: 0
|
||||||
return width.takeIf { it > 0 }
|
return width.takeIf { it > 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseParamsObject(paramsJson: String?): JSONObject? {
|
fun parseSnapshotFormat(paramsJson: String?): SnapshotFormat {
|
||||||
val raw = paramsJson?.trim() ?: return null
|
val obj = parseParamsObject(paramsJson) ?: return SnapshotFormat.Jpeg
|
||||||
if (raw.isBlank()) return null
|
val raw = obj.string("format").trim().lowercase()
|
||||||
|
return when (raw) {
|
||||||
|
"png" -> SnapshotFormat.Png
|
||||||
|
"jpeg", "jpg" -> SnapshotFormat.Jpeg
|
||||||
|
"" -> SnapshotFormat.Jpeg
|
||||||
|
else -> SnapshotFormat.Jpeg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSnapshotQuality(paramsJson: String?): Double? {
|
||||||
|
val obj = parseParamsObject(paramsJson) ?: return null
|
||||||
|
if (!obj.containsKey("quality")) return null
|
||||||
|
val q = obj.double("quality") ?: Double.NaN
|
||||||
|
if (!q.isFinite()) return null
|
||||||
|
return q.coerceIn(0.1, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSnapshotParams(paramsJson: String?): SnapshotParams {
|
||||||
|
return SnapshotParams(
|
||||||
|
format = parseSnapshotFormat(paramsJson),
|
||||||
|
quality = parseSnapshotQuality(paramsJson),
|
||||||
|
maxWidth = parseSnapshotMaxWidth(paramsJson),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||||
|
val raw = paramsJson?.trim().orEmpty()
|
||||||
|
if (raw.isEmpty()) return null
|
||||||
return try {
|
return try {
|
||||||
JSONObject(raw)
|
json.parseToJsonElement(raw).asObjectOrNull()
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonObject.string(key: String): String {
|
||||||
|
val prim = this[key] as? JsonPrimitive ?: return ""
|
||||||
|
val raw = prim.content
|
||||||
|
return raw.takeIf { it != "null" }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.int(key: String): Int? {
|
||||||
|
val prim = this[key] as? JsonPrimitive ?: return null
|
||||||
|
return prim.content.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.double(key: String): Double? {
|
||||||
|
val prim = this[key] as? JsonPrimitive ?: return null
|
||||||
|
return prim.content.toDoubleOrNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CanvasControllerSnapshotParamsTest {
|
||||||
|
@Test
|
||||||
|
fun parseSnapshotParamsDefaultsToJpeg() {
|
||||||
|
val params = CanvasController.parseSnapshotParams(null)
|
||||||
|
assertEquals(CanvasController.SnapshotFormat.Jpeg, params.format)
|
||||||
|
assertNull(params.quality)
|
||||||
|
assertNull(params.maxWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSnapshotParamsParsesPng() {
|
||||||
|
val params = CanvasController.parseSnapshotParams("""{"format":"png","maxWidth":900}""")
|
||||||
|
assertEquals(CanvasController.SnapshotFormat.Png, params.format)
|
||||||
|
assertEquals(900, params.maxWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSnapshotParamsParsesJpegAliases() {
|
||||||
|
assertEquals(
|
||||||
|
CanvasController.SnapshotFormat.Jpeg,
|
||||||
|
CanvasController.parseSnapshotParams("""{"format":"jpeg"}""").format,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CanvasController.SnapshotFormat.Jpeg,
|
||||||
|
CanvasController.parseSnapshotParams("""{"format":"jpg"}""").format,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSnapshotParamsClampsQuality() {
|
||||||
|
val low = CanvasController.parseSnapshotParams("""{"quality":0.01}""")
|
||||||
|
assertEquals(0.1, low.quality)
|
||||||
|
|
||||||
|
val high = CanvasController.parseSnapshotParams("""{"quality":5}""")
|
||||||
|
assertEquals(1.0, high.quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user