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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ and [Harness](docs/harness.md) the emulator lanes, evidence, and live proof.
| `core/common` | The put.io failure and result kernel (`PutioFailure`, `PutioResult`, `putioRequest`, API rejection reasons), Files ids, cursors and sort order, the download-token account read, timestamps, avatar URLs, account storage keys, `SessionScopedHolder` |
| `core/design` | `PutioTheme`, generated design tokens, file-type and shared Phosphor drawables, `BasePutioActivity`, the inactive-account notice's words |
| `domain/<name>` | One domain's models, SDK repository, reducer and controller, shared by both surfaces: `account` (account settings, app config, inactive-account notice), `auth`, `files`, `history`, `playback`, `search`, `sharing` (public links; mobile only so far), `transfers`, `trash` |
| `mobile` | Phone and tablet app: touch UI, shell, navigation, offline downloads, the system file picker's documents provider, services and session wiring |
| `mobile` | Phone and tablet app: touch UI, shell, navigation, offline downloads, the system file picker's documents provider, launcher shortcuts, the home-screen widget, services and session wiring |
| `tv` | Android TV app: D-pad UI, shell and session wiring |
| `build-logic` | Convention plugins (`putio.android.application`, `putio.android.library`), design-token codegen, launcher-manifest check, proof tasks |

Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ Mobile, on the emulator harness against the live API:
under Account shows the queue in start order with each row's place, a 1–4
concurrency limit (default 3), paused, failed and missing rows with retry,
storage used, and multi-select delete of local copies only. Finished and
failed downloads notify and open their row when notifications are allowed.
failed downloads notify and open their row when notifications are allowed,
with Play for a finished one and Try again for a failed one, each acting only
for the account that downloaded it.
Completed downloads play without a connection and resume where this device
left off; positions saved offline reach put.io once it answers again
- Transfer intake: tapped magnet links, opened or shared `.torrent` files, shared
Expand All @@ -54,6 +56,11 @@ Mobile, on the emulator harness against the live API:
- Exclusive access: create, copy, share and revoke public links to your own
files and folders from their action sheet; Account lists every link with
revoke
- Launcher shortcuts: Search, Add transfer (an empty sheet; nothing is added
until Add) and Downloads, each waiting for sign-in when signed out
- A Transfers home-screen widget, proven on the emulator with a faked account so
far: active transfers with progress, refreshed every 30 minutes, on sign-in,
from the Transfers screen and on demand; sign-out clears it at once
- Tablets and keyboards, proven on a tablet-size emulator with faked data so
far: keyboard navigation and shortcuts in Files and the player, listed in the
Keyboard Shortcuts Helper; files drag out to other apps, and links, magnet
Expand Down
23 changes: 23 additions & 0 deletions build-logic/src/main/kotlin/DesignTokenCodegen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ object DesignTokenCodegen {
*/
val APP_TOKENS: Set<String> = setOf("yellowSolid")

/**
* Tokens Android XML reads as `@color/putio_<css-name>`: RemoteViews layouts, which Compose
* colors cannot reach, and adaptive shortcut icons. Add a token here before an XML surface uses it.
*/
val RESOURCE_TOKENS: Set<String> = setOf("appBg", "componentBg", "text", "textSecondary", "yellowSolid")

/**
* M3 role → token property (optionally `property:alpha`). These are the
* contracted roles from DESIGN.md. The container and background entries
Expand Down Expand Up @@ -126,6 +132,23 @@ object DesignTokenCodegen {
return render(resolved, ratios, designVersion)
}

/** `values/putio_design_colors.xml`: the [RESOURCE_TOKENS] colors, resolved like the Kotlin ones. */
fun generateColorResources(dtcgJson: String, designVersion: String): String {
val tokens = parse(dtcgJson)
return buildString {
appendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>")
appendLine("<!-- Generated by :core:design:generateDesignTokens from design/tokens.dtcg.json")
appendLine(" (@putdotio/design $designVersion). Do not edit; see design/README.md. -->")
appendLine("<resources>")
for ((property, cssName) in TOKEN_BINDINGS) {
if (property !in RESOURCE_TOKENS) continue
val argb = resolve(tokens, cssName)
appendLine(" <color name=\"putio_${cssName.replace('-', '_')}\">#${"%08X".format(argb)}</color>")
}
appendLine("</resources>")
}
}

private fun parse(dtcgJson: String): Map<String, RawToken> {
val root = Json.parseToJsonElement(dtcgJson).jsonObject
val out = mutableMapOf<String, RawToken>()
Expand Down
9 changes: 8 additions & 1 deletion build-logic/src/main/kotlin/GenerateDesignTokensTask.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction

/** Writes PutioDesignTokens.kt from the vendored DTCG token graph. */
/** Writes PutioDesignTokens.kt and the XML color resources from the vendored DTCG token graph. */
@CacheableTask
abstract class GenerateDesignTokensTask : DefaultTask() {

Expand All @@ -25,12 +25,19 @@ abstract class GenerateDesignTokensTask : DefaultTask() {
@get:OutputDirectory
abstract val outputDir: DirectoryProperty

/** An Android resource directory holding `values/putio_design_colors.xml`. */
@get:OutputDirectory
abstract val resOutputDir: DirectoryProperty

@TaskAction
fun generate() {
val source = tokensFile.get().asFile.readText()
val code = DesignTokenCodegen.generate(source, designVersion.get())
val target = outputDir.get().asFile.resolve("io/putdotio/android/design/PutioDesignTokens.kt")
target.parentFile.mkdirs()
target.writeText(code)
val colors = resOutputDir.get().asFile.resolve("values/putio_design_colors.xml")
colors.parentFile.mkdirs()
colors.writeText(DesignTokenCodegen.generateColorResources(source, designVersion.get()))
}
}
48 changes: 48 additions & 0 deletions build-logic/src/main/kotlin/GenerateShortcutsTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction

/**
* Writes `xml/shortcuts.xml` from a template with each `${applicationId}` replaced. The platform
* reads a static shortcut's `android:targetPackage` literally, without resolving resource
* references, and every channel and build type installs under its own application id.
*/
@CacheableTask
abstract class GenerateShortcutsTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val template: RegularFileProperty

@get:Input
abstract val applicationId: Property<String>

/** An Android resource directory. */
@get:OutputDirectory
abstract val outputDir: DirectoryProperty

@TaskAction
fun generate() {
val target = outputDir.get().asFile.resolve("xml/shortcuts.xml")
target.parentFile.mkdirs()
target.writeText(renderShortcuts(template.get().asFile.readText(), applicationId.get()))
}
}

internal fun renderShortcuts(template: String, applicationId: String): String {
if (PLACEHOLDER !in template) throw GradleException("The shortcuts template names no $PLACEHOLDER")
if (!applicationId.matches(Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+"))) {
throw GradleException("'$applicationId' is not an application id")
}
return template.replace(PLACEHOLDER, applicationId)
}

private const val PLACEHOLDER = "\${applicationId}"
11 changes: 11 additions & 0 deletions build-logic/src/test/kotlin/DesignTokenCodegenTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,15 @@ class DesignTokenCodegenTest {
assertTrue(error is IllegalStateException)
assertTrue(error!!.message!!.contains("tv-overscan-y"))
}

@Test
fun generatesXmlColorsForTheResourceTokensOnly() {
val dtcg = java.io.File("../design/tokens.dtcg.json").readText()
val xml = DesignTokenCodegen.generateColorResources(dtcg, "3.3.0")
assertTrue(xml.contains("<color name=\"putio_yellow_solid\">#FFFDCE45</color>"))
// Dark mode wins here too.
assertTrue(xml.contains("<color name=\"putio_app_bg\">#FF161616</color>"))
assertEquals(DesignTokenCodegen.RESOURCE_TOKENS.size, Regex("<color ").findAll(xml).count())
assertTrue(!xml.contains("putio_red_solid"))
}
}
23 changes: 23 additions & 0 deletions build-logic/src/test/kotlin/GenerateShortcutsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import org.gradle.api.GradleException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test

class GenerateShortcutsTest {
@Test
fun everyPlaceholderBecomesTheVariantsApplicationId() {
val template = """<intent android:targetPackage="${'$'}{applicationId}" />""".repeat(2)
assertEquals(
"""<intent android:targetPackage="io.put.putio.mobile.nightly.debug" />""".repeat(2),
renderShortcuts(template, "io.put.putio.mobile.nightly.debug"),
)
}

@Test
fun aTemplateWithoutThePlaceholderOrABadIdFails() {
assertThrows(GradleException::class.java) { renderShortcuts("<shortcuts />", "io.put.putio.mobile") }
assertThrows(GradleException::class.java) {
renderShortcuts("${'$'}{applicationId}", "io.put.putio.mobile\" android:x=\"")
}
}
}
5 changes: 5 additions & 0 deletions core/design/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ val generateDesignTokens = tasks.register<GenerateDesignTokensTask>("generateDes
},
)
outputDir.set(layout.buildDirectory.dir("generated/designTokens/kotlin"))
resOutputDir.set(layout.buildDirectory.dir("generated/designTokens/res"))
}

androidComponents {
Expand All @@ -35,5 +36,9 @@ androidComponents {
generateDesignTokens,
GenerateDesignTokensTask::outputDir,
)
variant.sources.res?.addGeneratedSourceDirectory(
generateDesignTokens,
GenerateDesignTokensTask::resOutputDir,
)
}
}
5 changes: 4 additions & 1 deletion design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ SRI, and pins the SHA-256 of every source asset and of every written file.
The Compose color schemes and the TV overscan ratios (`tv.overscan.x/y`) are
generated from the JSON at build time by `:core:design:generateDesignTokens` (task
class in `build-logic/`), which records the locked version in the generated
header. Never hand-edit the generated Kotlin, the JSON, or the icons.
header. The same task writes `@color/putio_<css-name>` resources for the tokens in
`RESOURCE_TOKENS`, for XML that Compose cannot reach: the home-screen widget's
RemoteViews layout and the adaptive shortcut icons. Never hand-edit the generated
Kotlin, the JSON, or the icons.

Syncing downloads that one tarball, verifies it and each source asset before
use, and writes the files only when every output matches the lock. Icon
Expand Down
Loading