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

14 KiB

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

// 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

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:

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:

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

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:

// 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

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:

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:

'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:

'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

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