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>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+15
View File
@@ -0,0 +1,15 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
/worktree.properties
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+176
View File
@@ -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)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

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)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
}
include(":app")