Skip to content

Commit ba4fc10

Browse files
committed
expand scope from previous conversion examples
Signed-off-by: alperozturk96 <alper_ozturk@proton.me>
1 parent 0cb22d0 commit ba4fc10

4 files changed

Lines changed: 192 additions & 16 deletions

File tree

.claude/skills/android-java-to-kotlin/SKILL.md

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,17 @@ observable behaviour** — and then writes a test that proves behaviour is uncha
2626

2727
## The Two-Person Workflow
2828

29-
```dot
30-
digraph android_j2k {
31-
rankdir=TB;
32-
"Developer: IDE converts .java -> .kt" -> "Step 0: Establish baseline";
33-
"Step 1: Detect frameworks" -> "Step 2: Idiomatic pass";
34-
"Step 2: Idiomatic pass" -> "Step 3: Write behaviour-locking test";
35-
"Step 3: Write behaviour-locking test" -> "Step 4: Verify (build + checks + tests)";
36-
"Step 4: Verify (build + checks + tests)" -> "Done" [label="green"];
37-
"Step 5: Verify (build + checks + tests)" -> "Step 2: Idiomatic pass" [label="fail / behaviour drift"];
38-
}
39-
```
29+
This skill assumes a hand-off:
30+
31+
1. **Developer** runs the mechanical IDE conversion (`Code > Convert Java File to Kotlin
32+
File`, or ⌥⇧⌘K), producing a `.kt` file that compiles but is not idiomatic.
33+
2. **You (Claude)** drive everything after that:
34+
35+
Step 0 Baseline → Step 1 Idiomatic pass → Step 2 Behaviour-locking test → Step 3
36+
Verify. If Step 3 fails or behaviour drifts, loop back to Step 1.
4037

41-
The **developer** runs the mechanical IDE conversion (`Code > Convert Java File to Kotlin
42-
File`, or ⌥⇧⌘K). **You (Claude)** complete everything after that. If you are handed a
43-
`.java` file instead, first apply the faithful 1:1 translation to reach the same
44-
starting point, then continue.
38+
If you are handed a `.java` file instead, first apply a faithful 1:1 translation to reach
39+
the same starting point, then continue.
4540

4641
## The Prime Directive: Behaviour Must Not Change
4742

@@ -79,7 +74,9 @@ Apply, in this order, then re-check the invariants:
7974
[CONCURRENCY.md](references/CONCURRENCY.md).
8075
5. **Modern Android + Kotlin idioms.** Scope functions (`run`/`apply`/`let`), extension
8176
functions, `when`/`partition`/`filter` over `switch`, string templates, `isNullOrEmpty`,
82-
view/KTX extensions. See [ANDROID-IDIOMS.md](references/ANDROID-IDIOMS.md).
77+
view/KTX extensions. See [ANDROID-IDIOMS.md](references/ANDROID-IDIOMS.md). Retire
78+
deprecated Android APIs (`onActivityResult`, options-menu overrides,
79+
`java.util.Observable`) — see [DEPRECATED-APIS.md](references/DEPRECATED-APIS.md).
8380
6. **Project conventions.** SPDX header, no magic numbers (`companion object` +
8481
`const val`), resources not hardcoded strings, ≤300 lines/file, ≤120 cols. See
8582
[PROJECT-CONVENTIONS.md](references/PROJECT-CONVENTIONS.md).

.claude/skills/android-java-to-kotlin/references/ANDROID-IDIOMS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ Import members directly and lean on AndroidX KTX instead of verbose Java utiliti
100100
| `BundleExtensionsKt.getParcelableArgument(b, k, T.class)` | `b.getParcelableArgument(k, T::class.java)` |
101101
| `for (int i = 0; i < vg.getChildCount(); i++)` | `for (i in 0..<view.size)` (`androidx.core.view.size`) |
102102
| manual getter/setter methods | Kotlin property access (`view.visibility = View.GONE`) |
103+
| `private int x; public int getX()` (read-only to callers) | `var columnsCount = 0; private set` |
104+
| empty override method body | `= Unit` single-expression body |
103105
| free-standing util call | receiver extension (`externalShares.mergeDistinctByToken(publicShares)`) |
104106

105107
Domain-specific extensions read best as receivers on the relevant type:

