feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
@@ -0,0 +1,176 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val uploadKeystorePath = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH").orNull
|
||||
val uploadKeystorePassword = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEYSTORE_PASSWORD").orNull
|
||||
val uploadKeyAlias = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEY_ALIAS").orNull
|
||||
val uploadKeyPassword = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEY_PASSWORD").orNull
|
||||
val uploadSigningValues =
|
||||
mapOf(
|
||||
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH" to uploadKeystorePath,
|
||||
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PASSWORD" to uploadKeystorePassword,
|
||||
"BUZZ_ANDROID_UPLOAD_KEY_ALIAS" to uploadKeyAlias,
|
||||
"BUZZ_ANDROID_UPLOAD_KEY_PASSWORD" to uploadKeyPassword,
|
||||
)
|
||||
val missingUploadSigningValues = uploadSigningValues.filterValues { it.isNullOrBlank() }.keys
|
||||
val hasUploadSigning = missingUploadSigningValues.isEmpty()
|
||||
|
||||
// Worktree-aware debug identity (gitignored, written by
|
||||
// scripts/mobile-worktree-overrides.sh): debug builds from a git worktree get a
|
||||
// branch-labelled app name and a unique applicationId suffix so builds from
|
||||
// multiple worktrees install side by side. Release builds never read this.
|
||||
val worktreePropsFile = rootProject.file("worktree.properties")
|
||||
val worktreeProps =
|
||||
Properties().apply {
|
||||
if (worktreePropsFile.isFile) worktreePropsFile.inputStream().use { load(it) }
|
||||
}
|
||||
val worktreeLabel = worktreeProps.getProperty("label")?.takeIf { it.isNotBlank() }
|
||||
if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+"""))) {
|
||||
throw GradleException(
|
||||
"worktree.properties label must match [A-Za-z0-9._-]+ (safe for string " +
|
||||
"resources), got: " + worktreeLabel,
|
||||
)
|
||||
}
|
||||
val worktreeIdSuffix =
|
||||
worktreeProps.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() }
|
||||
if (worktreeIdSuffix != null && !worktreeIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) {
|
||||
throw GradleException(
|
||||
"worktree.properties applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " +
|
||||
worktreeIdSuffix,
|
||||
)
|
||||
}
|
||||
|
||||
// Release signing modes:
|
||||
// - "upload-keystore" (default): sign with the CI-vended upload keystore;
|
||||
// release builds fail loudly when any credential is missing.
|
||||
// - "external": deliberately produce an UNSIGNED release bundle for a
|
||||
// pipeline that signs through the central APK Signer service (Cashkite,
|
||||
// BOT-1234). No keystore material may be present in this mode.
|
||||
val releaseSigningMode =
|
||||
providers.environmentVariable("BUZZ_ANDROID_RELEASE_SIGNING").orNull ?: "upload-keystore"
|
||||
val externalReleaseSigning = releaseSigningMode == "external"
|
||||
if (releaseSigningMode !in setOf("upload-keystore", "external")) {
|
||||
throw GradleException(
|
||||
"BUZZ_ANDROID_RELEASE_SIGNING must be \"upload-keystore\" or \"external\", got: " +
|
||||
releaseSigningMode,
|
||||
)
|
||||
}
|
||||
if (externalReleaseSigning && uploadSigningValues.values.any { !it.isNullOrBlank() }) {
|
||||
throw GradleException(
|
||||
"BUZZ_ANDROID_RELEASE_SIGNING=external must not be combined with " +
|
||||
"BUZZ_ANDROID_UPLOAD_* credentials; unset one of them.",
|
||||
)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "xyz.block.buzz.mobile"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "xyz.block.buzz.mobile"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
resValue("string", "app_name", "Buzz")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasUploadSigning) {
|
||||
create("upload") {
|
||||
storeFile = file(requireNotNull(uploadKeystorePath))
|
||||
storePassword = uploadKeystorePassword
|
||||
keyAlias = uploadKeyAlias
|
||||
keyPassword = uploadKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// Only debug builds take the worktree identity; release/profile
|
||||
// keep the production applicationId and label.
|
||||
if (worktreeIdSuffix != null) {
|
||||
applicationIdSuffix = worktreeIdSuffix
|
||||
}
|
||||
if (worktreeLabel != null) {
|
||||
resValue("string", "app_name", "Buzz ($worktreeLabel)")
|
||||
}
|
||||
}
|
||||
release {
|
||||
if (hasUploadSigning) {
|
||||
signingConfig = signingConfigs.getByName("upload")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(kotlin("test"))
|
||||
|
||||
androidTestImplementation(kotlin("test"))
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
}
|
||||
|
||||
gradle.taskGraph.whenReady {
|
||||
val buildsRelease = allTasks.any { task ->
|
||||
task.project == project && task.name in setOf("assembleRelease", "bundleRelease")
|
||||
}
|
||||
if (buildsRelease && externalReleaseSigning) {
|
||||
// External signing: the unsigned bundle goes to the central APK
|
||||
// Signer. All keystore checks are intentionally skipped; the
|
||||
// guard above already rejected any BUZZ_ANDROID_UPLOAD_* values.
|
||||
return@whenReady
|
||||
}
|
||||
if (buildsRelease && !hasUploadSigning) {
|
||||
throw GradleException(
|
||||
"Release builds require Android upload signing credentials. Missing: " +
|
||||
missingUploadSigningValues.sorted().joinToString(", ") +
|
||||
". For central APK Signer pipelines set BUZZ_ANDROID_RELEASE_SIGNING=external.",
|
||||
)
|
||||
}
|
||||
if (buildsRelease) {
|
||||
val configuredKeystore = File(requireNotNull(uploadKeystorePath))
|
||||
if (!configuredKeystore.isAbsolute) {
|
||||
throw GradleException(
|
||||
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH must be absolute: $configuredKeystore",
|
||||
)
|
||||
}
|
||||
val keystore = file(configuredKeystore)
|
||||
val repositoryRoot = rootProject.projectDir.parentFile.parentFile.canonicalFile
|
||||
if (keystore.canonicalFile.toPath().startsWith(repositoryRoot.toPath())) {
|
||||
throw GradleException(
|
||||
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH must be outside the repository: $keystore",
|
||||
)
|
||||
}
|
||||
if (!keystore.isFile || !keystore.canRead()) {
|
||||
throw GradleException(
|
||||
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH is not a readable file: $keystore",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AndroidImageProcessorTest {
|
||||
companion object {
|
||||
private val ALLOWED_PNG_CHUNKS = setOf(
|
||||
"IHDR", "PLTE", "IDAT", "IEND", "cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT",
|
||||
"acTL", "fcTL", "fdAT",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportedApiCanProcessImagesWithoutNewerPlatformApis() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return
|
||||
|
||||
val sourceBytes = fixtureBytes("bitmap-srgb.png")
|
||||
val processed = assertNotNull(AndroidImageProcessor.decodeSrgbBitmap(sourceBytes))
|
||||
assertEquals(Bitmap.Config.ARGB_8888, processed.config)
|
||||
|
||||
val scrubbed = assertNotNull(
|
||||
AndroidImageProcessor.encodeAndScrub(processed, Bitmap.CompressFormat.PNG),
|
||||
)
|
||||
val chunkTypes = pngChunkTypes(scrubbed)
|
||||
assertEquals("IHDR", chunkTypes.first())
|
||||
assertEquals("IEND", chunkTypes.last())
|
||||
assertTrue(chunkTypes.all { it in ALLOWED_PNG_CHUNKS })
|
||||
assertPngPixelsPreserved(sourceBytes, scrubbed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transparentPngPixelsSurviveSrgbProcessing() {
|
||||
val sourceBytes = fixtureBytes("bitmap-srgb.png")
|
||||
val source = assertNotNull(BitmapFactory.decodeByteArray(sourceBytes, 0, sourceBytes.size))
|
||||
val processed = assertNotNull(AndroidImageProcessor.decodeSrgbBitmap(sourceBytes))
|
||||
assertTrue(processed.hasAlpha())
|
||||
|
||||
val scrubbed = assertNotNull(
|
||||
AndroidImageProcessor.encodeAndScrub(processed, Bitmap.CompressFormat.PNG),
|
||||
)
|
||||
assertPngPixelsPreserved(sourceBytes, scrubbed)
|
||||
assertEquals(128, Color.alpha(source.getPixel(0, 1)))
|
||||
assertEquals(64, Color.alpha(source.getPixel(1, 1)))
|
||||
assertEquals(0, Color.alpha(source.getPixel(2, 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayP3InputIsConvertedToSrgbBeforeEncodedMetadataIsScrubbed() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val sourceBytes = fixtureBytes("bitmap-display-p3.png")
|
||||
val source = assertNotNull(BitmapFactory.decodeByteArray(sourceBytes, 0, sourceBytes.size))
|
||||
assertEquals(android.graphics.ColorSpace.get(android.graphics.ColorSpace.Named.DISPLAY_P3), source.colorSpace)
|
||||
val expectedSrgbPixel = source.getColor(0, 0).convert(
|
||||
android.graphics.ColorSpace.get(android.graphics.ColorSpace.Named.SRGB),
|
||||
)
|
||||
|
||||
val srgbBitmap = assertNotNull(AndroidImageProcessor.decodeSrgbBitmap(sourceBytes))
|
||||
assertEquals(android.graphics.ColorSpace.get(android.graphics.ColorSpace.Named.SRGB), srgbBitmap.colorSpace)
|
||||
assertEquals(Bitmap.Config.ARGB_8888, srgbBitmap.config)
|
||||
assertColorClose(expectedSrgbPixel, srgbBitmap.getColor(0, 0), tolerance = 1.0f / 255.0f)
|
||||
|
||||
for ((format, tolerance) in listOf(
|
||||
Bitmap.CompressFormat.PNG to (1.0f / 255.0f),
|
||||
Bitmap.CompressFormat.JPEG to (2.0f / 255.0f),
|
||||
)) {
|
||||
val scrubbed = assertNotNull(AndroidImageProcessor.encodeAndScrub(srgbBitmap, format))
|
||||
when (format) {
|
||||
Bitmap.CompressFormat.PNG -> {
|
||||
assertEquals(listOf("IHDR", "sRGB", "sBIT", "IDAT", "IEND"), pngChunkTypes(scrubbed))
|
||||
}
|
||||
Bitmap.CompressFormat.JPEG -> {
|
||||
assertEquals(listOf(0xE0), jpegMetadataMarkers(scrubbed))
|
||||
}
|
||||
else -> error("Unexpected format: $format")
|
||||
}
|
||||
|
||||
val decodedOutput = assertNotNull(BitmapFactory.decodeByteArray(scrubbed, 0, scrubbed.size))
|
||||
assertEquals(
|
||||
android.graphics.ColorSpace.get(android.graphics.ColorSpace.Named.SRGB),
|
||||
decodedOutput.colorSpace,
|
||||
)
|
||||
assertColorClose(expectedSrgbPixel, decodedOutput.getColor(0, 0), tolerance)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertPngPixelsPreserved(expectedBytes: ByteArray, actualBytes: ByteArray) {
|
||||
val expected = assertNotNull(BitmapFactory.decodeByteArray(expectedBytes, 0, expectedBytes.size))
|
||||
val actual = assertNotNull(BitmapFactory.decodeByteArray(actualBytes, 0, actualBytes.size))
|
||||
assertEquals(expected.width, actual.width)
|
||||
assertEquals(expected.height, actual.height)
|
||||
for (y in 0 until expected.height) {
|
||||
for (x in 0 until expected.width) {
|
||||
assertEquals(
|
||||
expected.getPixel(x, y),
|
||||
actual.getPixel(x, y),
|
||||
"pixel ($x, $y)",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixtureBytes(name: String): ByteArray {
|
||||
return requireNotNull(javaClass.getResourceAsStream("/fixtures/android/$name")) {
|
||||
"Missing fixture: $name"
|
||||
}.use { it.readBytes() }
|
||||
}
|
||||
|
||||
private fun assertColorClose(expected: Color, actual: Color, tolerance: Float) {
|
||||
val expectedComponents = expected.components
|
||||
val actualComponents = actual.convert(expected.colorSpace).components
|
||||
for (index in expectedComponents.indices) {
|
||||
assertTrue(
|
||||
abs(expectedComponents[index] - actualComponents[index]) <= tolerance,
|
||||
"component $index: expected ${expectedComponents[index]}, got ${actualComponents[index]}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pngChunkTypes(bytes: ByteArray): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
var offset = 8
|
||||
while (offset < bytes.size) {
|
||||
require(bytes.size - offset >= 12)
|
||||
val payloadLength = readUnsignedInt(bytes, offset)
|
||||
val type = bytes.decodeToString(offset + 4, offset + 8)
|
||||
result += type
|
||||
offset += payloadLength + 12
|
||||
if (type == "IEND") {
|
||||
assertEquals(bytes.size, offset)
|
||||
return result
|
||||
}
|
||||
}
|
||||
error("PNG is missing IEND")
|
||||
}
|
||||
|
||||
private fun jpegMetadataMarkers(bytes: ByteArray): List<Int> {
|
||||
val result = mutableListOf<Int>()
|
||||
var offset = 2
|
||||
var inScan = false
|
||||
while (offset < bytes.size) {
|
||||
if (inScan && bytes[offset] != 0xFF.toByte()) {
|
||||
offset += 1
|
||||
continue
|
||||
}
|
||||
require(bytes[offset] == 0xFF.toByte())
|
||||
while (offset < bytes.size && bytes[offset] == 0xFF.toByte()) {
|
||||
offset += 1
|
||||
}
|
||||
require(offset < bytes.size)
|
||||
val marker = bytes[offset].toInt() and 0xFF
|
||||
offset += 1
|
||||
if (inScan && marker == 0x00) continue
|
||||
if (marker in 0xD0..0xD7 || marker == 0x01) continue
|
||||
if (marker == 0xD9) {
|
||||
assertEquals(bytes.size, offset)
|
||||
return result
|
||||
}
|
||||
val segmentLength = readUnsignedShort(bytes, offset)
|
||||
if (marker in 0xE0..0xEF || marker == 0xFE) result += marker
|
||||
offset += segmentLength
|
||||
inScan = marker == 0xDA
|
||||
}
|
||||
error("JPEG is missing EOI")
|
||||
}
|
||||
|
||||
private fun readUnsignedShort(bytes: ByteArray, offset: Int): Int {
|
||||
return ((bytes[offset].toInt() and 0xFF) shl 8) or (bytes[offset + 1].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
private fun readUnsignedInt(bytes: ByteArray, offset: Int): Int {
|
||||
return ((bytes[offset].toInt() and 0xFF) shl 24) or
|
||||
((bytes[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((bytes[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(bytes[offset + 3].toInt() and 0xFF)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 112 B |
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,71 @@
|
||||
<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.CAMERA" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32"
|
||||
tools:replace="android:maxSdkVersion" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Handle buzz:// deep links (e.g. buzz://message?channel=…&id=…). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="buzz"/>
|
||||
</intent-filter>
|
||||
<!-- Disable Flutter's built-in deeplink handler; app_links owns
|
||||
incoming URLs so they can be routed with app state in Dart. -->
|
||||
<meta-data
|
||||
android:name="flutter_deeplinking_enabled"
|
||||
android:value="false" />
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,160 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
internal object AndroidMediaSanitizer {
|
||||
private val pngSignature = byteArrayOf(
|
||||
0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
)
|
||||
private val allowedPngAncillaryChunks = setOf(
|
||||
"cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT",
|
||||
)
|
||||
|
||||
fun scrubPng(bytes: ByteArray): ByteArray {
|
||||
require(bytes.size >= pngSignature.size && bytes.copyOfRange(0, pngSignature.size).contentEquals(pngSignature)) {
|
||||
"Invalid PNG signature"
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream(bytes.size)
|
||||
output.write(pngSignature)
|
||||
var offset = pngSignature.size
|
||||
while (offset < bytes.size) {
|
||||
require(bytes.size - offset >= PNG_CHUNK_OVERHEAD) { "Truncated PNG chunk" }
|
||||
val payloadLength = readUnsignedIntBigEndian(bytes, offset)
|
||||
require(payloadLength <= bytes.size.toLong() - offset - PNG_CHUNK_OVERHEAD) {
|
||||
"Invalid PNG chunk length"
|
||||
}
|
||||
val chunkLength = payloadLength.toInt() + PNG_CHUNK_OVERHEAD
|
||||
val typeStart = offset + 4
|
||||
val type = String(bytes, typeStart, 4, StandardCharsets.US_ASCII)
|
||||
val isAncillary = bytes[typeStart].toInt() and 0x20 != 0
|
||||
if (!isAncillary || type in allowedPngAncillaryChunks) {
|
||||
output.write(bytes, offset, chunkLength)
|
||||
}
|
||||
offset += chunkLength
|
||||
if (type == "IEND") {
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
throw IllegalArgumentException("PNG is missing IEND")
|
||||
}
|
||||
|
||||
fun scrubJpeg(bytes: ByteArray): ByteArray {
|
||||
require(
|
||||
bytes.size >= 2 &&
|
||||
bytes[0] == 0xFF.toByte() &&
|
||||
(bytes[1].toInt() and 0xFF) == JPEG_SOI,
|
||||
) {
|
||||
"Invalid JPEG signature"
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream(bytes.size)
|
||||
output.write(byteArrayOf(0xFF.toByte(), JPEG_SOI.toByte()))
|
||||
var offset = 2
|
||||
var inScan = false
|
||||
while (offset < bytes.size) {
|
||||
if (inScan && bytes[offset] != 0xFF.toByte()) {
|
||||
val nextMarker = bytes.indexOf(0xFF.toByte(), offset).let { if (it == -1) bytes.size else it }
|
||||
output.write(bytes, offset, nextMarker - offset)
|
||||
offset = nextMarker
|
||||
continue
|
||||
}
|
||||
require(bytes[offset] == 0xFF.toByte()) { "Invalid JPEG marker" }
|
||||
|
||||
val markerStart = offset
|
||||
while (offset < bytes.size && bytes[offset] == 0xFF.toByte()) {
|
||||
offset += 1
|
||||
}
|
||||
require(offset < bytes.size) { "Truncated JPEG marker" }
|
||||
|
||||
val marker = bytes[offset].toInt() and 0xFF
|
||||
offset += 1
|
||||
if (inScan && marker == 0x00) {
|
||||
output.write(bytes, markerStart, offset - markerStart)
|
||||
continue
|
||||
}
|
||||
if (marker in JPEG_RESTART_MARKERS || marker == JPEG_TEMP) {
|
||||
output.write(bytes, markerStart, offset - markerStart)
|
||||
continue
|
||||
}
|
||||
if (marker == JPEG_EOI) {
|
||||
output.write(bytes, markerStart, offset - markerStart)
|
||||
return output.toByteArray()
|
||||
}
|
||||
require(marker != JPEG_SOI && bytes.size - offset >= 2) { "Invalid JPEG segment" }
|
||||
|
||||
val segmentLength = readUnsignedShortBigEndian(bytes, offset)
|
||||
require(segmentLength >= 2 && segmentLength <= bytes.size - offset) { "Invalid JPEG segment length" }
|
||||
val segmentEnd = offset + segmentLength
|
||||
if (shouldKeepJpegSegment(marker, bytes, offset + 2, segmentEnd)) {
|
||||
output.write(bytes, markerStart, segmentEnd - markerStart)
|
||||
}
|
||||
offset = segmentEnd
|
||||
inScan = marker == JPEG_SOS
|
||||
}
|
||||
|
||||
throw IllegalArgumentException("JPEG is missing EOI")
|
||||
}
|
||||
|
||||
private fun shouldKeepJpegSegment(
|
||||
marker: Int,
|
||||
bytes: ByteArray,
|
||||
payloadStart: Int,
|
||||
payloadEnd: Int,
|
||||
): Boolean {
|
||||
val payloadLength = payloadEnd - payloadStart
|
||||
return when (marker) {
|
||||
JPEG_APP0 -> {
|
||||
if (payloadLength < 14 || !bytes.matchesAscii(payloadStart, "JFIF\u0000")) {
|
||||
false
|
||||
} else {
|
||||
val thumbnailWidth = bytes[payloadStart + 12].toInt() and 0xFF
|
||||
val thumbnailHeight = bytes[payloadStart + 13].toInt() and 0xFF
|
||||
payloadLength == 14 + 3 * thumbnailWidth * thumbnailHeight
|
||||
}
|
||||
}
|
||||
JPEG_APP14 -> payloadLength == 12 && bytes.matchesAscii(payloadStart, "Adobe")
|
||||
in JPEG_FORBIDDEN_APP_MARKERS, JPEG_APP15, JPEG_COMMENT -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUnsignedShortBigEndian(bytes: ByteArray, offset: Int): Int {
|
||||
require(bytes.size - offset >= 2) { "Truncated two-byte integer" }
|
||||
return (bytes[offset].toInt() and 0xFF shl 8) or (bytes[offset + 1].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
private fun readUnsignedIntBigEndian(bytes: ByteArray, offset: Int): Long {
|
||||
require(bytes.size - offset >= 4) { "Truncated four-byte integer" }
|
||||
return (bytes[offset].toLong() and 0xFF shl 24) or
|
||||
(bytes[offset + 1].toLong() and 0xFF shl 16) or
|
||||
(bytes[offset + 2].toLong() and 0xFF shl 8) or
|
||||
(bytes[offset + 3].toLong() and 0xFF)
|
||||
}
|
||||
|
||||
private fun ByteArray.matchesAscii(offset: Int, value: String): Boolean {
|
||||
val expected = value.toByteArray(StandardCharsets.US_ASCII)
|
||||
return size - offset >= expected.size && expected.indices.all { this[offset + it] == expected[it] }
|
||||
}
|
||||
|
||||
private fun ByteArray.indexOf(value: Byte, startIndex: Int): Int {
|
||||
for (index in startIndex until size) {
|
||||
if (this[index] == value) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private const val PNG_CHUNK_OVERHEAD = 12
|
||||
private const val JPEG_SOI = 0xD8
|
||||
private const val JPEG_EOI = 0xD9
|
||||
private const val JPEG_SOS = 0xDA
|
||||
private const val JPEG_TEMP = 0x01
|
||||
private const val JPEG_APP0 = 0xE0
|
||||
private const val JPEG_APP14 = 0xEE
|
||||
private const val JPEG_APP15 = 0xEF
|
||||
private const val JPEG_COMMENT = 0xFE
|
||||
private val JPEG_RESTART_MARKERS = 0xD0..0xD7
|
||||
private val JPEG_FORBIDDEN_APP_MARKERS = 0xE1..0xED
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorSpace
|
||||
import android.graphics.ImageDecoder
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMuxer
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.UUID
|
||||
|
||||
internal object AndroidImageProcessor {
|
||||
fun decodeSrgbBitmap(bytes: ByteArray): Bitmap? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
decodeSrgbBitmapWithColorManagement(bytes)
|
||||
} else {
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun decodeSrgbBitmapWithColorManagement(bytes: ByteArray): Bitmap? {
|
||||
val decoded = decodeColorManagedBitmap(bytes) ?: return null
|
||||
val srgb = ColorSpace.get(ColorSpace.Named.SRGB)
|
||||
if (decoded.config == Bitmap.Config.ARGB_8888 && decoded.colorSpace == srgb) return decoded
|
||||
|
||||
val srgbBitmap = Bitmap.createBitmap(
|
||||
decoded.width,
|
||||
decoded.height,
|
||||
Bitmap.Config.ARGB_8888,
|
||||
decoded.hasAlpha(),
|
||||
srgb,
|
||||
)
|
||||
Canvas(srgbBitmap).drawBitmap(decoded, 0f, 0f, null)
|
||||
return srgbBitmap
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun decodeColorManagedBitmap(bytes: ByteArray): Bitmap? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
runCatching {
|
||||
val source = ImageDecoder.createSource(ByteBuffer.wrap(bytes))
|
||||
ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||
}
|
||||
}.getOrNull()?.let { return it }
|
||||
}
|
||||
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
|
||||
}
|
||||
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
}
|
||||
|
||||
fun encodeAndScrub(
|
||||
bitmap: Bitmap,
|
||||
format: Bitmap.CompressFormat,
|
||||
): ByteArray? {
|
||||
val output = ByteArrayOutputStream()
|
||||
if (!bitmap.compress(format, 100, output)) return null
|
||||
|
||||
return when (format) {
|
||||
Bitmap.CompressFormat.PNG -> AndroidMediaSanitizer.scrubPng(output.toByteArray())
|
||||
Bitmap.CompressFormat.JPEG -> AndroidMediaSanitizer.scrubJpeg(output.toByteArray())
|
||||
else -> error("Unsupported upload image format: $format")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var mediaUploadChannel: MethodChannel? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
mediaUploadChannel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
MEDIA_UPLOAD_CHANNEL,
|
||||
).also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
SANITIZE_IMAGE_FOR_UPLOAD_METHOD -> {
|
||||
handleSanitizeImageForUpload(call.arguments, result)
|
||||
}
|
||||
TRANSCODE_IMAGE_TO_JPEG_METHOD -> {
|
||||
handleTranscodeImageToJpeg(call.arguments, result)
|
||||
}
|
||||
TRANSCODE_VIDEO_TO_MP4_METHOD -> {
|
||||
handleTranscodeVideoToMp4(call.arguments, result)
|
||||
}
|
||||
GENERATE_VIDEO_POSTER_METHOD -> {
|
||||
handleGenerateVideoPoster(call.arguments, result)
|
||||
}
|
||||
REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD -> {
|
||||
result.success(Build.VERSION.SDK_INT <= Build.VERSION_CODES.P)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSanitizeImageForUpload(
|
||||
arguments: Any?,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val payload = arguments as? Map<*, *> ?: run {
|
||||
invalidArguments(result, "Expected image bytes and mime type.")
|
||||
return
|
||||
}
|
||||
val bytes = payload["bytes"] as? ByteArray ?: run {
|
||||
invalidArguments(result, "Expected raw image bytes.")
|
||||
return
|
||||
}
|
||||
val mimeType = payload["mimeType"] as? String ?: run {
|
||||
invalidArguments(result, "Expected image mime type.")
|
||||
return
|
||||
}
|
||||
|
||||
val format = sanitizeCompressFormatFor(mimeType)
|
||||
if (format == null) {
|
||||
result.error(
|
||||
"sanitize_failed",
|
||||
"Unable to sanitize picked image.",
|
||||
mimeType,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
transformImageBytes(
|
||||
bytes = bytes,
|
||||
result = result,
|
||||
format = format,
|
||||
errorCode = "sanitize_failed",
|
||||
encodeFailureMessage = "Unable to sanitize picked image.",
|
||||
errorDetails = mimeType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleTranscodeImageToJpeg(
|
||||
arguments: Any?,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val bytes = arguments as? ByteArray ?: run {
|
||||
invalidArguments(result, "Expected raw image bytes.")
|
||||
return
|
||||
}
|
||||
|
||||
transformImageBytes(
|
||||
bytes = bytes,
|
||||
result = result,
|
||||
format = Bitmap.CompressFormat.JPEG,
|
||||
errorCode = "transcode_failed",
|
||||
encodeFailureMessage = "Unable to convert picked image to JPEG.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitizeCompressFormatFor(
|
||||
mimeType: String,
|
||||
): Bitmap.CompressFormat? {
|
||||
return when (mimeType) {
|
||||
"image/jpeg" -> Bitmap.CompressFormat.JPEG
|
||||
"image/png", "image/webp" -> Bitmap.CompressFormat.PNG
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun transformImageBytes(
|
||||
bytes: ByteArray,
|
||||
result: MethodChannel.Result,
|
||||
format: Bitmap.CompressFormat,
|
||||
errorCode: String,
|
||||
encodeFailureMessage: String,
|
||||
errorDetails: Any? = null,
|
||||
) {
|
||||
val bitmap = AndroidImageProcessor.decodeSrgbBitmap(bytes) ?: run {
|
||||
result.error(
|
||||
errorCode,
|
||||
"Unable to decode picked image.",
|
||||
null,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val transformedBytes = try {
|
||||
AndroidImageProcessor.encodeAndScrub(bitmap, format)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
} ?: run {
|
||||
result.error(
|
||||
errorCode,
|
||||
encodeFailureMessage,
|
||||
errorDetails,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
result.success(transformedBytes)
|
||||
}
|
||||
|
||||
private fun handleTranscodeVideoToMp4(
|
||||
arguments: Any?,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val sourcePath = arguments as? String ?: run {
|
||||
invalidArguments(result, "Expected source file path as String.")
|
||||
return
|
||||
}
|
||||
|
||||
Thread {
|
||||
val outputFile = File(cacheDir, "${UUID.randomUUID()}.mp4")
|
||||
var muxer: MediaMuxer? = null
|
||||
val extractor = MediaExtractor()
|
||||
try {
|
||||
extractor.setDataSource(sourcePath)
|
||||
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
val trackIndices = mutableMapOf<Int, Int>()
|
||||
var copiedVideo = false
|
||||
var copiedAudio = false
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(android.media.MediaFormat.KEY_MIME) ?: continue
|
||||
val isVideo = mime.startsWith("video/")
|
||||
val isAudio = mime.startsWith("audio/")
|
||||
if ((!isVideo && !isAudio) || (isVideo && copiedVideo) || (isAudio && copiedAudio)) {
|
||||
continue
|
||||
}
|
||||
val newIndex = muxer.addTrack(format)
|
||||
trackIndices[i] = newIndex
|
||||
extractor.selectTrack(i)
|
||||
copiedVideo = copiedVideo || isVideo
|
||||
copiedAudio = copiedAudio || isAudio
|
||||
}
|
||||
|
||||
muxer.start()
|
||||
val buffer = ByteBuffer.allocate(1024 * 1024) // 1MB buffer
|
||||
val bufferInfo = android.media.MediaCodec.BufferInfo()
|
||||
|
||||
while (true) {
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) break
|
||||
val muxerTrack = trackIndices[extractor.sampleTrackIndex]
|
||||
if (muxerTrack == null) {
|
||||
extractor.advance()
|
||||
continue
|
||||
}
|
||||
bufferInfo.offset = 0
|
||||
bufferInfo.size = sampleSize
|
||||
bufferInfo.presentationTimeUs = extractor.sampleTime
|
||||
bufferInfo.flags = extractor.sampleFlags
|
||||
muxer.writeSampleData(muxerTrack, buffer, bufferInfo)
|
||||
extractor.advance()
|
||||
}
|
||||
|
||||
muxer.stop()
|
||||
result.success(outputFile.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
outputFile.delete()
|
||||
result.error(
|
||||
"transcode_failed",
|
||||
e.message ?: "Video transcoding failed.",
|
||||
null,
|
||||
)
|
||||
} finally {
|
||||
try { muxer?.release() } catch (_: Exception) {}
|
||||
extractor.release()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun handleGenerateVideoPoster(
|
||||
arguments: Any?,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val sourcePath = arguments as? String ?: run {
|
||||
invalidArguments(result, "Expected source file path as String.")
|
||||
return
|
||||
}
|
||||
|
||||
Thread {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(sourcePath)
|
||||
val source = retriever.getFrameAtTime(
|
||||
0,
|
||||
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||
) ?: retriever.getFrameAtTime(
|
||||
100_000,
|
||||
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||
) ?: throw IllegalArgumentException("Unable to decode a video frame.")
|
||||
val scale = minOf(1f, 720f / maxOf(source.width, source.height))
|
||||
val frame = if (scale < 1f) {
|
||||
Bitmap.createScaledBitmap(
|
||||
source,
|
||||
(source.width * scale).toInt(),
|
||||
(source.height * scale).toInt(),
|
||||
true,
|
||||
).also { source.recycle() }
|
||||
} else {
|
||||
source
|
||||
}
|
||||
val bytes = AndroidImageProcessor.encodeAndScrub(
|
||||
frame,
|
||||
Bitmap.CompressFormat.JPEG,
|
||||
) ?: throw IllegalArgumentException("Unable to encode a video preview.")
|
||||
frame.recycle()
|
||||
result.success(bytes)
|
||||
} catch (e: Exception) {
|
||||
result.error(
|
||||
"poster_failed",
|
||||
"Unable to create a video preview.",
|
||||
e.message,
|
||||
)
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun invalidArguments(
|
||||
result: MethodChannel.Result,
|
||||
message: String,
|
||||
) {
|
||||
result.error("invalid_arguments", message, null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MEDIA_UPLOAD_CHANNEL = "buzz/media_upload"
|
||||
private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload"
|
||||
private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg"
|
||||
private const val TRANSCODE_VIDEO_TO_MP4_METHOD = "transcodeVideoToMp4"
|
||||
private const val GENERATE_VIDEO_POSTER_METHOD = "generateVideoPoster"
|
||||
private const val REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD =
|
||||
"requiresLegacyMediaStoragePermission"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/black" />
|
||||
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@mipmap/ic_launcher_foreground"
|
||||
android:inset="10%" />
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/black" />
|
||||
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground_inset"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,128 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class AndroidMediaSanitizerTest {
|
||||
@Test
|
||||
fun `scrubPng keeps canonical sRGB output unchanged`() {
|
||||
val fixture = fixtureBytes("bitmap-srgb.png")
|
||||
|
||||
val sanitized = AndroidMediaSanitizer.scrubPng(fixture)
|
||||
|
||||
assertContentEquals(fixture, sanitized)
|
||||
assertEquals(listOf("IHDR", "sRGB", "sBIT", "IDAT", "IEND"), pngChunkTypes(sanitized))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrubPng removes Display P3 profile and trailing data`() {
|
||||
val fixture = fixtureBytes("bitmap-display-p3.png")
|
||||
val withTrailingData = fixture + "hidden location".encodeToByteArray()
|
||||
|
||||
val sanitized = AndroidMediaSanitizer.scrubPng(withTrailingData)
|
||||
|
||||
assertEquals(listOf("IHDR", "sBIT", "IDAT", "IEND"), pngChunkTypes(sanitized))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrubJpeg removes Android ICC profile and trailing data`() {
|
||||
for (fixtureName in listOf("bitmap-srgb.jpg", "bitmap-display-p3.jpg")) {
|
||||
val fixture = fixtureBytes(fixtureName)
|
||||
val withTrailingData = fixture + "hidden location".encodeToByteArray()
|
||||
|
||||
val sanitized = AndroidMediaSanitizer.scrubJpeg(withTrailingData)
|
||||
|
||||
assertEquals(listOf(0xE0), jpegMetadataMarkers(sanitized), fixtureName)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrubbers fail closed for malformed containers`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
AndroidMediaSanitizer.scrubPng(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47))
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
AndroidMediaSanitizer.scrubJpeg(byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte()))
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
AndroidMediaSanitizer.scrubJpeg(
|
||||
byteArrayOf(
|
||||
0xFF.toByte(),
|
||||
0xD8.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xD8.toByte(),
|
||||
0x00,
|
||||
0x02,
|
||||
0xFF.toByte(),
|
||||
0xD9.toByte(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixtureBytes(name: String): ByteArray {
|
||||
return requireNotNull(javaClass.getResourceAsStream("/fixtures/android/$name")) {
|
||||
"Missing fixture: $name"
|
||||
}.use { it.readBytes() }
|
||||
}
|
||||
|
||||
private fun pngChunkTypes(bytes: ByteArray): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
var offset = 8
|
||||
while (offset < bytes.size) {
|
||||
require(bytes.size - offset >= 12)
|
||||
val payloadLength = readUnsignedInt(bytes, offset)
|
||||
val type = bytes.decodeToString(offset + 4, offset + 8)
|
||||
result += type
|
||||
offset += payloadLength + 12
|
||||
if (type == "IEND") {
|
||||
assertEquals(bytes.size, offset)
|
||||
return result
|
||||
}
|
||||
}
|
||||
error("PNG is missing IEND")
|
||||
}
|
||||
|
||||
private fun jpegMetadataMarkers(bytes: ByteArray): List<Int> {
|
||||
val result = mutableListOf<Int>()
|
||||
var offset = 2
|
||||
var inScan = false
|
||||
while (offset < bytes.size) {
|
||||
if (inScan && bytes[offset] != 0xFF.toByte()) {
|
||||
offset += 1
|
||||
continue
|
||||
}
|
||||
require(bytes[offset] == 0xFF.toByte())
|
||||
while (offset < bytes.size && bytes[offset] == 0xFF.toByte()) {
|
||||
offset += 1
|
||||
}
|
||||
require(offset < bytes.size)
|
||||
val marker = bytes[offset].toInt() and 0xFF
|
||||
offset += 1
|
||||
if (inScan && marker == 0x00) continue
|
||||
if (marker in 0xD0..0xD7 || marker == 0x01) continue
|
||||
if (marker == 0xD9) {
|
||||
assertEquals(bytes.size, offset)
|
||||
return result
|
||||
}
|
||||
val segmentLength = readUnsignedShort(bytes, offset)
|
||||
if (marker in 0xE0..0xEF || marker == 0xFE) result += marker
|
||||
offset += segmentLength
|
||||
inScan = marker == 0xDA
|
||||
}
|
||||
error("JPEG is missing EOI")
|
||||
}
|
||||
|
||||
private fun readUnsignedShort(bytes: ByteArray, offset: Int): Int {
|
||||
return ((bytes[offset].toInt() and 0xFF) shl 8) or (bytes[offset + 1].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
private fun readUnsignedInt(bytes: ByteArray, offset: Int): Int {
|
||||
return ((bytes[offset].toInt() and 0xFF) shl 24) or
|
||||
((bytes[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((bytes[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(bytes[offset + 3].toInt() and 0xFF)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 827 B |
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 857 B |
|
After Width: | Height: | Size: 112 B |