diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 465734e..e1ffa9d 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -658,6 +658,8 @@ public final class com.mapconductor.core.groundimage.GroundImageState implements public final void setBounds(com.mapconductor.core.features.GeoRectBounds); public final android.graphics.drawable.Drawable getImage(); public final void setImage(android.graphics.drawable.Drawable); + public final java.lang.String getImageUrl(); + public final void setImageUrl(java.lang.String); public final float getOpacity(); public final void setOpacity(float); public final int getTileSize(); @@ -668,6 +670,8 @@ public final class com.mapconductor.core.groundimage.GroundImageState implements public final void setExtra(java.io.Serializable); public final kotlin.jvm.functions.Function1 getOnClick(); public final void setOnClick(kotlin.jvm.functions.Function1); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public final com.mapconductor.core.groundimage.GroundImageFingerPrint fingerPrint(); public final com.mapconductor.core.groundimage.GroundImageState copy(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); public boolean equals(java.lang.Object); @@ -1582,6 +1586,8 @@ public final class com.mapconductor.core.marker.ImageIcon extends com.mapconduct public final long getLabelStrokeColor-0d7_KjU(); public final java.lang.String getLabelPosition(); public final java.lang.String getLabelWritingMode(); + public com.mapconductor.core.marker.ImageIcon(java.lang.String, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final java.lang.String getImageUrl(); public long getAnchor-F1C5BW0(); public long getInfoAnchor-F1C5BW0(); public boolean equals(java.lang.Object); @@ -1589,6 +1595,7 @@ public final class com.mapconductor.core.marker.ImageIcon extends com.mapconduct public com.mapconductor.core.marker.BitmapIcon toBitmapIcon(); public final com.mapconductor.core.marker.ImageIcon copy-MQQ_14o(android.graphics.drawable.Drawable, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String); public com.mapconductor.core.marker.ImageIcon(android.graphics.drawable.Drawable, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.ImageIcon(java.lang.String, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String, kotlin.jvm.internal.DefaultConstructorMarker); } final class com.mapconductor.core.marker.ImageIcon$Layout { public final int getCanvasSize(); diff --git a/docs/groundimage/GroundImage.kt.md b/docs/groundimage/GroundImage.kt.md index a2a4d86..f462019 100644 --- a/docs/groundimage/GroundImage.kt.md +++ b/docs/groundimage/GroundImage.kt.md @@ -20,6 +20,26 @@ class GroundImageState( ) : ComponentState ``` +The image can also be given as a URL: + +```kotlin +GroundImageState( + bounds: GeoRectBounds, + imageUrl: String, + opacity: Float = 1.0f, + tileSize: Int = GroundImageTileProvider.DEFAULT_TILE_SIZE, + clickable: Boolean = true, + id: String? = null, + extra: Serializable? = null, + onClick: OnGroundImageEventHandler? = null, +) +``` + +The SDK downloads the image (http, https, file or data URLs; http(s) needs the app's +`android.permission.INTERNET`). Until it arrives `image` is a transparent placeholder; once it +arrives `image` becomes the loaded image and the overlay is redrawn. If the image cannot be loaded +the overlay stays transparent and a warning is logged once. + #### Parameters - `bounds` @@ -29,6 +49,9 @@ class GroundImageState( - `image` - Type: `Drawable` - Description: The `Drawable` resource to be displayed as the overlay. +- `imageUrl` + - Type: `String` + - Description: The URL of the image to load, instead of `image`. See above. - `opacity` - Type: `Float` - Description: The opacity of the image, ranging from `0.0f` (fully transparent) to `1.0f` @@ -62,7 +85,11 @@ after initialization to dynamically update the ground image on the map. - Description: The geographical area the image covers. - `image` - Type: `Drawable` - - Description: The image `Drawable` to display. + - Description: The image `Drawable` to display. Setting it clears `imageUrl`. +- `imageUrl` + - Type: `String?` + - Description: The URL the image is loaded from, or `null` if the image was given as a + `Drawable`. Setting it loads that image; a result for an earlier URL is ignored. - `opacity` - Type: `Float` - Description: The opacity of the image. diff --git a/docs/marker/ImageIcon.kt.md b/docs/marker/ImageIcon.kt.md index 96e1e7c..e87a198 100644 --- a/docs/marker/ImageIcon.kt.md +++ b/docs/marker/ImageIcon.kt.md @@ -23,12 +23,37 @@ class ImageIcon( ) : AndroidDrawableIcon ``` +An `ImageIcon` can also load its image from a URL: + +```kotlin +ImageIcon( + imageUrl: String, + iconSize: Dp = Settings.Default.iconSize, + scale: Float = 1.0f, + anchor: Offset = Offset(0.5f, 0.5f), + infoAnchor: Offset = Offset(0.5f, 0.5f), + debug: Boolean = false, + // label parameters are the same as for the Drawable constructor +) +``` + +The SDK downloads the image (http, https, file or data URLs; http(s) needs the app's +`android.permission.INTERNET`). Until the image arrives the icon is transparent at its final size +and anchor (a label is still drawn), and every marker using it is redrawn once it arrives. Each URL +is fetched once, however many icons or markers use it. If the image cannot be loaded the icon stays +transparent and a warning is logged once. `imageUrl` returns the URL, or `null` for an icon built +from a `Drawable`; `copy()` keeps the URL. + ## Parameters - `image` - Type: `Drawable` - Default: (none) - Description: The Android `Drawable` to be used as the icon. +- `imageUrl` + - Type: `String` + - Default: (none) + - Description: The URL of the image to load instead of `image`. See above. - `iconSize` - Type: `Dp` - Default: `Settings.Default.iconSize` @@ -90,7 +115,13 @@ val customIcon = ImageIcon( anchor = Offset(0.5f, 1.0f) // Anchor at the bottom-center ) -// 3. Use the icons when creating Markers for the map +// 3. Load the image from a URL. The marker shows once the image has arrived. +val remoteIcon = ImageIcon( + imageUrl = "https://example.com/marker.png", + anchor = Offset(0.5f, 1.0f) +) + +// 4. Use the icons when creating Markers for the map val marker1 = Marker( position = LatLng(34.0522, -118.2437), icon = defaultIcon, diff --git a/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt new file mode 100644 index 0000000..aa25189 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt @@ -0,0 +1,199 @@ +package com.mapconductor.core + +import androidx.compose.ui.unit.Dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.marker.ImageIcon +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.marker.iconRevision +import java.io.ByteArrayOutputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import kotlinx.coroutines.asCoroutineDispatcher + +/** + * URL で渡す画像(`ImageIcon(imageUrl)` / `GroundImageState(imageUrl)`)。 + * + * 取得は差し替えた [RemoteImageLoader.fetch] がラッチで止めるので、「読み込み中」と + * 「届いた後」を順に確かめられる。復号は端末の BitmapFactory を使う。 + */ +@RunWith(AndroidJUnit4::class) +class RemoteImageDeviceTest { + private val executor = Executors.newSingleThreadExecutor() + private val release = CountDownLatch(1) + private val fetches = AtomicInteger() + private var failing = false + + private val png: ByteArray by lazy { + val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) } + ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray() + } + + @Before + fun setUp() { + RemoteImageLoader.resetForTest() + RemoteImageLoader.dispatcher = executor.asCoroutineDispatcher() + RemoteImageLoader.fetch = { url -> + fetches.incrementAndGet() + release.await(5, TimeUnit.SECONDS) + if (failing) throw IllegalStateException("offline: $url") + png + } + } + + @After + fun tearDown() { + RemoteImageLoader.resetForTest() + executor.shutdownNow() + } + + /** 取得のスレッドで、それまでに積まれた仕事が終わるのを待つ。 */ + private fun drain() { + executor.submit {}.get(5, TimeUnit.SECONDS) + } + + private fun loadAll() { + release.countDown() + drain() + } + + @Test + fun oneFetchPerUrlAndEveryoneGetsTheSameImage() { + val results = mutableListOf() + RemoteImageLoader.load("https://example.test/a.png") { synchronized(results) { results.add(it) } } + RemoteImageLoader.load("https://example.test/a.png") { synchronized(results) { results.add(it) } } + loadAll() + + assertEquals(1, fetches.get()) + assertEquals(2, results.size) + assertSame(results[0], results[1]) + assertEquals(8, results[0]!!.width) + // 読み終えた後は、その場で返る。 + var immediate: Bitmap? = null + RemoteImageLoader.load("https://example.test/a.png") { immediate = it } + assertSame(results[0], immediate) + assertEquals(1, fetches.get()) + } + + @Test + fun aFailedUrlStaysFailed() { + failing = true + var first: Bitmap? = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + RemoteImageLoader.load("https://example.test/missing.png") { first = it } + loadAll() + assertNull(first) + + var second: Bitmap? = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + RemoteImageLoader.load("https://example.test/missing.png") { second = it } + assertNull(second) + assertEquals(1, fetches.get()) + } + + @Test + fun dataUrlsAreDecodedWithoutTheNetwork() { + RemoteImageLoader.resetForTest() + val url = "data:image/png;base64," + android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP) + val done = CountDownLatch(1) + var bitmap: Bitmap? = null + RemoteImageLoader.load(url) { + bitmap = it + done.countDown() + } + assertTrue(done.await(5, TimeUnit.SECONDS)) + assertEquals(8, bitmap!!.width) + } + + @Test + fun iconLooksTheSameSizeAndChangesOnceWhenTheImageArrives() { + val icon = ImageIcon(imageUrl = "https://example.test/icon.png", iconSize = Dp(16f)) + val same = ImageIcon(imageUrl = "https://example.test/icon.png", iconSize = Dp(16f)) + assertEquals("https://example.test/icon.png", icon.imageUrl) + assertEquals(icon, same) + val pendingHash = icon.hashCode() + val pending = icon.toBitmapIcon() + val center = pending.bitmap.width / 2 + assertEquals(0, Color.alpha(pending.bitmap.getPixel(center, center))) + + loadAll() + + assertNotEquals(pendingHash, icon.hashCode()) + assertEquals(icon, same) + val ready = icon.toBitmapIcon() + assertEquals(pending.size, ready.size) + assertEquals(pending.anchor, ready.anchor) + assertEquals(Color.RED, ready.bitmap.getPixel(center, center)) + // copy は URL を引き継ぐ。 + assertEquals("https://example.test/icon.png", icon.copy(scale = 2f).imageUrl) + assertNull(ImageIcon(image = ColorDrawable(Color.RED)).imageUrl) + } + + @Test + fun markerRedrawsWhenItsIconArrivesButNotAfterTheIconWasReplaced() { + val icon = ImageIcon(imageUrl = "https://example.test/marker.png") + val kept = MarkerState(position = GeoPoint.fromLatLong(35.0, 139.0), icon = icon) + val replaced = MarkerState(position = GeoPoint.fromLatLong(35.0, 139.0), icon = icon) + val keptWrites = AtomicInteger() + val replacedWrites = AtomicInteger() + kept.mutations.listen { keptWrites.incrementAndGet() } + replaced.icon = ImageIcon(image = ColorDrawable(Color.BLUE)) + replaced.mutations.listen { replacedWrites.incrementAndGet() } + val before = kept.fingerPrint() + + loadAll() + + assertEquals(1, keptWrites.get()) + assertEquals(0, replacedWrites.get()) + assertEquals(1, kept.iconRevision) + assertEquals(0, replaced.iconRevision) + assertNotEquals(before, kept.fingerPrint()) + } + + @Test + fun groundImageShowsTheImageOnceLoadedAndKeepsOnlyTheLatestUrl() { + val bounds = + GeoRectBounds( + southWest = GeoPoint.fromLatLong(35.0, 139.0), + northEast = GeoPoint.fromLatLong(35.1, 139.1), + ) + val state = GroundImageState(bounds = bounds, imageUrl = "https://example.test/ground.png") + val placeholder = state.image + assertEquals(1, placeholder.intrinsicWidth) + + // 読み終える前に別の URL へ替えたら、前の結果は使わない。 + val other = GroundImageState(bounds = bounds, imageUrl = "https://example.test/old.png") + other.imageUrl = "https://example.test/new.png" + + loadAll() + + assertEquals(8, state.image.intrinsicWidth) + assertNotEquals(placeholder.hashCode(), state.image.hashCode()) + assertEquals("https://example.test/new.png", other.imageUrl) + assertEquals( + GroundImageState(bounds = bounds, imageUrl = "https://example.test/new.png").image, + other.image, + ) + + // 画像を直接書き換えたら URL は消える。copy は URL を引き継ぐ。 + assertEquals("https://example.test/ground.png", state.copy(opacity = 0.5f).imageUrl) + state.image = ColorDrawable(Color.GREEN) + assertNull(state.imageUrl) + assertFalse(state.image is UrlImageDrawable) + } +} diff --git a/src/main/java/com/mapconductor/core/RemoteImageDrawable.kt b/src/main/java/com/mapconductor/core/RemoteImageDrawable.kt new file mode 100644 index 0000000..55d2052 --- /dev/null +++ b/src/main/java/com/mapconductor/core/RemoteImageDrawable.kt @@ -0,0 +1,99 @@ +package com.mapconductor.core + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable + +/** + * URL の画像を、読み終えるまでは透明に、読み終えたらその画像を描く Drawable。 + * [com.mapconductor.core.marker.ImageIcon] が `imageUrl` で作られたときの中身。 + * + * 画像は一度届いたら手放さない([RemoteImageLoader] のキャッシュから追い出されても描ける)。 + * 透明から画像への変化は一方向で、一度しか起きない。 + */ +@InternalMapConductorApi +internal class RemoteImageDrawable( + val url: String, +) : Drawable() { + private val lock = Any() + + @Volatile + private var bitmap: Bitmap? = RemoteImageLoader.cached(url) + private var settled = bitmap != null + private var listeners: MutableList<() -> Unit>? = null + private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + + init { + if (!settled) RemoteImageLoader.load(url, ::onLoaded) + } + + /** 画像が届いているか。 */ + val isReady: Boolean + get() = bitmap != null + + /** URL と、届いているかどうか。届いた時点で一度だけ変わる。 */ + fun identity(): Int = 31 * url.hashCode() + if (isReady) 1 else 0 + + /** + * 読み込みが終わったら(失敗も含めて)[listener] を一度だけ呼ぶ。 + * もう終わっていれば呼ばずに false を返す。呼ぶのは読み込みのスレッド。 + */ + fun whenLoaded(listener: () -> Unit): Boolean = + synchronized(lock) { + if (settled) return false + val list = listeners ?: mutableListOf<() -> Unit>().also { listeners = it } + list.add(listener) + true + } + + private fun onLoaded(result: Bitmap?) { + val toCall = + synchronized(lock) { + bitmap = result + settled = true + listeners.also { listeners = null } + } + toCall?.forEach { it() } + } + + override fun draw(canvas: Canvas) { + val image = bitmap ?: return + canvas.drawBitmap(image, null, bounds, paint) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun getIntrinsicWidth(): Int = bitmap?.width ?: -1 + + override fun getIntrinsicHeight(): Int = bitmap?.height ?: -1 +} + +/** + * URL から読んだ画像を包む [BitmapDrawable]。 + * [com.mapconductor.core.groundimage.GroundImageState] が `imageUrl` で作られたときの `image`。 + * + * 同じ URL の同じ画像なら等しい。毎回作り直す `GroundImage(imageUrl = …)` でも + * 指紋が変わらず、作り直しのたびに描き直さずに済む。読み終えるまでは 1 px の透明な画像。 + */ +@InternalMapConductorApi +internal class UrlImageDrawable( + val url: String, + bitmap: Bitmap?, +) : BitmapDrawable(null, bitmap ?: RemoteImageLoader.transparentPixel) { + override fun equals(other: Any?): Boolean = other is UrlImageDrawable && other.url == url && other.bitmap === bitmap + + override fun hashCode(): Int = 31 * url.hashCode() + System.identityHashCode(bitmap) +} diff --git a/src/main/java/com/mapconductor/core/RemoteImageLoader.kt b/src/main/java/com/mapconductor/core/RemoteImageLoader.kt new file mode 100644 index 0000000..a84d7d1 --- /dev/null +++ b/src/main/java/com/mapconductor/core/RemoteImageLoader.kt @@ -0,0 +1,184 @@ +package com.mapconductor.core + +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLDecoder +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import android.util.Log +import android.util.LruCache +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * URL で渡された画像([com.mapconductor.core.marker.ImageIcon] の `imageUrl`、 + * [com.mapconductor.core.groundimage.GroundImageState] の `imageUrl`)を読む。 + * + * - 同じ URL は同時に一度だけ取りに行き、待っている全員に同じ [Bitmap] を渡す。 + * 14 万本のマーカーが 1 つのアイコンを共有していても、通信は 1 回で済む。 + * - 読んだ画像は大きさの合計で [MAX_CACHE_BYTES] まで覚えておく。 + * - 失敗した URL はアプリが終わるまで失敗のままにし、その後の要求にはすぐ null を返す。 + * ログは URL ごとに一度だけ出す。react-sdk / ios-sdk と同じ。 + * - 読めるのは http / https / file / data。http(s) にはアプリの `android.permission.INTERNET` が要る。 + * + * 読み込みの完了は、呼び出し元とは別のスレッドから通知する。 + */ +@InternalMapConductorApi +internal object RemoteImageLoader { + private const val TAG = "MapConductor" + private const val MAX_CACHE_BYTES = 32 * 1024 * 1024 + private const val MAX_REDIRECTS = 10 + + /** URL からバイト列を取る。テストで差し替える。 */ + @Volatile + internal var fetch: (url: String) -> ByteArray = ::defaultFetch + + /** 取得と復号を走らせる先。テストで差し替える。 */ + @Volatile + internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + + private val scope = CoroutineScope(SupervisorJob()) + private val lock = Any() + private val cache = + object : LruCache(MAX_CACHE_BYTES) { + override fun sizeOf( + key: String, + value: Bitmap, + ): Int = value.allocationByteCount + } + private val waiting = HashMap Unit>>() + private val failed = HashSet() + + /** 読み終えるまでの代わりに使う、1 px の透明な画像。 */ + val transparentPixel: Bitmap by lazy { + Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888).apply { density = Bitmap.DENSITY_NONE } + } + + /** 読み終えて覚えている画像。無ければ null(読み込みは始めない)。 */ + fun cached(url: String): Bitmap? = synchronized(lock) { cache.get(url) } + + /** + * [url] の画像を読み、終わったら [onDone] を一度だけ呼ぶ(失敗は null)。 + * + * 覚えている画像や、失敗した URL なら、その場で呼ぶ。 + */ + fun load( + url: String, + onDone: (Bitmap?) -> Unit, + ) { + val settled: Bitmap? + synchronized(lock) { + val hit = cache.get(url) + when { + hit != null -> settled = hit + url in failed -> settled = null + else -> { + val listeners = waiting[url] + if (listeners != null) { + listeners.add(onDone) + } else { + waiting[url] = mutableListOf(onDone) + start(url) + } + return + } + } + } + onDone(settled) + } + + private fun start(url: String) { + scope.launch(dispatcher) { + val result = + runCatching { + decode(fetch(url)) ?: throw IllegalArgumentException("not an image") + } + val bitmap = result.getOrNull() + val listeners = + synchronized(lock) { + if (bitmap != null) { + cache.put(url, bitmap) + } else if (failed.add(url)) { + Log.w(TAG, "Failed to load image: $url", result.exceptionOrNull()) + } + waiting.remove(url).orEmpty() + } + listeners.forEach { it(bitmap) } + } + } + + /** 覚えている画像と失敗を忘れ、差し替えた取得手段を元に戻す。テスト用。 */ + internal fun resetForTest() { + synchronized(lock) { + cache.evictAll() + failed.clear() + } + fetch = ::defaultFetch + dispatcher = Dispatchers.IO + } + + /** + * 画素の大きさのまま復号する。密度は [Bitmap.DENSITY_NONE] にして、`BitmapDrawable` に + * 包んでも端末の密度で縮まないようにする。 + */ + private fun decode(bytes: ByteArray): Bitmap? { + val options = BitmapFactory.Options().apply { inScaled = false } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)?.apply { + density = Bitmap.DENSITY_NONE + } + } + + private fun defaultFetch(url: String): ByteArray = + when { + url.startsWith("data:") -> decodeDataUrl(url) + url.startsWith("file://") -> File(URL(url).path).readBytes() + url.startsWith("http://") || url.startsWith("https://") -> fetchHttp(url) + else -> throw IllegalArgumentException("Unsupported image URL: $url") + } + + private fun decodeDataUrl(url: String): ByteArray { + val comma = url.indexOf(',') + require(comma >= 0) { "Malformed data URL" } + val header = url.substring(0, comma) + val body = url.substring(comma + 1) + return if (header.endsWith(";base64")) { + Base64.decode(body, Base64.DEFAULT) + } else { + URLDecoder.decode(body, "UTF-8").toByteArray() + } + } + + /** + * リダイレクトは手で追う(HttpURLConnection の自動追従は http と https をまたがない)。 + * android-kml の `KMLLoader` と同じ。 + */ + private fun fetchHttp(urlStr: String): ByteArray { + var url = URL(urlStr) + repeat(MAX_REDIRECTS) { + val connection = url.openConnection() as HttpURLConnection + try { + connection.connectTimeout = 15_000 + connection.readTimeout = 30_000 + connection.instanceFollowRedirects = true + when (val status = connection.responseCode) { + HttpURLConnection.HTTP_OK -> return connection.inputStream.use { it.readBytes() } + in 300..399 -> { + val location = + connection.getHeaderField("Location") + ?: throw IllegalStateException("HTTP $status without Location: $url") + url = URL(url, location) + } + else -> throw IllegalStateException("HTTP $status: $url") + } + } finally { + connection.disconnect() + } + } + throw IllegalStateException("Too many redirects: $urlStr") + } +} diff --git a/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt b/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt index a903d32..a4c5e55 100644 --- a/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt +++ b/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt @@ -3,12 +3,21 @@ package com.mapconductor.core.groundimage import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import com.mapconductor.core.ComponentState +import com.mapconductor.core.RemoteImageLoader import com.mapconductor.core.StateMutationSignal +import com.mapconductor.core.UrlImageDrawable import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoRectBounds import java.io.Serializable import android.graphics.drawable.Drawable +/** + * 地図に貼る画像。 + * + * 画像は `Drawable` で渡すか、[imageUrl](http / https / file / data)で渡す。URL のときは + * SDK が読み、読み終えるまでは透明、読み終えたら [image] がその画像に替わって描き直される。 + * http(s) にはアプリの `android.permission.INTERNET` が要る。react-sdk の `imageUrl` と同じ。 + */ class GroundImageState( bounds: GeoRectBounds, image: Drawable, @@ -29,8 +38,37 @@ class GroundImageState( // var bounds by StateFlowDelegate(bounds) var bounds by mutations.notifying(bounds) - var image by mutations.notifying(image) + private val currentImage = mutations.notifying(image) + private val currentImageUrl = mutations.notifying(null) + + /** 描く画像。書き換えると [imageUrl] は null になる。 */ + var image: Drawable + get() = currentImage.value + set(value) { + currentImageUrl.value = null + currentImage.value = value + } + + /** + * 画像の URL。`Drawable` で作ったか、[image] を書き換えたなら null。 + * + * 書き換えると、読み終えるまで [image] は透明になり、読み終えたらその画像になる。 + * 読めなかったときは透明のまま。読み終える前にまた書き換えたら、古い方の結果は捨てる。 + */ + var imageUrl: String? + get() = currentImageUrl.value + set(value) { + currentImageUrl.value = value + if (value == null) return + currentImage.value = UrlImageDrawable(value, null) + RemoteImageLoader.load(value) { bitmap -> + if (bitmap != null && currentImageUrl.value == value) { + currentImage.value = UrlImageDrawable(value, bitmap) + } + } + } var opacity by mutations.notifying(opacity) + var tileSize by mutations.notifying(tileSize) /** @@ -45,6 +83,29 @@ class GroundImageState( var extra by mutations.notifying(extra) var onClick by mutations.notifying(onClick) + /** [imageUrl] の画像を貼る。読み終えるまでは透明。ほかの引数は `Drawable` を渡すコンストラクタと同じ。 */ + constructor( + bounds: GeoRectBounds, + imageUrl: String, + opacity: Float = 1.0f, + tileSize: Int = GroundImageTileProvider.DEFAULT_TILE_SIZE, + clickable: Boolean = true, + id: String? = null, + extra: Serializable? = null, + onClick: OnGroundImageEventHandler? = null, + ) : this( + bounds = bounds, + image = UrlImageDrawable(imageUrl, null), + opacity = opacity, + tileSize = tileSize, + clickable = clickable, + id = id, + extra = extra, + onClick = onClick, + ) { + this.imageUrl = imageUrl + } + fun fingerPrint(): GroundImageFingerPrint = GroundImageFingerPrint( id = id.hashCode(), @@ -82,8 +143,22 @@ class GroundImageState( id: String? = this.id, extra: Serializable? = this.extra, onClick: OnGroundImageEventHandler? = this.onClick, - ): GroundImageState = - GroundImageState( + ): GroundImageState { + // 画像を差し替えないなら URL ごと引き継ぐ(読み込み中でも、届いたら写る)。 + val url = imageUrl + if (url != null && image === this.image) { + return GroundImageState( + bounds = bounds, + imageUrl = url, + opacity = opacity, + tileSize = tileSize, + clickable = clickable, + id = id, + extra = extra, + onClick = onClick, + ) + } + return GroundImageState( bounds = bounds, image = image, opacity = opacity, @@ -93,6 +168,7 @@ class GroundImageState( extra = extra, onClick = onClick, ) + } override fun equals(other: Any?): Boolean = (other as? GroundImageState)?.hashCode() == this.hashCode() diff --git a/src/main/java/com/mapconductor/core/marker/ImageIcon.kt b/src/main/java/com/mapconductor/core/marker/ImageIcon.kt index 8c55e3b..0b4c79a 100644 --- a/src/main/java/com/mapconductor/core/marker/ImageIcon.kt +++ b/src/main/java/com/mapconductor/core/marker/ImageIcon.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import androidx.core.graphics.createBitmap import com.mapconductor.core.BitmapIconCache +import com.mapconductor.core.RemoteImageDrawable import com.mapconductor.core.ResourceProvider import com.mapconductor.settings.Settings import android.graphics.Canvas @@ -38,6 +39,14 @@ import android.graphics.drawable.GradientDrawable * * タイル描画は本体の画像と文字を別々に使うので、ラベル付きのアイコンをいくつ作っても、 * 本体の画像は元の画像と大きさごとに 1 枚で済む。 + * + * ## URL の画像 + * + * `ImageIcon(imageUrl = "https://…")` は画像を SDK が読む。読み終えるまでは同じ大きさの + * 透明な画像(ラベルは描く)で、届いたらそのアイコンを使っているマーカーが描き直される。 + * 同じ URL は一度しか取りに行かない。http / https / file / data を読め、http(s) には + * アプリの `android.permission.INTERNET` が要る。読めなかったときは透明のまま。 + * react-sdk / ios-sdk の `ImageIcon` の `imageUrl` と同じ。 */ class ImageIcon( image: Drawable, @@ -56,9 +65,54 @@ class ImageIcon( ) : AndroidDrawableIcon( drawable = image, ) { + /** + * [imageUrl](http / https / file / data)の画像を使う。読み終えるまでは透明。 + * ほかの引数は `Drawable` を渡すコンストラクタと同じ。 + */ + constructor( + imageUrl: String, + iconSize: Dp = Settings.Default.iconSize, + scale: Float = 1.0f, + anchor: Offset = Offset(0.5f, 0.5f), + infoAnchor: Offset = Offset(0.5f, 0.5f), + debug: Boolean = false, + label: String? = null, + labelTextColor: Color? = Color.Black, + labelTextSize: TextUnit = 18.sp, + labelTypeFace: Typeface = Typeface.DEFAULT, + labelStrokeColor: Color = Color.White, + labelPosition: String = AbstractDefaultIcon.DEFAULT_LABEL_POSITION, + labelWritingMode: String = AbstractDefaultIcon.DEFAULT_LABEL_WRITING_MODE, + ) : this( + image = RemoteImageDrawable(imageUrl), + iconSize = iconSize, + scale = scale, + anchor = anchor, + infoAnchor = infoAnchor, + debug = debug, + label = label, + labelTextColor = labelTextColor, + labelTextSize = labelTextSize, + labelTypeFace = labelTypeFace, + labelStrokeColor = labelStrokeColor, + labelPosition = labelPosition, + labelWritingMode = labelWritingMode, + ) + private val requestedAnchor = anchor private val requestedInfoAnchor = infoAnchor + /** URL で作ったアイコンならその URL。`Drawable` で作ったなら null。 */ + val imageUrl: String? + get() = (drawable as? RemoteImageDrawable)?.url + + /** + * URL の画像がまだ届いていなければ、届いたとき(失敗も含む)に [listener] を一度だけ呼ぶ。 + * 待つ必要が無ければ呼ばずに false を返す。[MarkerState] が描き直しに使う。 + */ + internal fun whenImageLoaded(listener: () -> Unit): Boolean = + (drawable as? RemoteImageDrawable)?.whenLoaded(listener) ?: false + private class Layout( val canvasSize: Int, val result: IconLabelLayout.Result, @@ -143,6 +197,8 @@ class ImageIcon( private fun getDrawableIdentity(): Int = when (drawable) { + // URL と、画像が届いたかどうか。届いた時点で変わり、マーカーが描き直される。 + is RemoteImageDrawable -> drawable.identity() is BitmapDrawable -> { val bmp = drawable.bitmap if (bmp == null || bmp.isRecycled) { diff --git a/src/main/java/com/mapconductor/core/marker/Marker.kt b/src/main/java/com/mapconductor/core/marker/Marker.kt index d5f9416..bc14c3f 100644 --- a/src/main/java/com/mapconductor/core/marker/Marker.kt +++ b/src/main/java/com/mapconductor/core/marker/Marker.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.marker import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -53,7 +54,18 @@ class MarkerState( */ override val mutations = StateMutationSignal() - var icon by mutations.notifying(icon) + private val currentIcon = mutations.notifying(icon) + + /** [iconRevision] の中身。URL の画像が届くたびに増える。 */ + internal val iconRevisionState = mutableIntStateOf(0) + + var icon: MarkerIconInterface? + get() = currentIcon.value + set(value) { + val changed = currentIcon.value != value + currentIcon.value = value + if (changed) redrawWhenImageLoaded(value) + } var clickable by mutations.notifying(clickable) var draggable by mutations.notifying(draggable) var onClick by mutations.notifying(onClick) @@ -66,6 +78,27 @@ class MarkerState( private var internalAnimation by mutations.notifying(animation) + init { + redrawWhenImageLoaded(icon) + } + + /** + * URL の画像がまだ届いていないアイコンなら、届いたときに描き直させる。 + * + * アイコンの見た目(hashCode)は画像が届いた時点で変わるが、[icon] 自体は書き換わらない + * ので、ここで知らせる。そのときもまだ同じアイコンを付けていれば、だけ。 + * 読み込みのスレッドから呼ばれるが、[StateMutationSignal.notifyMutated] はどのスレッドからでもよい。 + */ + private fun redrawWhenImageLoaded(icon: MarkerIconInterface?) { + val image = icon as? ImageIcon ?: return + image.whenImageLoaded { + if (currentIcon.value === image) { + iconRevisionState.intValue++ + mutations.notifyMutated() + } + } + } + fun animate(animation: MarkerAnimation?) { internalAnimation = animation } diff --git a/src/main/java/com/mapconductor/core/marker/MarkerIconRevision.kt b/src/main/java/com/mapconductor/core/marker/MarkerIconRevision.kt new file mode 100644 index 0000000..dab03a5 --- /dev/null +++ b/src/main/java/com/mapconductor/core/marker/MarkerIconRevision.kt @@ -0,0 +1,17 @@ +package com.mapconductor.core.marker + +import com.mapconductor.core.InternalMapConductorApi + +/** + * [MarkerState.icon] の URL の画像が届くたびに増える数。Compose から読むと、そのとき描き直される。 + * + * URL の画像が届いても、アイコンは同じインスタンスのまま `hashCode` だけが変わるので、 + * `marker.icon` を読んでいるだけのコンポジションは走り直さない。マーカーをコンポーズの + * オーバーレイとして描くプロバイダは、これも読んで `remember` のキーにする。 + * + * [MarkerState.icon] の読み出し自体は Compose の状態を増やさない。タイルの描画スレッドや + * コントローラが 14 万回読むので、そこへ余計な読み取りを足さないため。 + */ +@InternalMapConductorApi +val MarkerState.iconRevision: Int + get() = iconRevisionState.intValue