.claude/skills/android-java-to-kotlin/references/CONCURRENCY.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,95 @@ automatically when the owner is destroyed, so no work touches a dead view.
2424
Use `Dispatchers.IO` for disk/network/DB, `Dispatchers.Main` (or `withContext(Main)`) to
2525
touch views.
2626

27+
## `AsyncTask` → Coroutines
28+
29+
`AsyncTask` is deprecated. Map its callbacks onto a single `launch`:
30+
31+
| `AsyncTask` | Coroutine |
32+
|---|---|
33+
| `onPreExecute()` | code before `withContext(IO)` (runs on Main) |
34+
| `doInBackground()` | `withContext(Dispatchers.IO) { ... }` |
35+
| `onPostExecute(result)` | code after, back on `Dispatchers.Main` |
36+
| `isCancelled()` | `isActive` (or `ensureActive()`) |
37+
| `cancel(true)` | `job.cancel()` |
38+
| the `AsyncTask` instance field | the returned `Job` |
39+
40+
A self-contained task class stops extending `AsyncTask` and holds the lifecycle owner so
41+
its scope cancels with the screen:
42+
43+
```kotlin
44+
// BEFORE: class GallerySearchTask extends AsyncTask<Void, Void, Result> { ... }
45+
class GallerySearchTask(
46+
private val fragment: GalleryFragment,
47+
private val user: User,
48+
private val storageManager: FileDataStorageManager,
49+
private val endDate: Long,
50+
private val limit: Int
51+
) {
52+
fun execute(): Job = fragment.lifecycleScope.launch(Dispatchers.IO) {
53+
if (!isActive) return@launch
54+
val context = fragment.context ?: return@launch
55+
val result = performSearch(context)
56+
withContext(Dispatchers.Main) {
57+
fragment.searchCompleted(result.emptySearch, result.lastTimestamp)
58+
}
59+
}
60+
}
61+
```
62+
63+
The caller keeps the `Job` and cancels it with the view:
64+
65+
```kotlin
66+
private var photoSearchTask: Job? = null
67+
// ...
68+
photoSearchTask = GallerySearchTask(this, user, storageManager, endDate, limit).execute()
69+
70+
override fun onDestroyView() {
71+
photoSearchTask?.cancel()
72+
photoSearchTask = null
73+
}
74+
```
75+
76+
Because `lifecycleScope` cancels on destroy, the old `WeakReference<Fragment>` leak-guard
77+
disappears — a direct `fragment` reference plus `fragment.context ?: return@launch` is
78+
enough. (PR #16908)
79+
80+
## `AsyncTask` Behind a Callback API → `runCatching` + `fold`
81+
82+
When a service method hid an `AsyncTask` that set mutable fields (a boolean success flag,
83+
`errorMessage`, result fields) and dispatched them in `onPostExecute`, keep the public
84+
callback signature but drive it from a coroutine. Return an **immutable** result from the
85+
background function and branch with `fold`:
86+
87+
```kotlin
88+
private data class ActivitiesResult(
89+
val activities: List<Any>,
90+
val client: NextcloudClient,
91+
val lastGiven: Long
92+
)
93+
94+
override fun getActivities(lastGiven: Long, callback: ActivitiesServiceCallback) {
95+
scope.launch {
96+
runCatching { withContext(Dispatchers.IO) { fetchActivities(lastGiven) } }
97+
.fold(
98+
onSuccess = { (activities, client, updated) ->
99+
callback.onLoaded(activities, client, updated)
100+
},
101+
onFailure = { callback.onError(it.message ?: "") }
102+
)
103+
}
104+
}
105+
```
106+
107+
`runCatching` / `fold` replaces the boolean-success + `errorMessage`-field idiom; the
108+
background function `fetchActivities` throws on failure instead of returning `false`, and
109+
the immutable `ActivitiesResult` destructures straight into `onSuccess`. (PR #16654)
110+
111+
> **Scope caveat:** that PR launched on an ad-hoc `CoroutineScope(Dispatchers.Main)` with
112+
> no lifecycle owner — it never cancels and can leak. Prefer an injected `CoroutineScope`
113+
> or expose a `suspend` function the presenter runs on `lifecycleScope` / `viewModelScope`.
114+
> Flag ad-hoc scopes in review.
115+
27116
## `new Thread { ... runOnUiThread(...) }``lifecycleScope.launch` + `withContext`
28117

29118
```kotlin
@@ -115,6 +204,9 @@ private suspend fun loadAndPartitionShares(): Pair<List<OCShare>, List<OCShare>>
115204
## Pitfalls
116205

117206
- Never launch on `GlobalScope` — it outlives the screen and leaks.
207+
- An ad-hoc `CoroutineScope(Dispatchers.Main)` created inside a service/presenter has the
208+
same problem: no lifecycle owner, no cancellation, a leak. Use an injected scope or a
209+
`suspend` function the caller runs on `lifecycleScope` / `viewModelScope`.
118210
- Keep `try/catch` semantics: coroutine cancellation throws `CancellationException`;
119211
rethrow it (don't swallow in a broad `catch (e: Exception)` that logs and continues) or
120212
catch `Exception` only around the real work, not around the whole `launch`.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<!--
2+
~ Nextcloud - Android Client
3+
~
4+
~ SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
~ SPDX-License-Identifier: AGPL-3.0-or-later
6+
-->
7+
8+
# Retiring Deprecated Android APIs During Conversion
9+
10+
A Java→Kotlin conversion is the right moment to replace deprecated Android APIs the IDE
11+
converter leaves untouched. Each replacement below is behaviour-preserving. Examples are
12+
real conversions (nextcloud/android PRs #16878, #16792).
13+
14+
## `startActivityForResult` / `onActivityResult` → Activity Result API
15+
16+
The request-code + `onActivityResult` protocol is deprecated. Register a launcher at
17+
construction time and receive the result in its callback.
18+
19+
```kotlin
20+
// BEFORE
21+
startActivityForResult(action, SELECT_LOCATION_REQUEST_CODE)
22+
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
23+
if (requestCode == SELECT_LOCATION_REQUEST_CODE && data != null) { handle(data) }
24+
}
25+
26+
// AFTER
27+
private val folderPickerLauncher = registerForActivityResult(
28+
ActivityResultContracts.StartActivityForResult()
29+
) { result ->
30+
if (result.resultCode == Activity.RESULT_OK) {
31+
handle(result.data)
32+
}
33+
}
34+
35+
// launch it:
36+
folderPickerLauncher.launch(intent)
37+
```
38+
39+
Type-safe, no manual request-code bookkeeping, and it survives process death because
40+
registration is declarative. Register during initialization (a field initializer or
41+
`onCreate`/`onViewCreated`) — never inside a click handler, or the registration is lost.
42+
43+
## `onCreateOptionsMenu` / `onOptionsItemSelected``MenuProvider`
44+
45+
`setHasOptionsMenu(true)` plus the two menu overrides are deprecated on `Fragment`. Add a
46+
`MenuProvider` bound to the view lifecycle instead.
47+
48+
```kotlin
49+
// AFTER
50+
val menuHost: MenuHost = requireActivity()
51+
menuHost.addMenuProvider(object : MenuProvider {
52+
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) =
53+
inflater.inflate(R.menu.gallery_menu, menu)
54+
55+
override fun onMenuItemSelected(item: MenuItem): Boolean = when (item.itemId) {
56+
R.id.action_select_all -> { selectAll(); true }
57+
else -> false
58+
}
59+
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
60+
```
61+
62+
Passing `viewLifecycleOwner` + `Lifecycle.State.RESUMED` auto-adds and removes the menu as
63+
the view's lifecycle changes — no leak, no manual `setHasOptionsMenu`. (PR #16878)
64+
65+
## `java.util.Observable` — Keep or Migrate?
66+
67+
`java.util.Observable` / `Observer` are deprecated (Java 9+). But a conversion is
68+
behaviour-locked, so **do not** silently swap the notification mechanism — existing Java
69+
observers rely on `setChanged()` / `notifyObservers()`. PR #16792 deliberately KEPT it:
70+
71+
```kotlin
72+
class UploadsStorageManager(...) : Observable() {
73+
fun notifyObserversNow() {
74+
Handler(Looper.getMainLooper()).post {
75+
setChanged()
76+
notifyObservers()
77+
}
78+
}
79+
}
80+
```
81+
82+
Migrating to `StateFlow` / `SharedFlow` changes the observation contract and every call
83+
site — that is a separate, opt-in refactor, not part of a 1:1 conversion. Note the
84+
deprecation, propose the flow migration as a follow-up, and keep the current mechanism
85+
unless the developer scopes the larger change.

0 commit comments

Comments
 (0)