Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/android-sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<com.mapconductor.core.groundimage.GroundImageEvent, kotlin.Unit> getOnClick();
public final void setOnClick(kotlin.jvm.functions.Function1<? super com.mapconductor.core.groundimage.GroundImageEvent, kotlin.Unit>);
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<? super com.mapconductor.core.groundimage.GroundImageEvent, kotlin.Unit>);
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<? super com.mapconductor.core.groundimage.GroundImageEvent, kotlin.Unit>);
public boolean equals(java.lang.Object);
Expand Down Expand Up @@ -1582,13 +1586,16 @@ 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);
public int hashCode();
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();
Expand Down
29 changes: 28 additions & 1 deletion docs/groundimage/GroundImage.kt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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`
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 32 additions & 1 deletion docs/marker/ImageIcon.kt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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,
Expand Down
199 changes: 199 additions & 0 deletions src/androidTest/java/com/mapconductor/core/RemoteImageDeviceTest.kt
Original file line number Diff line number Diff line change
@@ -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<Bitmap?>()
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)
}
}
Loading
Loading