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
132 changes: 132 additions & 0 deletions android/app/src/androidTest/java/com/example/kati/Issue128Test.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package com.example.kati

import android.graphics.BitmapFactory
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* mishka-group/kati#128 on a device: the share card is the card alone, with
* its shadow, Your year's round slot shares, Up next rows open their
* title with no empty Gone cold band, and a film kept in a list says so.
*/
@RunWith(AndroidJUnit4::class)
class Issue128Test {

@get:Rule
val kati = KatiRule()

private fun capture(): File =
File(
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext.cacheDir,
"kati-year-${java.time.LocalDate.now().year}.png"
)

/** A tap on a control that may sit below the fold of a sheet. */
private fun reach(tag: String) {
repeat(4) {
try {
kati.tap(tag)
return
} catch (_: AssertionError) {
val d = kati.device
d.swipe(d.displayWidth / 2, d.displayHeight * 3 / 4, d.displayWidth / 2, d.displayHeight / 4, 20)
d.waitForIdle()
}
}
kati.tap(tag)
}

private fun mark(what: String) {
android.util.Log.i("KATI_SHOT", "READY $what")
Thread.sleep(6_000)
}

@Test
fun a_the_share_card_is_the_card_in_its_shape() {
kati.launch()
kati.firstRun()

kati.tap("root_stats")
kati.awaitScreen("stats")
kati.compose.waitUntil(20_000) { kati.present("share_fab") }
mark("stats_dock")
kati.tap("share_fab")
kati.compose.waitUntil(20_000) { kati.present("save_image") }
assertFalse("the scope rail is gone", kati.present("scope_books"))

capture().delete()
kati.tap("save_image")
kati.compose.waitUntil(10_000) { capture().exists() }
val square = BitmapFactory.decodeFile(capture().path)
val screen = kati.device.displayHeight
assertTrue("the card, not the screen", square.height < screen)
kati.device.pressBack()
kati.device.waitForIdle()

kati.compose.waitUntil(20_000) { kati.present("aspect_story") }
kati.tap("aspect_story")
capture().delete()
kati.tap("share_image")
kati.compose.waitUntil(10_000) { capture().exists() }
val story = BitmapFactory.decodeFile(capture().path)
assertTrue("Story sets the card's type larger", story.height > square.height)
mark("share_sheet")
kati.device.pressBack()
kati.device.waitForIdle()
}

@Test
fun b_up_next_rows_open_and_no_empty_cold_band() {
kati.launch()
kati.firstRun()
// Two shows: the first is Up next's hero, the second a row under it.
ByHand.add(kati, "Radio Star", "kind_tv", "status_watching")
ByHand.add(kati, "Silo", "kind_tv", "status_watching")
ByHand.toTabs(kati)

kati.tap("root_library")
kati.awaitScreen("library")
kati.compose.waitUntil(20_000) { kati.present("open_up_next") }
kati.tap("open_up_next")
kati.awaitScreen("up_next")
kati.compose.waitUntil(20_000) { kati.tagStartingWith("row_") != null }
assertFalse("no Gone cold band over nothing", ByHand.shown(kati, "GONE COLD"))
mark("up_next")

kati.tap(kati.tagStartingWith("row_")!!)
kati.awaitScreen("series")
}

@Test
fun c_a_film_in_a_list_says_so() {
kati.launch()
kati.firstRun()
ByHand.add(kati, "Rogue Nation", "kind_movie")

kati.compose.waitUntil(20_000) { kati.present("add_to_list") }
kati.systemDialog("Allow")
kati.device.waitForIdle()
Thread.sleep(1_500)
assertTrue(ByHand.shown(kati, "Add to list"))
kati.tap("add_to_list")
kati.awaitScreen("add_to_list")
reach("new_list")
kati.compose.waitUntil(10_000) { kati.present("list_name") }
kati.compose.onNodeWithTag("list_name", useUnmergedTree = true).performTextInput("Weekend")
kati.device.waitForIdle()
reach("save_list")
kati.device.waitForIdle()
kati.compose.waitUntil(10_000) { kati.count("list_memberships") > 0 }

kati.device.pressBack()
kati.compose.waitUntil(20_000) { ByHand.shown(kati, "In a list") }
mark("film_in_list")
}
}
95 changes: 94 additions & 1 deletion android/app/src/main/java/com/example/kati/MobBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.launch
// KATI-BEGIN(K-78 capture-node-import) mob_new=0.6.3
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.asAndroidBitmap
// KATI-END(K-78 capture-node-import)
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
// KATI-BEGIN(K-08 box-shadow-import) mob_new=0.4.20
Expand Down Expand Up @@ -1833,6 +1837,9 @@ object MobBridge {
@JvmStatic
fun katiCaptureScreen(name: String): String {
val activity = activityRef?.get() ?: return "error:no_activity"
// KATI-BEGIN(K-78 capture-node-route) mob_new=0.6.3
if (name.contains("|")) return katiCaptureNode(activity, name)
// KATI-END(K-78 capture-node-route)

val result = java.util.concurrent.atomic.AtomicReference<String>("error:timeout")
val latch = java.util.concurrent.CountDownLatch(1)
Expand Down Expand Up @@ -1956,6 +1963,89 @@ object MobBridge {
}
// KATI-END(K-45 capture-screen)

// KATI-BEGIN(K-78 capture-node) mob_new=0.6.3
/**
* A picture of one node, not of the screen.
*
* A node whose `:id` starts with `capture_` records its own drawing into a
* GraphicsLayer as it draws (`captureModifier`). The share card asks for
* that layer by id, so what it saves and sends is the card alone: no back
* pill floating over it, nothing scrolled past, no page around it. The
* layer renders through the GPU, so posters decoded as hardware bitmaps
* come out as they look.
*
* The request rides on `katiCaptureScreen`'s one string so no new NIF is
* needed: `"<id>|<argb>|<corner dp>|<filename>"`. The picture is the
* node with its own rounded corners and a soft shadow under it, on a
* thin margin of the page colour (the argb) — the card as it sits on the
* page, and nothing else of the page.
*/
private val captureLayers =
ConcurrentHashMap<String, androidx.compose.ui.graphics.layer.GraphicsLayer>()

@Composable
fun captureModifier(id: String): Modifier {
val layer = androidx.compose.ui.graphics.rememberGraphicsLayer()
DisposableEffect(id, layer) {
captureLayers[id] = layer
onDispose { captureLayers.remove(id, layer) }
}
return Modifier.drawWithContent {
layer.record { this@drawWithContent.drawContent() }
drawLayer(layer)
}
}

@JvmStatic
private fun katiCaptureNode(activity: android.app.Activity, request: String): String {
val parts = request.split("|", limit = 4)
if (parts.size != 4) return "error:bad_request"
val layer = captureLayers[parts[0]] ?: return "error:no_node"
val ground = parts[1].toLongOrNull()?.toInt() ?: android.graphics.Color.WHITE
val corner = parts[2].toFloatOrNull() ?: 0f

val result = java.util.concurrent.atomic.AtomicReference<String>("error:timeout")
val latch = java.util.concurrent.CountDownLatch(1)

mainScope.launch {
try {
val drawn = layer.toImageBitmap().asAndroidBitmap()
val card = drawn.copy(Bitmap.Config.ARGB_8888, false)
val dp = activity.resources.displayMetrics.density
val corner = corner * dp
val margin = (24 * dp).toInt()
val framed = Bitmap.createBitmap(
card.width + 2 * margin, card.height + 2 * margin, Bitmap.Config.ARGB_8888
)
android.graphics.Canvas(framed).apply {
drawColor(ground)
val shadow = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = card.getPixel(card.width / 2, card.height / 2).let {
if (android.graphics.Color.alpha(it) == 255) it else android.graphics.Color.WHITE
}
setShadowLayer(14 * dp, 0f, 5 * dp, 0x2E1A1917)
}
drawRoundRect(
margin.toFloat(), margin.toFloat(),
(margin + card.width).toFloat(), (margin + card.height).toFloat(),
corner, corner, shadow
)
drawBitmap(card, margin.toFloat(), margin.toFloat(), null)
}
card.recycle()
result.set(katiWriteCapture(activity, framed, parts[3]))
} catch (e: Exception) {
android.util.Log.e("KatiCapture", "node capture failed", e)
result.set("error:" + e.javaClass.simpleName)
}
latch.countDown()
}

latch.await(5, java.util.concurrent.TimeUnit.SECONDS)
return result.get()
}
// KATI-END(K-78 capture-node)

// KATI-BEGIN(K-44 open-settings) mob_new=0.4.20
/**
* Open one of the phone's own settings screens, by name.
Expand Down Expand Up @@ -4906,7 +4996,10 @@ private fun RenderNodeInner(node: MobNode, modifier: Modifier) {
// Track on-screen frame + set a testTag for any node carrying an :id, so the
// agent can read positions (Mob.Test.element_frames) without a screenshot.
val trackId = node.props["id"] as? String
val m = if (trackId != null) base.then(MobBridge.frameTrackingModifier(trackId)) else base
// KATI-BEGIN(K-78 capture-node-modifier) mob_new=0.6.3
val captured = if (trackId?.startsWith("capture_") == true) MobBridge.captureModifier(trackId).then(base) else base
val m = if (trackId != null) captured.then(MobBridge.frameTrackingModifier(trackId)) else captured
// KATI-END(K-78 capture-node-modifier)
when (node.type) {
"column" -> Column(
modifier = m,
Expand Down
54 changes: 52 additions & 2 deletions lib/kati/discover/filters.ex
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ defmodule Kati.Discover.Filters do
sort: sort,
rating: sanitise(Map.get(stored, :rating), @ratings)
}
|> with_providers(Map.get(stored, :providers, []))

_absent ->
resting()
Expand Down Expand Up @@ -150,7 +151,43 @@ defmodule Kati.Discover.Filters do
true
"""
@spec narrowed?(map()) :: boolean()
def narrowed?(choice), do: choice != resting()
def narrowed?(choice),
do: Map.delete(choice, :providers) != resting() or providers(choice) != []

@doc """
The reader's services the browse is narrowed to — TMDB provider ids (#106).
Absent from a choice that names none, so a choice made before services
existed compares equal to one made after.

iex> Kati.Discover.Filters.providers(Kati.Discover.Filters.resting())
[]

iex> Kati.Discover.Filters.with_provider(Kati.Discover.Filters.resting(), "8")
%{kind: nil, sort: :popular, rating: nil, providers: ["8"]}

iex> Kati.Discover.Filters.resting()
...> |> Kati.Discover.Filters.with_provider("8")
...> |> Kati.Discover.Filters.with_provider("8")
%{kind: nil, sort: :popular, rating: nil}
"""
@spec providers(map()) :: [String.t()]
def providers(choice), do: Map.get(choice, :providers, [])

@spec with_provider(map(), String.t()) :: map()
def with_provider(choice, id) when is_binary(id) do
chosen = providers(choice)

if id in chosen,
do: with_providers(choice, List.delete(chosen, id)),
else: with_providers(choice, chosen ++ [id])
end

defp with_providers(choice, ids) do
case Enum.filter(List.wrap(ids), &is_binary/1) do
[] -> Map.delete(choice, :providers)
kept -> Map.put(choice, :providers, kept)
end
end

@doc """
Which endpoint a choice asks: the kind it names, or film when it names none.
Expand Down Expand Up @@ -214,7 +251,20 @@ defmodule Kati.Discover.Filters do
[include_adult: "false", page: "1", sort_by: sort_by(Map.get(choice, :sort), kind)] ++
[{date_key, Date.to_iso8601(today)}] ++
vote_floor(Map.get(choice, :sort), Map.get(choice, :rating)) ++
rating_param(Map.get(choice, :rating))
rating_param(Map.get(choice, :rating)) ++
provider_params(providers(choice))
end

# On the reader's own services, in their region, to stream rather than to
# rent or buy (#106).
defp provider_params([]), do: []

defp provider_params(ids) do
[
with_watch_providers: Enum.join(ids, "|"),
watch_region: Kati.Services.region(),
with_watch_monetization_types: "flatrate|free|ads"
]
end

@doc """
Expand Down
Loading
Loading