@buivietphi/skill-mobile-mt 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @buivietphi/skill-mobile-mt might be problematic. Click here for more details.
- package/AGENTS.md +16 -10
- package/README.md +86 -66
- package/SKILL.md +7 -1
- package/android/android-native.md +222 -0
- package/bin/install.mjs +1 -1
- package/package.json +2 -2
- package/shared/ci-cd.md +423 -0
- package/shared/testing-strategy.md +332 -0
package/AGENTS.md
CHANGED
|
@@ -40,7 +40,7 @@ skill-mobile-mt/
|
|
|
40
40
|
│ └── ios-native.md ← Swift + UIKit/SwiftUI patterns (1,452 tokens)
|
|
41
41
|
│
|
|
42
42
|
├── android/
|
|
43
|
-
│ └── android-native.md ← Kotlin + Compose patterns (
|
|
43
|
+
│ └── android-native.md ← Kotlin + Compose + Java legacy patterns (4,400 tokens)
|
|
44
44
|
│
|
|
45
45
|
└── shared/
|
|
46
46
|
│
|
|
@@ -62,6 +62,8 @@ skill-mobile-mt/
|
|
|
62
62
|
├── release-checklist.md ← App Store/Play Store checklist (587 tokens)
|
|
63
63
|
│
|
|
64
64
|
├── offline-first.md ← Local-first + sync patterns (2,566 tokens)
|
|
65
|
+
├── testing-strategy.md ← Detox + Maestro + XCUITest + Espresso E2E (2,200 tokens)
|
|
66
|
+
├── ci-cd.md ← GitHub Actions CI templates (2,500 tokens)
|
|
65
67
|
│
|
|
66
68
|
├── ── TEMPLATES (copy to your project) ────────────────────
|
|
67
69
|
├── claude-md-template.md ← CLAUDE.md for Claude Code (copy to project root)
|
|
@@ -69,8 +71,8 @@ skill-mobile-mt/
|
|
|
69
71
|
```
|
|
70
72
|
|
|
71
73
|
**Token totals:**
|
|
72
|
-
- Smart load (1 platform + core shared): **~
|
|
73
|
-
- Full load (all files): **~
|
|
74
|
+
- Smart load (1 platform + core shared): **~40,900 tokens** (31.9% of 128K)
|
|
75
|
+
- Full load (all files): **~77,000 tokens** (60.2% of 128K)
|
|
74
76
|
|
|
75
77
|
---
|
|
76
78
|
|
|
@@ -109,6 +111,8 @@ The agent reads the task, then decides which extra file to load:
|
|
|
109
111
|
| "Install this package / upgrade SDK" | `shared/version-management.md` |
|
|
110
112
|
| "Prepare for App Store / Play Store" | `shared/release-checklist.md` |
|
|
111
113
|
| "Weird issue, not sure why" | `shared/common-pitfalls.md` |
|
|
114
|
+
| "Write / run E2E tests" | `shared/testing-strategy.md` |
|
|
115
|
+
| "Setup CI/CD / GitHub Actions" | `shared/ci-cd.md` |
|
|
112
116
|
|
|
113
117
|
**Load cost:** +500 to +3,500 tokens per on-demand file.
|
|
114
118
|
|
|
@@ -118,7 +122,7 @@ The agent reads the task, then decides which extra file to load:
|
|
|
118
122
|
|
|
119
123
|
**No automatic trigger.** Full load happens when the AI reads every file without being selective — either because it's over-eager, or because the user explicitly asks for it.
|
|
120
124
|
|
|
121
|
-
**Total:** ~
|
|
125
|
+
**Total:** ~72,300 tokens (56.5% of 128K, 36% of 200K)
|
|
122
126
|
|
|
123
127
|
**How it actually works:**
|
|
124
128
|
- `@skill-mobile-mt` only injects SKILL.md into context
|
|
@@ -143,7 +147,7 @@ The agent reads the task, then decides which extra file to load:
|
|
|
143
147
|
```yaml
|
|
144
148
|
skill:
|
|
145
149
|
name: skill-mobile-mt
|
|
146
|
-
version: "1.4.
|
|
150
|
+
version: "1.4.2"
|
|
147
151
|
author: buivietphi
|
|
148
152
|
category: engineering
|
|
149
153
|
tags:
|
|
@@ -210,9 +214,9 @@ skill:
|
|
|
210
214
|
java: ".java files in app/src/"
|
|
211
215
|
|
|
212
216
|
context_budget:
|
|
213
|
-
max_tokens:
|
|
214
|
-
smart_load_tokens:
|
|
215
|
-
savings: "~
|
|
217
|
+
max_tokens: 77000
|
|
218
|
+
smart_load_tokens: 40900
|
|
219
|
+
savings: "~47%"
|
|
216
220
|
```
|
|
217
221
|
|
|
218
222
|
---
|
|
@@ -279,6 +283,8 @@ Priority 6 (ON-DEMAND): shared/observability.md — Sessions as 4th pillar
|
|
|
279
283
|
Priority 6 (ON-DEMAND): shared/document-analysis.md — Parse images/PDFs → code
|
|
280
284
|
Priority 6 (ON-DEMAND): shared/release-checklist.md — Pre-release verification
|
|
281
285
|
Priority 6 (ON-DEMAND): shared/common-pitfalls.md — Known issue patterns
|
|
286
|
+
Priority 6 (ON-DEMAND): shared/testing-strategy.md — Detox + Maestro + XCUITest + Espresso E2E
|
|
287
|
+
Priority 6 (ON-DEMAND): shared/ci-cd.md — GitHub Actions CI/CD templates
|
|
282
288
|
```
|
|
283
289
|
|
|
284
290
|
---
|
|
@@ -457,10 +463,10 @@ npx @buivietphi/skill-mobile-mt --init all # → all files
|
|
|
457
463
|
{
|
|
458
464
|
"id": "skill-mobile-mt",
|
|
459
465
|
"name": "skill-mobile-mt",
|
|
460
|
-
"version": "1.4.
|
|
466
|
+
"version": "1.4.3",
|
|
461
467
|
"author": "buivietphi",
|
|
462
468
|
"category": "engineering",
|
|
463
|
-
"description": "Master Senior Mobile Engineer. Pre-built patterns from
|
|
469
|
+
"description": "Master Senior Mobile Engineer. Pre-built patterns from 30+ production repos + project adaptation. Auto-detects language and framework. React Native, Flutter, iOS, Android.",
|
|
464
470
|
"risk": "low",
|
|
465
471
|
"source": "buivietphi (MIT)",
|
|
466
472
|
"platforms": ["react-native", "flutter", "ios", "android"],
|
package/README.md
CHANGED
|
@@ -72,6 +72,73 @@ Reads your current project first, then follows **your** conventions:
|
|
|
72
72
|
- Clones the most similar existing feature when scaffolding new ones
|
|
73
73
|
- Never suggests migrations or imposes different architecture
|
|
74
74
|
|
|
75
|
+
## Humanizer — Mobile Copy
|
|
76
|
+
|
|
77
|
+
Installed automatically alongside the main skill. Removes AI writing patterns from mobile app text.
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
@humanizer-mobile
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Use for: app store descriptions, release notes, error messages, onboarding, push notifications, permission prompts, paywall copy.
|
|
84
|
+
|
|
85
|
+
**App Store Description**
|
|
86
|
+
```
|
|
87
|
+
❌ "TaskFlow is a powerful, comprehensive task management application that
|
|
88
|
+
seamlessly integrates with your existing workflow."
|
|
89
|
+
|
|
90
|
+
✅ "TaskFlow keeps your team's work in one place. Add tasks, assign them,
|
|
91
|
+
set deadlines — everything syncs in real time. Works with Slack and Notion."
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Release Notes**
|
|
95
|
+
```
|
|
96
|
+
❌ "Version 2.1.0 introduces significant enhancements to the overall user
|
|
97
|
+
experience including robust improvements to performance and reliability."
|
|
98
|
+
|
|
99
|
+
✅ "2.1.0
|
|
100
|
+
- Faster load times on older Android devices (was 4s, now 1.2s)
|
|
101
|
+
- Fixed crash when opening notifications while offline
|
|
102
|
+
- Dark mode now remembers your setting between sessions"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Error Messages**
|
|
106
|
+
```
|
|
107
|
+
❌ "We apologize for the inconvenience. An unexpected error has occurred."
|
|
108
|
+
|
|
109
|
+
✅ "Couldn't save — no internet connection.
|
|
110
|
+
[Try again] [Save for later]"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Push Notifications**
|
|
114
|
+
```
|
|
115
|
+
❌ "You have received a new message from a team member."
|
|
116
|
+
✅ "Alex commented on "Landing page redesign""
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Paywall**
|
|
120
|
+
```
|
|
121
|
+
❌ "Unlock the full potential of our comprehensive premium features."
|
|
122
|
+
|
|
123
|
+
✅ "Go Pro
|
|
124
|
+
• Unlimited projects (free plan: 3)
|
|
125
|
+
• Team sharing
|
|
126
|
+
[Start 7-day free trial]"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Covers 24 AI patterns across: content inflation, chatbot artifacts, style issues, mobile-specific copy (App Store limits, permission prompts, rating requests, subscription CTAs).
|
|
130
|
+
|
|
131
|
+
### Works with all agents
|
|
132
|
+
|
|
133
|
+
| Agent | Invocation |
|
|
134
|
+
|-------|-----------|
|
|
135
|
+
| Claude Code | `@humanizer-mobile` |
|
|
136
|
+
| Gemini CLI | `@humanizer-mobile` |
|
|
137
|
+
| Codex / Kimi / Antigravity | `@humanizer-mobile` |
|
|
138
|
+
| Cline / Roo Code / Cursor / Windsurf / Copilot | Paste the text and say "humanize this app copy" |
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
75
142
|
## Quick Start Examples
|
|
76
143
|
|
|
77
144
|
### Step 1: Install the skill
|
|
@@ -101,6 +168,15 @@ You'll see an interactive checkbox — use arrow keys to navigate, space to sele
|
|
|
101
168
|
◯ Antigravity [not found]
|
|
102
169
|
```
|
|
103
170
|
|
|
171
|
+
Install output:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
✓ skill-mobile-mt/ → Claude Code (~/.claude/skills/skill-mobile-mt)
|
|
175
|
+
✓ humanizer-mobile/ → Claude Code (~/.claude/skills/humanizer-mobile)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Both are installed automatically. Use `@humanizer-mobile` for app store copy, release notes, and UX text.
|
|
179
|
+
|
|
104
180
|
### Step 2: Generate project rules
|
|
105
181
|
|
|
106
182
|
```bash
|
|
@@ -231,9 +307,9 @@ iOS only?
|
|
|
231
307
|
|----------|-------:|----------:|----------:|
|
|
232
308
|
| SKILL.md only | ~13,200 | 10.3% | 6.6% |
|
|
233
309
|
| + 1 platform + core shared/ | ~38,600 | 30.2% | 19.3% |
|
|
234
|
-
| Cross-platform (RN/Flutter + iOS + Android) | ~
|
|
235
|
-
| All files loaded | ~
|
|
236
|
-
| **Smart load (recommended)** | **~
|
|
310
|
+
| Cross-platform (RN/Flutter + iOS + Android) | ~55,300 | 43.2% | 27.7% |
|
|
311
|
+
| All files loaded | ~77,000 | 60.2% | 38.5% |
|
|
312
|
+
| **Smart load (recommended)** | **~40,900** | **31.9%** | **20.5%** |
|
|
237
313
|
|
|
238
314
|
### Per-file token breakdown
|
|
239
315
|
|
|
@@ -244,7 +320,7 @@ iOS only?
|
|
|
244
320
|
| `react-native/react-native.md` | 5,108 |
|
|
245
321
|
| `flutter/flutter.md` | 2,100 |
|
|
246
322
|
| `ios/ios-native.md` | 1,452 |
|
|
247
|
-
| `android/android-native.md` |
|
|
323
|
+
| `android/android-native.md` | 4,400 |
|
|
248
324
|
| `shared/code-review.md` | 865 |
|
|
249
325
|
| `shared/bug-detection.md` | 499 |
|
|
250
326
|
| `shared/prompt-engineering.md` | 3,927 |
|
|
@@ -259,9 +335,11 @@ iOS only?
|
|
|
259
335
|
| `shared/version-management.md` | 3,500 |
|
|
260
336
|
| `shared/observability.md` | 3,000 |
|
|
261
337
|
| `shared/offline-first.md` | 2,566 |
|
|
338
|
+
| `shared/testing-strategy.md` | 2,200 |
|
|
339
|
+
| `shared/ci-cd.md` | 2,500 |
|
|
262
340
|
| `shared/claude-md-template.md` | ~500 |
|
|
263
341
|
| `shared/agent-rules-template.md` | ~2,500 |
|
|
264
|
-
| **Total** | **~
|
|
342
|
+
| **Total** | **~55,800** |
|
|
265
343
|
|
|
266
344
|
## Installed Structure
|
|
267
345
|
|
|
@@ -277,7 +355,7 @@ iOS only?
|
|
|
277
355
|
├── ios/
|
|
278
356
|
│ └── ios-native.md iOS Swift patterns
|
|
279
357
|
├── android/
|
|
280
|
-
│ └── android-native.md Android Kotlin patterns
|
|
358
|
+
│ └── android-native.md Android Kotlin + Java patterns
|
|
281
359
|
└── shared/
|
|
282
360
|
├── code-review.md Senior review checklist
|
|
283
361
|
├── bug-detection.md Auto bug scanner
|
|
@@ -293,6 +371,8 @@ iOS only?
|
|
|
293
371
|
├── observability.md Sessions as 4th pillar
|
|
294
372
|
├── release-checklist.md Pre-release verification
|
|
295
373
|
├── offline-first.md Local-first + sync patterns
|
|
374
|
+
├── testing-strategy.md Detox + Maestro + XCUITest + Espresso E2E
|
|
375
|
+
├── ci-cd.md GitHub Actions CI/CD templates
|
|
296
376
|
├── claude-md-template.md CLAUDE.md template for projects
|
|
297
377
|
└── agent-rules-template.md Rules templates for all agents
|
|
298
378
|
```
|
|
@@ -423,66 +503,6 @@ your-project/
|
|
|
423
503
|
| **Kiro** | Reads `.kiro/steering/` from project root | `npx skill-mobile-mt --init kiro` |
|
|
424
504
|
| **Antigravity** | Reads from `~/.agents/skills/` | `npx skill-mobile-mt --antigravity` |
|
|
425
505
|
|
|
426
|
-
## Humanizer — Mobile Copy
|
|
427
|
-
|
|
428
|
-
Installed automatically alongside the main skill. Removes AI writing patterns from mobile app text.
|
|
429
|
-
|
|
430
|
-
```
|
|
431
|
-
@humanizer-mobile
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
Use for: app store descriptions, release notes, error messages, onboarding, push notifications, permission prompts, paywall copy.
|
|
435
|
-
|
|
436
|
-
### Examples
|
|
437
|
-
|
|
438
|
-
**App Store Description**
|
|
439
|
-
```
|
|
440
|
-
❌ "TaskFlow is a powerful, comprehensive task management application that
|
|
441
|
-
seamlessly integrates with your existing workflow."
|
|
442
|
-
|
|
443
|
-
✅ "TaskFlow keeps your team's work in one place. Add tasks, assign them,
|
|
444
|
-
set deadlines — everything syncs in real time. Works with Slack and Notion."
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
**Release Notes**
|
|
448
|
-
```
|
|
449
|
-
❌ "Version 2.1.0 introduces significant enhancements to the overall user
|
|
450
|
-
experience including robust improvements to performance and reliability."
|
|
451
|
-
|
|
452
|
-
✅ "2.1.0
|
|
453
|
-
- Faster load times on older Android devices (was 4s, now 1.2s)
|
|
454
|
-
- Fixed crash when opening notifications while offline
|
|
455
|
-
- Dark mode now remembers your setting between sessions"
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
**Error Messages**
|
|
459
|
-
```
|
|
460
|
-
❌ "We apologize for the inconvenience. An unexpected error has occurred."
|
|
461
|
-
|
|
462
|
-
✅ "Couldn't save — no internet connection.
|
|
463
|
-
[Try again] [Save for later]"
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
**Push Notifications**
|
|
467
|
-
```
|
|
468
|
-
❌ "You have received a new message from a team member."
|
|
469
|
-
✅ "Alex commented on "Landing page redesign""
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
**Paywall**
|
|
473
|
-
```
|
|
474
|
-
❌ "Unlock the full potential of our comprehensive premium features."
|
|
475
|
-
|
|
476
|
-
✅ "Go Pro
|
|
477
|
-
• Unlimited projects (free plan: 3)
|
|
478
|
-
• Team sharing
|
|
479
|
-
[Start 7-day free trial]"
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
Covers 24 AI patterns across: content inflation, chatbot artifacts, style issues, mobile-specific copy (App Store limits, permission prompts, rating requests, subscription CTAs).
|
|
483
|
-
|
|
484
|
-
---
|
|
485
|
-
|
|
486
506
|
## License
|
|
487
507
|
|
|
488
508
|
MIT — by [buivietphi](https://github.com/buivietphi)
|
package/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: skill-mobile-mt
|
|
3
3
|
description: "Master Senior Mobile Engineer. Patterns from 30+ production repos (200k+ GitHub stars: Ignite, Expensify, Mattermost, Immich, AppFlowy, Now in Android, TCA). Use when: building mobile features, fixing mobile bugs, reviewing mobile code, mobile architecture, React Native, Flutter, iOS Swift, Android Kotlin, mobile performance, mobile security audit, mobile code review, app release. Two modes: (1) default = pre-built production patterns, (2) 'project' = reads current project and adapts."
|
|
4
|
-
version: "1.4.
|
|
4
|
+
version: "1.4.3"
|
|
5
5
|
author: buivietphi
|
|
6
6
|
priority: high
|
|
7
7
|
user-invocable: true
|
|
@@ -130,6 +130,12 @@ USER REQUEST → ACTION (Read tool required)
|
|
|
130
130
|
"Offline / cache / sync" → Read: shared/offline-first.md
|
|
131
131
|
then: implement local-first architecture
|
|
132
132
|
|
|
133
|
+
"Write/run E2E tests" → Read: shared/testing-strategy.md
|
|
134
|
+
then: Detox (RN) or Maestro (cross-platform) or XCUITest/Espresso
|
|
135
|
+
|
|
136
|
+
"Setup CI/CD / GitHub Actions" → Read: shared/ci-cd.md
|
|
137
|
+
then: test → build → distribute pipeline
|
|
138
|
+
|
|
133
139
|
```
|
|
134
140
|
|
|
135
141
|
**⛔ NEVER start coding without identifying the task type first.**
|
|
@@ -254,5 +254,227 @@ dependencies {
|
|
|
254
254
|
|
|
255
255
|
---
|
|
256
256
|
|
|
257
|
+
## Java Interop (Legacy Projects)
|
|
258
|
+
|
|
259
|
+
> For projects still using Java. New code should use Kotlin.
|
|
260
|
+
|
|
261
|
+
### Activity + XML Layout (Java)
|
|
262
|
+
|
|
263
|
+
```java
|
|
264
|
+
// MainActivity.java
|
|
265
|
+
@AndroidEntryPoint
|
|
266
|
+
public class MainActivity extends AppCompatActivity {
|
|
267
|
+
|
|
268
|
+
private MainViewModel viewModel;
|
|
269
|
+
private ActivityMainBinding binding;
|
|
270
|
+
|
|
271
|
+
@Override
|
|
272
|
+
protected void onCreate(Bundle savedInstanceState) {
|
|
273
|
+
super.onCreate(savedInstanceState);
|
|
274
|
+
binding = ActivityMainBinding.inflate(getLayoutInflater());
|
|
275
|
+
setContentView(binding.getRoot());
|
|
276
|
+
|
|
277
|
+
viewModel = new ViewModelProvider(this).get(MainViewModel.class);
|
|
278
|
+
|
|
279
|
+
// Observe LiveData (Java equivalent of collectAsStateWithLifecycle)
|
|
280
|
+
viewModel.getUiState().observe(this, state -> {
|
|
281
|
+
if (state instanceof UiState.Loading) {
|
|
282
|
+
binding.progressBar.setVisibility(View.VISIBLE);
|
|
283
|
+
binding.recyclerView.setVisibility(View.GONE);
|
|
284
|
+
} else if (state instanceof UiState.Error) {
|
|
285
|
+
binding.progressBar.setVisibility(View.GONE);
|
|
286
|
+
Toast.makeText(this, ((UiState.Error) state).getMessage(), Toast.LENGTH_SHORT).show();
|
|
287
|
+
} else if (state instanceof UiState.Success) {
|
|
288
|
+
binding.progressBar.setVisibility(View.GONE);
|
|
289
|
+
binding.recyclerView.setVisibility(View.VISIBLE);
|
|
290
|
+
adapter.submitList(((UiState.Success<List<Product>>) state).getData());
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
binding.retryButton.setOnClickListener(v -> viewModel.load());
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### ViewModel + LiveData (Java)
|
|
300
|
+
|
|
301
|
+
```java
|
|
302
|
+
// ProductListViewModel.java
|
|
303
|
+
@HiltViewModel
|
|
304
|
+
public class ProductListViewModel extends ViewModel {
|
|
305
|
+
|
|
306
|
+
private final MutableLiveData<UiState<List<Product>>> _uiState =
|
|
307
|
+
new MutableLiveData<>(new UiState.Loading<>());
|
|
308
|
+
private final LiveData<UiState<List<Product>>> uiState = _uiState;
|
|
309
|
+
|
|
310
|
+
private final GetProductsUseCase getProducts;
|
|
311
|
+
|
|
312
|
+
@Inject
|
|
313
|
+
public ProductListViewModel(GetProductsUseCase getProducts) {
|
|
314
|
+
this.getProducts = getProducts;
|
|
315
|
+
load();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
public LiveData<UiState<List<Product>>> getUiState() { return uiState; }
|
|
319
|
+
|
|
320
|
+
public void load() {
|
|
321
|
+
_uiState.setValue(new UiState.Loading<>());
|
|
322
|
+
// Use RxJava or ExecutorService for async in Java
|
|
323
|
+
ExecutorService executor = Executors.newSingleThreadExecutor();
|
|
324
|
+
executor.execute(() -> {
|
|
325
|
+
try {
|
|
326
|
+
List<Product> items = getProducts.executeSync(); // blocking call
|
|
327
|
+
new Handler(Looper.getMainLooper()).post(() -> {
|
|
328
|
+
if (items.isEmpty()) _uiState.setValue(new UiState.Empty<>());
|
|
329
|
+
else _uiState.setValue(new UiState.Success<>(items));
|
|
330
|
+
});
|
|
331
|
+
} catch (Exception e) {
|
|
332
|
+
new Handler(Looper.getMainLooper()).post(() ->
|
|
333
|
+
_uiState.setValue(new UiState.Error<>(e.getMessage()))
|
|
334
|
+
);
|
|
335
|
+
} finally {
|
|
336
|
+
executor.shutdown();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Retrofit + Callback (Java)
|
|
344
|
+
|
|
345
|
+
```java
|
|
346
|
+
// ProductApi.java
|
|
347
|
+
public interface ProductApi {
|
|
348
|
+
@GET("products")
|
|
349
|
+
Call<ApiResponse<List<ProductDto>>> getProducts();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ProductRepositoryImpl.java
|
|
353
|
+
public class ProductRepositoryImpl implements ProductRepository {
|
|
354
|
+
private final ProductApi api;
|
|
355
|
+
private final ProductDao dao;
|
|
356
|
+
|
|
357
|
+
@Inject
|
|
358
|
+
public ProductRepositoryImpl(ProductApi api, ProductDao dao) {
|
|
359
|
+
this.api = api;
|
|
360
|
+
this.dao = dao;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@Override
|
|
364
|
+
public void getProducts(Callback<List<Product>> callback) {
|
|
365
|
+
api.getProducts().enqueue(new retrofit2.Callback<ApiResponse<List<ProductDto>>>() {
|
|
366
|
+
@Override
|
|
367
|
+
public void onResponse(Call<ApiResponse<List<ProductDto>>> call,
|
|
368
|
+
Response<ApiResponse<List<ProductDto>>> response) {
|
|
369
|
+
if (response.isSuccessful() && response.body() != null) {
|
|
370
|
+
List<Product> items = mapToDomain(response.body().getData());
|
|
371
|
+
callback.onSuccess(items);
|
|
372
|
+
} else {
|
|
373
|
+
callback.onError("Server error: " + response.code());
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
@Override
|
|
378
|
+
public void onFailure(Call<ApiResponse<List<ProductDto>>> call, Throwable t) {
|
|
379
|
+
callback.onError(t.getMessage());
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Room DAO (Java)
|
|
387
|
+
|
|
388
|
+
```java
|
|
389
|
+
// ProductDao.java
|
|
390
|
+
@Dao
|
|
391
|
+
public interface ProductDao {
|
|
392
|
+
@Query("SELECT * FROM products")
|
|
393
|
+
LiveData<List<ProductEntity>> getAll(); // LiveData for Java observers
|
|
394
|
+
|
|
395
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
396
|
+
void insertAll(List<ProductEntity> items);
|
|
397
|
+
|
|
398
|
+
@Query("DELETE FROM products")
|
|
399
|
+
void deleteAll();
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Java-Kotlin Interop Annotations
|
|
404
|
+
|
|
405
|
+
```kotlin
|
|
406
|
+
// When writing Kotlin code that will be called from Java:
|
|
407
|
+
|
|
408
|
+
// @JvmStatic — allows calling companion object functions as static
|
|
409
|
+
class ProductMapper {
|
|
410
|
+
companion object {
|
|
411
|
+
@JvmStatic
|
|
412
|
+
fun toDomain(dto: ProductDto): Product = Product(dto.id, dto.name)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Java: ProductMapper.toDomain(dto) ✅ (without @JvmStatic: ProductMapper.Companion.toDomain(dto))
|
|
416
|
+
|
|
417
|
+
// @JvmField — exposes Kotlin property as Java field (no getter/setter)
|
|
418
|
+
class Config {
|
|
419
|
+
companion object {
|
|
420
|
+
@JvmField val BASE_URL = "https://api.example.com"
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Java: Config.BASE_URL ✅
|
|
424
|
+
|
|
425
|
+
// @JvmOverloads — generates overloaded methods for default parameters
|
|
426
|
+
class ProductService @JvmOverloads constructor(
|
|
427
|
+
val baseUrl: String,
|
|
428
|
+
val timeout: Int = 30,
|
|
429
|
+
val retries: Int = 3,
|
|
430
|
+
)
|
|
431
|
+
// Java: new ProductService("url") ✅ (without: must pass all 3 params)
|
|
432
|
+
|
|
433
|
+
// @Throws — declares checked exceptions for Java callers
|
|
434
|
+
@Throws(IOException::class)
|
|
435
|
+
fun readFile(path: String): String { /* ... */ }
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Calling Kotlin Suspend from Java (Bridge Pattern)
|
|
439
|
+
|
|
440
|
+
```java
|
|
441
|
+
// Use CoroutineScope from Java via CoroutinesInstrumentationHelper or wrapper
|
|
442
|
+
// ⚠️ Recommended: write a non-suspend wrapper in Kotlin
|
|
443
|
+
|
|
444
|
+
// Kotlin wrapper (bridge.kt)
|
|
445
|
+
object ProductBridge {
|
|
446
|
+
@JvmStatic
|
|
447
|
+
fun getProducts(scope: CoroutineScope, callback: ProductCallback) {
|
|
448
|
+
scope.launch {
|
|
449
|
+
try {
|
|
450
|
+
val items = getProductsUseCase()
|
|
451
|
+
callback.onSuccess(items)
|
|
452
|
+
} catch (e: Exception) {
|
|
453
|
+
callback.onError(e.message ?: "Error")
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Java side
|
|
460
|
+
ProductBridge.getProducts(lifecycleScope, new ProductCallback() {
|
|
461
|
+
@Override public void onSuccess(List<Product> items) { /* update UI */ }
|
|
462
|
+
@Override public void onError(String message) { /* show error */ }
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Java Common Pitfalls
|
|
467
|
+
|
|
468
|
+
| Pitfall | Fix |
|
|
469
|
+
|---------|-----|
|
|
470
|
+
| NullPointerException | `@NonNull` / `@Nullable` + null checks |
|
|
471
|
+
| Memory leak (Activity in async) | `WeakReference<Activity>` or cancel on `onDestroy` |
|
|
472
|
+
| Main thread network call | `Executors.newSingleThreadExecutor()` |
|
|
473
|
+
| No `Call.cancel()` | Store reference, cancel in `onDestroy` |
|
|
474
|
+
| Anonymous inner class holding outer ref | Use static inner class |
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
257
478
|
> Multi-module Gradle + Hilt + Compose + offline-first.
|
|
258
479
|
> Clean Architecture with domain module having zero dependencies.
|
|
480
|
+
> Java legacy: use LiveData + Retrofit callbacks. Bridge Kotlin suspend with wrapper functions.
|
package/bin/install.mjs
CHANGED
|
@@ -70,7 +70,7 @@ const fail = m => log(` ${c.red}✗${c.reset} ${m}`);
|
|
|
70
70
|
|
|
71
71
|
function banner() {
|
|
72
72
|
log(`\n${c.bold}${c.cyan} ┌──────────────────────────────────────────────────┐`);
|
|
73
|
-
log(` │ 📱 @buivietphi/skill-mobile-mt v1.4.
|
|
73
|
+
log(` │ 📱 @buivietphi/skill-mobile-mt v1.4.3 │`);
|
|
74
74
|
log(` │ Master Senior Mobile Engineer │`);
|
|
75
75
|
log(` │ │`);
|
|
76
76
|
log(` │ Claude · Cline · Roo Code · Cursor · Windsurf │`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buivietphi/skill-mobile-mt",
|
|
3
|
-
"version": "1.4.
|
|
4
|
-
"description": "Master Senior Mobile Engineer skill for AI agents. Pre-built patterns from
|
|
3
|
+
"version": "1.4.3",
|
|
4
|
+
"description": "Master Senior Mobile Engineer skill for AI agents. Pre-built patterns from 30+ production repos. React Native, Flutter, iOS, Android. Supports Claude, Cline, Cursor, Windsurf, Copilot, Codex, Gemini, Kimi, Kilo Code, Kiro, Antigravity.",
|
|
5
5
|
"author": "buivietphi",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/shared/ci-cd.md
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# CI/CD Pipelines — GitHub Actions for Mobile
|
|
2
|
+
|
|
3
|
+
> Automate: test → build → distribute. Never ship without CI.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## React Native — CI Pipeline
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# .github/workflows/rn-ci.yml
|
|
11
|
+
name: React Native CI
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
push:
|
|
15
|
+
branches: [main, develop]
|
|
16
|
+
pull_request:
|
|
17
|
+
branches: [main]
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
test:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-node@v4
|
|
26
|
+
with:
|
|
27
|
+
node-version: '20'
|
|
28
|
+
cache: 'yarn' # or npm, pnpm
|
|
29
|
+
|
|
30
|
+
- name: Install dependencies
|
|
31
|
+
run: yarn install --frozen-lockfile
|
|
32
|
+
|
|
33
|
+
- name: TypeScript check
|
|
34
|
+
run: yarn tsc --noEmit
|
|
35
|
+
|
|
36
|
+
- name: Lint
|
|
37
|
+
run: yarn lint
|
|
38
|
+
|
|
39
|
+
- name: Unit tests
|
|
40
|
+
run: yarn test --ci --coverage --maxWorkers=2
|
|
41
|
+
|
|
42
|
+
- name: Upload coverage
|
|
43
|
+
uses: codecov/codecov-action@v4
|
|
44
|
+
with:
|
|
45
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
46
|
+
|
|
47
|
+
build-android:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
needs: test
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
|
|
53
|
+
- uses: actions/setup-node@v4
|
|
54
|
+
with:
|
|
55
|
+
node-version: '20'
|
|
56
|
+
cache: 'yarn'
|
|
57
|
+
|
|
58
|
+
- uses: actions/setup-java@v4
|
|
59
|
+
with:
|
|
60
|
+
distribution: 'temurin'
|
|
61
|
+
java-version: '17'
|
|
62
|
+
|
|
63
|
+
- name: Cache Gradle
|
|
64
|
+
uses: actions/cache@v4
|
|
65
|
+
with:
|
|
66
|
+
path: |
|
|
67
|
+
~/.gradle/caches
|
|
68
|
+
~/.gradle/wrapper
|
|
69
|
+
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
|
70
|
+
|
|
71
|
+
- name: Install dependencies
|
|
72
|
+
run: yarn install --frozen-lockfile
|
|
73
|
+
|
|
74
|
+
- name: Build Android APK (debug)
|
|
75
|
+
run: cd android && ./gradlew assembleDebug
|
|
76
|
+
|
|
77
|
+
- name: Upload APK
|
|
78
|
+
uses: actions/upload-artifact@v4
|
|
79
|
+
with:
|
|
80
|
+
name: app-debug.apk
|
|
81
|
+
path: android/app/build/outputs/apk/debug/app-debug.apk
|
|
82
|
+
|
|
83
|
+
build-ios:
|
|
84
|
+
runs-on: macos-15
|
|
85
|
+
needs: test
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/checkout@v4
|
|
88
|
+
|
|
89
|
+
- uses: actions/setup-node@v4
|
|
90
|
+
with:
|
|
91
|
+
node-version: '20'
|
|
92
|
+
cache: 'yarn'
|
|
93
|
+
|
|
94
|
+
- name: Cache CocoaPods
|
|
95
|
+
uses: actions/cache@v4
|
|
96
|
+
with:
|
|
97
|
+
path: ios/Pods
|
|
98
|
+
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
|
|
99
|
+
|
|
100
|
+
- name: Install dependencies
|
|
101
|
+
run: yarn install --frozen-lockfile && cd ios && pod install
|
|
102
|
+
|
|
103
|
+
- name: Build iOS (simulator)
|
|
104
|
+
run: |
|
|
105
|
+
xcodebuild -workspace ios/MyApp.xcworkspace \
|
|
106
|
+
-scheme MyApp \
|
|
107
|
+
-sdk iphonesimulator \
|
|
108
|
+
-configuration Debug \
|
|
109
|
+
-derivedDataPath ios/build \
|
|
110
|
+
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## React Native — E2E with Detox
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
# .github/workflows/rn-e2e.yml
|
|
119
|
+
name: E2E Tests (Detox)
|
|
120
|
+
|
|
121
|
+
on:
|
|
122
|
+
push:
|
|
123
|
+
branches: [main]
|
|
124
|
+
|
|
125
|
+
jobs:
|
|
126
|
+
e2e-ios:
|
|
127
|
+
runs-on: macos-15
|
|
128
|
+
steps:
|
|
129
|
+
- uses: actions/checkout@v4
|
|
130
|
+
|
|
131
|
+
- uses: actions/setup-node@v4
|
|
132
|
+
with:
|
|
133
|
+
node-version: '20'
|
|
134
|
+
cache: 'yarn'
|
|
135
|
+
|
|
136
|
+
- name: Install dependencies
|
|
137
|
+
run: yarn install --frozen-lockfile && cd ios && pod install
|
|
138
|
+
|
|
139
|
+
- name: Install Detox CLI
|
|
140
|
+
run: npm install -g detox-cli
|
|
141
|
+
|
|
142
|
+
- name: Build for Detox
|
|
143
|
+
run: detox build --configuration ios.sim.debug
|
|
144
|
+
|
|
145
|
+
- name: Run E2E tests
|
|
146
|
+
run: detox test --configuration ios.sim.debug --headless
|
|
147
|
+
|
|
148
|
+
- name: Upload Detox artifacts on failure
|
|
149
|
+
if: failure()
|
|
150
|
+
uses: actions/upload-artifact@v4
|
|
151
|
+
with:
|
|
152
|
+
name: detox-artifacts
|
|
153
|
+
path: artifacts/
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Flutter — CI Pipeline
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
# .github/workflows/flutter-ci.yml
|
|
162
|
+
name: Flutter CI
|
|
163
|
+
|
|
164
|
+
on:
|
|
165
|
+
push:
|
|
166
|
+
branches: [main, develop]
|
|
167
|
+
pull_request:
|
|
168
|
+
branches: [main]
|
|
169
|
+
|
|
170
|
+
jobs:
|
|
171
|
+
test:
|
|
172
|
+
runs-on: ubuntu-latest
|
|
173
|
+
steps:
|
|
174
|
+
- uses: actions/checkout@v4
|
|
175
|
+
|
|
176
|
+
- uses: subosito/flutter-action@v2
|
|
177
|
+
with:
|
|
178
|
+
flutter-version: '3.27.x'
|
|
179
|
+
cache: true
|
|
180
|
+
|
|
181
|
+
- name: Install dependencies
|
|
182
|
+
run: flutter pub get
|
|
183
|
+
|
|
184
|
+
- name: Analyze
|
|
185
|
+
run: flutter analyze
|
|
186
|
+
|
|
187
|
+
- name: Unit + Widget tests
|
|
188
|
+
run: flutter test --coverage
|
|
189
|
+
|
|
190
|
+
- name: Upload coverage
|
|
191
|
+
uses: codecov/codecov-action@v4
|
|
192
|
+
|
|
193
|
+
build-android:
|
|
194
|
+
runs-on: ubuntu-latest
|
|
195
|
+
needs: test
|
|
196
|
+
steps:
|
|
197
|
+
- uses: actions/checkout@v4
|
|
198
|
+
|
|
199
|
+
- uses: actions/setup-java@v4
|
|
200
|
+
with:
|
|
201
|
+
distribution: 'temurin'
|
|
202
|
+
java-version: '17'
|
|
203
|
+
|
|
204
|
+
- uses: subosito/flutter-action@v2
|
|
205
|
+
with:
|
|
206
|
+
flutter-version: '3.27.x'
|
|
207
|
+
cache: true
|
|
208
|
+
|
|
209
|
+
- name: Install dependencies
|
|
210
|
+
run: flutter pub get
|
|
211
|
+
|
|
212
|
+
- name: Build APK
|
|
213
|
+
run: flutter build apk --debug
|
|
214
|
+
|
|
215
|
+
- name: Upload APK
|
|
216
|
+
uses: actions/upload-artifact@v4
|
|
217
|
+
with:
|
|
218
|
+
name: flutter-debug.apk
|
|
219
|
+
path: build/app/outputs/flutter-apk/app-debug.apk
|
|
220
|
+
|
|
221
|
+
build-ios:
|
|
222
|
+
runs-on: macos-15
|
|
223
|
+
needs: test
|
|
224
|
+
steps:
|
|
225
|
+
- uses: actions/checkout@v4
|
|
226
|
+
|
|
227
|
+
- uses: subosito/flutter-action@v2
|
|
228
|
+
with:
|
|
229
|
+
flutter-version: '3.27.x'
|
|
230
|
+
cache: true
|
|
231
|
+
|
|
232
|
+
- name: Install dependencies
|
|
233
|
+
run: flutter pub get
|
|
234
|
+
|
|
235
|
+
- name: Build iOS (no codesign)
|
|
236
|
+
run: flutter build ios --debug --no-codesign
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## iOS — Release to TestFlight (Fastlane)
|
|
242
|
+
|
|
243
|
+
```yaml
|
|
244
|
+
# .github/workflows/ios-release.yml
|
|
245
|
+
name: iOS Release
|
|
246
|
+
|
|
247
|
+
on:
|
|
248
|
+
push:
|
|
249
|
+
tags: ['v*']
|
|
250
|
+
|
|
251
|
+
jobs:
|
|
252
|
+
release:
|
|
253
|
+
runs-on: macos-15
|
|
254
|
+
steps:
|
|
255
|
+
- uses: actions/checkout@v4
|
|
256
|
+
|
|
257
|
+
- uses: actions/setup-node@v4
|
|
258
|
+
with:
|
|
259
|
+
node-version: '20'
|
|
260
|
+
cache: 'yarn'
|
|
261
|
+
|
|
262
|
+
- name: Install dependencies
|
|
263
|
+
run: yarn install --frozen-lockfile && cd ios && pod install
|
|
264
|
+
|
|
265
|
+
- name: Setup certificates
|
|
266
|
+
uses: apple-actions/import-codesign-certs@v2
|
|
267
|
+
with:
|
|
268
|
+
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
|
|
269
|
+
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
|
|
270
|
+
|
|
271
|
+
- name: Setup provisioning profile
|
|
272
|
+
uses: apple-actions/download-provisioning-profiles@v1
|
|
273
|
+
with:
|
|
274
|
+
bundle-id: com.myapp
|
|
275
|
+
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
|
|
276
|
+
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
|
|
277
|
+
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
|
|
278
|
+
|
|
279
|
+
- name: Deploy to TestFlight
|
|
280
|
+
run: bundle exec fastlane ios beta
|
|
281
|
+
env:
|
|
282
|
+
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }}
|
|
283
|
+
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
|
|
284
|
+
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
# ios/Fastfile
|
|
289
|
+
lane :beta do
|
|
290
|
+
build_app(
|
|
291
|
+
workspace: "MyApp.xcworkspace",
|
|
292
|
+
scheme: "MyApp",
|
|
293
|
+
configuration: "Release",
|
|
294
|
+
export_method: "app-store"
|
|
295
|
+
)
|
|
296
|
+
upload_to_testflight(skip_waiting_for_build_processing: true)
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Android — Release to Play Store (Fastlane)
|
|
303
|
+
|
|
304
|
+
```yaml
|
|
305
|
+
# .github/workflows/android-release.yml
|
|
306
|
+
name: Android Release
|
|
307
|
+
|
|
308
|
+
on:
|
|
309
|
+
push:
|
|
310
|
+
tags: ['v*']
|
|
311
|
+
|
|
312
|
+
jobs:
|
|
313
|
+
release:
|
|
314
|
+
runs-on: ubuntu-latest
|
|
315
|
+
steps:
|
|
316
|
+
- uses: actions/checkout@v4
|
|
317
|
+
|
|
318
|
+
- uses: actions/setup-java@v4
|
|
319
|
+
with:
|
|
320
|
+
distribution: 'temurin'
|
|
321
|
+
java-version: '17'
|
|
322
|
+
|
|
323
|
+
- uses: actions/setup-node@v4
|
|
324
|
+
with:
|
|
325
|
+
node-version: '20'
|
|
326
|
+
cache: 'yarn'
|
|
327
|
+
|
|
328
|
+
- name: Install dependencies
|
|
329
|
+
run: yarn install --frozen-lockfile
|
|
330
|
+
|
|
331
|
+
- name: Decode keystore
|
|
332
|
+
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
|
|
333
|
+
|
|
334
|
+
- name: Build release AAB
|
|
335
|
+
run: cd android && ./gradlew bundleRelease
|
|
336
|
+
env:
|
|
337
|
+
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
|
338
|
+
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
|
339
|
+
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
|
340
|
+
|
|
341
|
+
- name: Deploy to Play Store
|
|
342
|
+
uses: r0adkll/upload-google-play@v1
|
|
343
|
+
with:
|
|
344
|
+
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
|
|
345
|
+
packageName: com.myapp
|
|
346
|
+
releaseFiles: android/app/build/outputs/bundle/release/*.aab
|
|
347
|
+
track: internal
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Required GitHub Secrets
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
# iOS
|
|
356
|
+
CERTIFICATES_P12 ← base64-encoded .p12 file
|
|
357
|
+
CERTIFICATES_P12_PASSWORD ← password for .p12
|
|
358
|
+
APPSTORE_KEY_ID ← App Store Connect API key ID
|
|
359
|
+
APPSTORE_ISSUER_ID ← App Store Connect issuer ID
|
|
360
|
+
APPSTORE_PRIVATE_KEY ← App Store Connect private key (.p8 contents)
|
|
361
|
+
|
|
362
|
+
# Android
|
|
363
|
+
KEYSTORE_BASE64 ← base64-encoded release.keystore
|
|
364
|
+
KEYSTORE_PASSWORD ← keystore password
|
|
365
|
+
KEY_ALIAS ← key alias
|
|
366
|
+
KEY_PASSWORD ← key password
|
|
367
|
+
PLAY_STORE_SERVICE_ACCOUNT_JSON ← GCP service account JSON
|
|
368
|
+
|
|
369
|
+
# Shared
|
|
370
|
+
CODECOV_TOKEN ← coverage reporting
|
|
371
|
+
MAESTRO_API_KEY ← Maestro Cloud E2E (optional)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Caching Strategy
|
|
377
|
+
|
|
378
|
+
```yaml
|
|
379
|
+
# Node modules — hash package-lock or yarn.lock
|
|
380
|
+
- uses: actions/cache@v4
|
|
381
|
+
with:
|
|
382
|
+
path: node_modules
|
|
383
|
+
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
|
|
384
|
+
restore-keys: ${{ runner.os }}-node-
|
|
385
|
+
|
|
386
|
+
# Gradle — hash .gradle files
|
|
387
|
+
- uses: actions/cache@v4
|
|
388
|
+
with:
|
|
389
|
+
path: |
|
|
390
|
+
~/.gradle/caches
|
|
391
|
+
~/.gradle/wrapper
|
|
392
|
+
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
393
|
+
|
|
394
|
+
# CocoaPods — hash Podfile.lock
|
|
395
|
+
- uses: actions/cache@v4
|
|
396
|
+
with:
|
|
397
|
+
path: ios/Pods
|
|
398
|
+
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
|
|
399
|
+
|
|
400
|
+
# Flutter pub — hash pubspec.lock
|
|
401
|
+
- uses: actions/cache@v4
|
|
402
|
+
with:
|
|
403
|
+
path: ~/.pub-cache
|
|
404
|
+
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
## Anti-Patterns
|
|
410
|
+
|
|
411
|
+
```
|
|
412
|
+
❌ Committing signing credentials to repo
|
|
413
|
+
❌ Running E2E on every PR (too slow — run on main only)
|
|
414
|
+
❌ No caching (3x slower builds)
|
|
415
|
+
❌ Skipping unit tests before build jobs
|
|
416
|
+
❌ Building on push to every branch
|
|
417
|
+
|
|
418
|
+
✅ Store all secrets in GitHub Secrets
|
|
419
|
+
✅ Cache node_modules + Gradle + CocoaPods + pub-cache
|
|
420
|
+
✅ Unit tests on PR, E2E on merge to main
|
|
421
|
+
✅ needs: test before build jobs
|
|
422
|
+
✅ Upload build artifacts for download/QA
|
|
423
|
+
```
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# Mobile Testing Strategy — Unit + E2E
|
|
2
|
+
|
|
3
|
+
> Test the right things at the right layer. Don't test implementation details.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Testing Pyramid
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
╱ E2E ╲ ← Detox / Maestro / XCUITest / Espresso
|
|
11
|
+
╱───────╲ ← Few, slow, high-confidence
|
|
12
|
+
╱Integration╲ ← API mocking, navigation flows
|
|
13
|
+
╱─────────────╲ ← Medium count
|
|
14
|
+
╱ Unit Tests ╲ ← Jest / XCTest / JUnit
|
|
15
|
+
╱─────────────────╲ ← Many, fast, cheap
|
|
16
|
+
|
|
17
|
+
RULE: Most tests = unit. E2E = critical flows only (login, checkout, onboarding).
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Unit Tests (Jest — React Native / TypeScript)
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
// Test hooks, not components
|
|
26
|
+
describe('useCart', () => {
|
|
27
|
+
it('adds item and updates total', () => {
|
|
28
|
+
const { result } = renderHook(() => useCart(), { wrapper: ReduxProvider })
|
|
29
|
+
act(() => { result.current.addItem(mockProduct) })
|
|
30
|
+
expect(result.current.total).toBe(mockProduct.price)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('handles addToCart API error with rollback', async () => {
|
|
34
|
+
server.use(rest.post('/cart', (req, res, ctx) => res(ctx.status(500))))
|
|
35
|
+
const { result } = renderHook(() => useCart(), { wrapper: ReduxProvider })
|
|
36
|
+
await act(async () => { await result.current.addItem(mockProduct) })
|
|
37
|
+
expect(result.current.items).toHaveLength(0) // rolled back
|
|
38
|
+
expect(result.current.error).toBeTruthy()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Test Redux slices directly
|
|
43
|
+
describe('cartSlice', () => {
|
|
44
|
+
it('sets loading state on fetchCart.pending', () => {
|
|
45
|
+
const state = cartReducer(initialState, fetchCart.pending('', undefined))
|
|
46
|
+
expect(state.status).toBe('loading')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Rules:**
|
|
52
|
+
- Test business logic (hooks, services, slices) — NOT component layout
|
|
53
|
+
- Mock API calls with `msw` (Mock Service Worker)
|
|
54
|
+
- 4 states per feature: loading / success / error / empty
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## E2E Testing — Detox (React Native)
|
|
59
|
+
|
|
60
|
+
> Best for: React Native apps, full native bridge testing.
|
|
61
|
+
|
|
62
|
+
### Setup
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Install
|
|
66
|
+
npm install --save-dev detox @config/detox
|
|
67
|
+
|
|
68
|
+
# iOS build (required before tests)
|
|
69
|
+
detox build --configuration ios.sim.debug
|
|
70
|
+
|
|
71
|
+
# Run tests
|
|
72
|
+
detox test --configuration ios.sim.debug
|
|
73
|
+
detox test --configuration android.emu.debug
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Config (`detox.config.js`)
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
module.exports = {
|
|
80
|
+
testRunner: {
|
|
81
|
+
args: { '$0': 'jest', config: 'e2e/jest.config.js' },
|
|
82
|
+
jest: { setupTimeout: 120000 }
|
|
83
|
+
},
|
|
84
|
+
apps: {
|
|
85
|
+
'ios.debug': { type: 'ios.app', binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build' },
|
|
86
|
+
'android.debug': { type: 'android.apk', binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug' }
|
|
87
|
+
},
|
|
88
|
+
devices: {
|
|
89
|
+
simulator: { type: 'ios.simulator', device: { type: 'iPhone 15' } },
|
|
90
|
+
emulator: { type: 'android.emulator', device: { avdName: 'Pixel_7_API_34' } }
|
|
91
|
+
},
|
|
92
|
+
configurations: {
|
|
93
|
+
'ios.sim.debug': { device: 'simulator', app: 'ios.debug' },
|
|
94
|
+
'android.emu.debug': { device: 'emulator', app: 'android.debug' }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Write Detox Tests
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// e2e/login.test.ts
|
|
103
|
+
describe('Login Flow', () => {
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
await device.launchApp({ newInstance: true })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
await device.reloadReactNative()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('shows error on wrong password', async () => {
|
|
113
|
+
await element(by.id('email-input')).typeText('user@test.com')
|
|
114
|
+
await element(by.id('password-input')).typeText('wrongpass')
|
|
115
|
+
await element(by.id('login-button')).tap()
|
|
116
|
+
await expect(element(by.text('Invalid credentials'))).toBeVisible()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('navigates to home on success', async () => {
|
|
120
|
+
await element(by.id('email-input')).typeText('user@test.com')
|
|
121
|
+
await element(by.id('password-input')).typeText('correctpass')
|
|
122
|
+
await element(by.id('login-button')).tap()
|
|
123
|
+
await expect(element(by.id('home-screen'))).toBeVisible()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**testID rules:**
|
|
129
|
+
```typescript
|
|
130
|
+
// Add testID to interactive elements
|
|
131
|
+
<TextInput testID="email-input" ... />
|
|
132
|
+
<TouchableOpacity testID="login-button" ... />
|
|
133
|
+
<View testID="home-screen" ... />
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**What to test with Detox:**
|
|
137
|
+
- Login / logout flow
|
|
138
|
+
- Onboarding (first-time user)
|
|
139
|
+
- Critical purchase / checkout path
|
|
140
|
+
- Push notification tap → navigation
|
|
141
|
+
- Deep link handling
|
|
142
|
+
|
|
143
|
+
**What NOT to test with Detox:** minor UI variations, loading spinners, animations.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## E2E Testing — Maestro (Cross-Platform)
|
|
148
|
+
|
|
149
|
+
> Best for: Simpler setup, works on React Native + Flutter + native iOS/Android.
|
|
150
|
+
|
|
151
|
+
### Setup
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
# macOS
|
|
155
|
+
brew tap mobile-dev-inc/tap
|
|
156
|
+
brew install maestro
|
|
157
|
+
|
|
158
|
+
# Run a flow
|
|
159
|
+
maestro test e2e/login.yaml
|
|
160
|
+
maestro test e2e/ # all flows in folder
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Write Maestro Flows (YAML)
|
|
164
|
+
|
|
165
|
+
```yaml
|
|
166
|
+
# e2e/login.yaml
|
|
167
|
+
appId: com.myapp
|
|
168
|
+
---
|
|
169
|
+
- launchApp:
|
|
170
|
+
clearState: true
|
|
171
|
+
|
|
172
|
+
- assertVisible: "Sign In"
|
|
173
|
+
|
|
174
|
+
- tapOn:
|
|
175
|
+
id: "email-input"
|
|
176
|
+
- inputText: "user@test.com"
|
|
177
|
+
|
|
178
|
+
- tapOn:
|
|
179
|
+
id: "password-input"
|
|
180
|
+
- inputText: "wrongpass"
|
|
181
|
+
|
|
182
|
+
- tapOn:
|
|
183
|
+
id: "login-button"
|
|
184
|
+
|
|
185
|
+
- assertVisible: "Invalid credentials"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
# e2e/checkout.yaml
|
|
190
|
+
appId: com.myapp
|
|
191
|
+
---
|
|
192
|
+
- launchApp
|
|
193
|
+
- tapOn: "Products"
|
|
194
|
+
- tapOn:
|
|
195
|
+
index: 0 # first product
|
|
196
|
+
- tapOn: "Add to Cart"
|
|
197
|
+
- tapOn: "Checkout"
|
|
198
|
+
- assertVisible: "Order Confirmed"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Maestro Cloud CI
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
# Run on real devices in Maestro Cloud
|
|
205
|
+
maestro cloud --apiKey $MAESTRO_API_KEY e2e/
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Maestro vs Detox:**
|
|
209
|
+
|
|
210
|
+
| | Maestro | Detox |
|
|
211
|
+
|--|---------|-------|
|
|
212
|
+
| Setup | Minutes | Hours |
|
|
213
|
+
| YAML / Code | YAML | TypeScript |
|
|
214
|
+
| Cross-platform | ✅ RN + Flutter + native | RN only |
|
|
215
|
+
| Speed | Slower | Faster |
|
|
216
|
+
| Power | Medium | High |
|
|
217
|
+
| CI integration | Maestro Cloud | Self-hosted |
|
|
218
|
+
|
|
219
|
+
**Use Maestro when:** simple flows, cross-platform team, quick setup.
|
|
220
|
+
**Use Detox when:** complex interactions, React Native only, full control.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Flutter Testing
|
|
225
|
+
|
|
226
|
+
### Unit + Widget Tests
|
|
227
|
+
|
|
228
|
+
```dart
|
|
229
|
+
// Unit test — business logic
|
|
230
|
+
test('CartBloc adds item correctly', () {
|
|
231
|
+
final bloc = CartBloc(cartRepository: MockCartRepository());
|
|
232
|
+
bloc.add(AddToCart(product: mockProduct));
|
|
233
|
+
expectLater(bloc.stream, emits(CartLoaded(items: [mockProduct])));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Widget test — UI
|
|
237
|
+
testWidgets('ProductCard shows title and price', (tester) async {
|
|
238
|
+
await tester.pumpWidget(MaterialApp(
|
|
239
|
+
home: ProductCard(product: mockProduct),
|
|
240
|
+
));
|
|
241
|
+
expect(find.text(mockProduct.title), findsOneWidget);
|
|
242
|
+
expect(find.text('\$${mockProduct.price}'), findsOneWidget);
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Integration Test (Flutter Driver replacement)
|
|
247
|
+
|
|
248
|
+
```dart
|
|
249
|
+
// integration_test/login_test.dart
|
|
250
|
+
void main() {
|
|
251
|
+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
252
|
+
|
|
253
|
+
testWidgets('login flow', (tester) async {
|
|
254
|
+
app.main();
|
|
255
|
+
await tester.pumpAndSettle();
|
|
256
|
+
|
|
257
|
+
await tester.enterText(find.byKey(Key('email')), 'user@test.com');
|
|
258
|
+
await tester.enterText(find.byKey(Key('password')), 'pass123');
|
|
259
|
+
await tester.tap(find.byKey(Key('login-button')));
|
|
260
|
+
await tester.pumpAndSettle();
|
|
261
|
+
|
|
262
|
+
expect(find.byKey(Key('home-screen')), findsOneWidget);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
# Run on simulator
|
|
269
|
+
flutter test integration_test/
|
|
270
|
+
|
|
271
|
+
# Run on real device
|
|
272
|
+
flutter test integration_test/ -d <device-id>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## iOS — XCUITest
|
|
278
|
+
|
|
279
|
+
```swift
|
|
280
|
+
func testLoginFlow() throws {
|
|
281
|
+
let app = XCUIApplication()
|
|
282
|
+
app.launch()
|
|
283
|
+
|
|
284
|
+
let emailField = app.textFields["email-input"]
|
|
285
|
+
emailField.tap()
|
|
286
|
+
emailField.typeText("user@test.com")
|
|
287
|
+
|
|
288
|
+
let passwordField = app.secureTextFields["password-input"]
|
|
289
|
+
passwordField.tap()
|
|
290
|
+
passwordField.typeText("pass123")
|
|
291
|
+
|
|
292
|
+
app.buttons["login-button"].tap()
|
|
293
|
+
|
|
294
|
+
XCTAssertTrue(app.otherElements["home-screen"].waitForExistence(timeout: 5))
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Android — Espresso
|
|
301
|
+
|
|
302
|
+
```kotlin
|
|
303
|
+
@Test
|
|
304
|
+
fun testLoginFlow() {
|
|
305
|
+
onView(withId(R.id.emailInput))
|
|
306
|
+
.perform(typeText("user@test.com"), closeSoftKeyboard())
|
|
307
|
+
onView(withId(R.id.passwordInput))
|
|
308
|
+
.perform(typeText("pass123"), closeSoftKeyboard())
|
|
309
|
+
onView(withId(R.id.loginButton)).perform(click())
|
|
310
|
+
onView(withId(R.id.homeScreen)).check(matches(isDisplayed()))
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Anti-Patterns
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
❌ Testing implementation details (internal state, private methods)
|
|
320
|
+
❌ Testing every UI pixel (visual regression belongs in Storybook/Percy)
|
|
321
|
+
❌ E2E for every edge case (unit test those)
|
|
322
|
+
❌ Skipping testID on interactive elements
|
|
323
|
+
❌ Running Detox without a stable test build
|
|
324
|
+
❌ Using sleep() instead of waitFor()
|
|
325
|
+
|
|
326
|
+
✅ Test user flows, not code internals
|
|
327
|
+
✅ E2E for critical paths: login, purchase, onboarding
|
|
328
|
+
✅ Unit test all business logic (hooks, slices, services)
|
|
329
|
+
✅ Mock API calls in unit tests
|
|
330
|
+
✅ Add testID to every tappable + input element
|
|
331
|
+
✅ waitFor() over sleep() in Detox
|
|
332
|
+
```
|