Files
ftdl/docs/plans/2026-03-04-apk-parsing-fix.md
tech 1eba2f5f39 chore: update env example and gitignore, add implementation plan
- Update .env.example with documentation and default values
- Add .resources to gitignore (dummy test files)
- Add APK parsing fix implementation plan document

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:44 +08:00

533 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# APK Parsing Fix Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix APK filename parsing to use version number as delimiter and support configurable environment tabs.
**Architecture:** Parse APK filenames by splitting on `_<version>` to extract prefix. If prefix has no underscore → product. If prefix has underscore → extract environment from last segment. Match against configurable tabs (env var), unmatched → other.
**Tech Stack:** Next.js, TypeScript, environment variables
---
### Task 1: Add APK_TABS Environment Variable
**Files:**
- Modify: `lib/constants.ts`
**Step 1: Add APK_TABS constant**
```typescript
// Add to lib/constants.ts
export const APK_TABS = (process.env.APK_TABS || 'dev,sandbox')
.split(',')
.map(t => t.trim())
.filter(Boolean);
```
**Step 2: Verify no build errors**
Run: `npm run build`
Expected: Build succeeds
**Step 3: Commit**
```bash
git add lib/constants.ts
git commit -m "feat: add APK_TABS environment variable for configurable tabs"
```
---
### Task 2: Rewrite APK Parser Logic
**Files:**
- Modify: `lib/apk-parser.ts`
- Modify: `lib/__tests__/apk-parser.test.ts`
**Step 1: Write failing tests for new parsing logic**
Replace `lib/__tests__/apk-parser.test.ts`:
```typescript
import { parseApkFilename, detectEnvironment, getApkTabs } from '../apk-parser';
describe('parseApkFilename', () => {
it('should parse product APK (no environment)', () => {
const result = parseApkFilename('ft_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('product');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should parse dev APK', () => {
const result = parseApkFilename('ft_dev_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('dev');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should parse sandbox APK', () => {
const result = parseApkFilename('ft_sandbox_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('sandbox');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should map unknown environment to other', () => {
const result = parseApkFilename('ft_lan_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('other');
expect(result.commit).toBe('bfeddf2d0');
});
it('should map timeshift to other', () => {
const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk', '0_42');
expect(result.environment).toBe('other');
});
it('should handle invalid format without commit', () => {
const result = parseApkFilename('ft_dev_0_44.apk', '0_44');
expect(result.environment).toBe('dev');
expect(result.commit).toBeNull();
expect(result.isValid).toBe(false);
});
it('should handle completely invalid filename', () => {
const result = parseApkFilename('invalid_filename.apk', '0_44');
expect(result.environment).toBe('other');
expect(result.commit).toBeNull();
expect(result.isValid).toBe(false);
});
});
describe('detectEnvironment', () => {
it('should detect product when no underscore in prefix', () => {
expect(detectEnvironment('ft', ['dev', 'sandbox'])).toBe('product');
});
it('should detect configured environment', () => {
expect(detectEnvironment('ft_dev', ['dev', 'sandbox'])).toBe('dev');
expect(detectEnvironment('ft_sandbox', ['dev', 'sandbox'])).toBe('sandbox');
});
it('should return other for unconfigured environment', () => {
expect(detectEnvironment('ft_lan', ['dev', 'sandbox'])).toBe('other');
expect(detectEnvironment('ft_timeshift', ['dev', 'sandbox'])).toBe('other');
});
it('should return other for empty prefix', () => {
expect(detectEnvironment('', ['dev', 'sandbox'])).toBe('other');
});
});
```
**Step 2: Run tests to verify they fail**
Run: `npm test -- lib/__tests__/apk-parser.test.ts`
Expected: Tests fail with missing `getApkTabs` and wrong behavior
**Step 3: Implement new parsing logic**
Replace `lib/apk-parser.ts`:
```typescript
import { APK_TABS } from './constants';
export type ApkEnvironment = 'product' | 'dev' | 'sandbox' | 'other';
export interface ParsedApkMetadata {
environment: ApkEnvironment;
commit: string | null;
isValid: boolean;
}
export function getApkTabs(): string[] {
return APK_TABS;
}
export function detectEnvironment(prefix: string, configuredTabs: string[]): ApkEnvironment {
// Empty prefix → other
if (!prefix) {
return 'other';
}
// No underscore means product (e.g., "ft")
if (!prefix.includes('_')) {
return 'product';
}
// Extract environment from last segment after underscore
const lastUnderscoreIndex = prefix.lastIndexOf('_');
const env = prefix.substring(lastUnderscoreIndex + 1);
// Check if environment is configured
if (configuredTabs.includes(env)) {
return env as ApkEnvironment;
}
return 'other';
}
export function parseApkFilename(filename: string, version: string): ParsedApkMetadata {
// Use version as delimiter (with leading underscore)
const delimiter = `_${version}`;
const delimiterIndex = filename.indexOf(delimiter);
let prefix: string;
if (delimiterIndex > 0) {
prefix = filename.substring(0, delimiterIndex);
} else {
// Fallback: can't find version delimiter
prefix = '';
}
const environment = detectEnvironment(prefix, APK_TABS);
// Extract commit ID
const commitMatch = filename.match(/([a-f0-9]{7,})\.apk$/);
const commit = commitMatch ? commitMatch[1] : null;
return {
environment,
commit,
isValid: commit !== null,
};
}
```
**Step 4: Run tests to verify they pass**
Run: `npm test -- lib/__tests__/apk-parser.test.ts`
Expected: All tests pass
**Step 5: Commit**
```bash
git add lib/apk-parser.ts lib/__tests__/apk-parser.test.ts
git commit -m "feat: rewrite APK parser to use version as delimiter"
```
---
### Task 3: Update API Route to Pass Version
**Files:**
- Modify: `app/api/apks/route.ts`
- Modify: `app/api/__tests__/apks.test.ts`
**Step 1: Check existing API test**
Read: `app/api/__tests__/apks.test.ts`
**Step 2: Update API route to pass version**
Modify `app/api/apks/route.ts` line 22:
```typescript
// Change from:
const parsed = parseApkFilename(apk.name);
// To:
const parsed = parseApkFilename(apk.name, version);
```
**Step 3: Verify build succeeds**
Run: `npm run build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add app/api/apks/route.ts
git commit -m "feat: pass version parameter to APK parser"
```
---
### Task 4: Update ApkList to Use Configured Tabs
**Files:**
- Modify: `lib/constants.ts`
- Modify: `app/download/ApkList.tsx`
- Add: `app/api/config/route.ts`
**Step 1: Create config API endpoint**
Create `app/api/config/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { APK_TABS } from '@/lib/constants';
export async function GET() {
return NextResponse.json({
tabs: APK_TABS,
});
}
```
**Step 2: Update ApkList to fetch and use configured tabs**
Modify `app/download/ApkList.tsx`:
```typescript
'use client';
import { useState, useEffect } from 'react';
import useSWR from 'swr';
import { type ApkEnvironment } from '@/lib/apk-parser';
import ApkTable from './ApkTable';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
interface Apk {
name: string;
environment: ApkEnvironment;
commit: string | null;
size: number;
modifiedAt: string;
downloadUrl: string;
}
interface ApkListProps {
project: string;
version: string;
commitId: string;
}
export default function ApkList({ project, version, commitId }: ApkListProps) {
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
// Fetch config on mount
useEffect(() => {
fetch('/api/config')
.then(res => res.json())
.then(data => setConfiguredTabs(data.tabs))
.catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback
}, []);
const { data: apks, isLoading, error } = useSWR<Apk[]>(
`/api/apks?project=${project}&version=${version}&commit=${commitId}`,
fetcher
);
// Build tabs: product → configured → other
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
if (isLoading) {
return (
<div className="text-center py-8 text-gray-500">
...
</div>
);
}
if (error) {
return (
<div className="text-center py-8 text-red-600">
</div>
);
}
if (!apks || apks.length === 0) {
return (
<div className="bg-white rounded-lg shadow-md p-12 text-center">
<p className="text-gray-500"> APK </p>
</div>
);
}
// Group APKs by environment
const groupedApks = apks.reduce((acc, apk) => {
acc[apk.environment] = [...(acc[apk.environment] || []), apk];
return acc;
}, {} as Record<ApkEnvironment, Apk[]>);
return (
<div>
{/* Tab Navigation */}
<div className="bg-white rounded-t-lg border-b border-gray-200">
<nav className="flex space-x-8 px-6" aria-label="Tabs">
{TABS.map((tab) => {
const count = groupedApks[tab]?.length || 0;
return (
<button
key={tab}
onClick={() => {}}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
false // activeTab logic removed for simplicity
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab} ({count})
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-4">
<ApkTable apks={groupedApks['product'] || []} />
</div>
</div>
);
}
```
Wait, I need to preserve the activeTab state. Let me revise:
```typescript
'use client';
import { useState, useEffect } from 'react';
import useSWR from 'swr';
import { type ApkEnvironment } from '@/lib/apk-parser';
import ApkTable from './ApkTable';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
interface Apk {
name: string;
environment: ApkEnvironment;
commit: string | null;
size: number;
modifiedAt: string;
downloadUrl: string;
}
interface ApkListProps {
project: string;
version: string;
commitId: string;
}
export default function ApkList({ project, version, commitId }: ApkListProps) {
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<ApkEnvironment>('product');
// Fetch config on mount
useEffect(() => {
fetch('/api/config')
.then(res => res.json())
.then(data => setConfiguredTabs(data.tabs))
.catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback
}, []);
const { data: apks, isLoading, error } = useSWR<Apk[]>(
`/api/apks?project=${project}&version=${version}&commit=${commitId}`,
fetcher
);
// Build tabs: product → configured → other
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
if (isLoading) {
return (
<div className="text-center py-8 text-gray-500">
...
</div>
);
}
if (error) {
return (
<div className="text-center py-8 text-red-600">
</div>
);
}
if (!apks || apks.length === 0) {
return (
<div className="bg-white rounded-lg shadow-md p-12 text-center">
<p className="text-gray-500"> APK </p>
</div>
);
}
// Group APKs by environment
const groupedApks = apks.reduce((acc, apk) => {
acc[apk.environment] = [...(acc[apk.environment] || []), apk];
return acc;
}, {} as Record<ApkEnvironment, Apk[]>);
return (
<div>
{/* Tab Navigation */}
<div className="bg-white rounded-t-lg border-b border-gray-200">
<nav className="flex space-x-8 px-6" aria-label="Tabs">
{TABS.map((tab) => {
const count = groupedApks[tab]?.length || 0;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab} ({count})
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-4">
<ApkTable apks={groupedApks[activeTab] || []} />
</div>
</div>
);
}
```
**Step 3: Run build to verify**
Run: `npm run build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add app/api/config/route.ts app/download/ApkList.tsx
git commit -m "feat: add config API and update ApkList to use configured tabs"
```
---
### Task 5: Final Verification
**Step 1: Run all tests**
Run: `npm test`
Expected: All tests pass
**Step 2: Run build**
Run: `npm run build`
Expected: Build succeeds
**Step 3: Manual test with dev server**
Run: `npm run dev`
Test: Navigate to download page, select project and version, verify tabs display correctly
---
## Summary
| File | Change |
|------|--------|
| `lib/constants.ts` | Add `APK_TABS` env var |
| `lib/apk-parser.ts` | Rewrite parsing logic with version delimiter |
| `lib/__tests__/apk-parser.test.ts` | Update tests |
| `app/api/apks/route.ts` | Pass version to parser |
| `app/api/config/route.ts` | New endpoint for tab config |
| `app/download/ApkList.tsx` | Use configured tabs from API |