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
104 changes: 103 additions & 1 deletion packages/targets/tv-firetv/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,106 @@
import { smokeTest } from '@profullstack/sh1pt-core/testing';
import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import adapter from './index.js';

smokeTest(adapter, { idPrefix: 'tv', requireKind: true });

const tempDirs: string[] = [];

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

describe('Fire TV package planning', () => {
it('writes an inspectable package plan with Fire TV manifest requirements', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-firetv-'));
tempDirs.push(outDir);

const result = await adapter.build(fakeBuildContext({
outDir,
version: '1.8.0',
channel: 'stable',
}) as any, {
packageName: 'com.acme.firetv',
appSku: 'ACMEFIRETV',
});

const planFile = join(outDir, 'firetv-package-plan.json');
expect(result.artifact).toBe(join(outDir, 'firetv', 'com.acme.firetv.apk'));
expect(result.meta?.planFile).toBe(planFile);
expect(result.meta?.deviceTargeting).toBe('firetv-only');

const plan = JSON.parse(await readFile(planFile, 'utf-8')) as {
appSku: string;
packageName: string;
version: string;
artifact: string;
deviceTargeting: string;
manifestChecks: Array<{ requirement: string; required: boolean }>;
commands: string[];
};

expect(plan.appSku).toBe('ACMEFIRETV');
expect(plan.packageName).toBe('com.acme.firetv');
expect(plan.version).toBe('1.8.0');
expect(plan.artifact).toBe(result.artifact);
expect(plan.deviceTargeting).toBe('firetv-only');
expect(plan.manifestChecks).toEqual(expect.arrayContaining([
expect.objectContaining({
requirement: 'category android:name="android.intent.category.LEANBACK_LAUNCHER"',
required: true,
}),
expect.objectContaining({
requirement: 'uses-feature android:name="android.hardware.touchscreen" android:required="false"',
required: true,
}),
]));
expect(plan.commands).toContain('./gradlew :app:assembleRelease');
});

it('keeps shared phone and Fire TV targeting explicit in dry-run shipping', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-firetv-'));
tempDirs.push(outDir);

const result = await adapter.build(fakeBuildContext({
outDir,
version: '1.8.0',
}) as any, {
packageName: 'com.acme.firetv',
appSku: 'ACMEFIRETV',
apkPath: 'dist/firetv.apk',
deviceTargeting: 'firetv-and-phone',
});

expect(result.artifact).toBe('dist/firetv.apk');
expect(result.meta?.deviceTargeting).toBe('firetv-and-phone');

const ship = await adapter.ship(fakeShipContext({
artifact: 'dist/firetv.apk',
dryRun: true,
}) as any, {
packageName: 'com.acme.firetv',
appSku: 'ACMEFIRETV',
apkPath: 'dist/firetv.apk',
deviceTargeting: 'firetv-and-phone',
});

expect(ship).toEqual({
id: 'dry-run',
meta: {
appSku: 'ACMEFIRETV',
packageName: 'com.acme.firetv',
artifact: 'dist/firetv.apk',
deviceTargeting: 'firetv-and-phone',
commands: [
'amazon-appstore edits.create appSku=ACMEFIRETV',
'amazon-appstore apk.upload artifact=dist/firetv.apk',
'amazon-appstore targeting.update device=firetv-and-phone',
'amazon-appstore edits.submit',
],
},
});
});
});
104 changes: 85 additions & 19 deletions packages/targets/tv-firetv/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,96 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { defineTarget, manualSetup } from '@profullstack/sh1pt-core';

interface Config {
packageName: string; // e.g. com.acme.myapp
appSku: string; // Amazon Appstore SKU
// Fire TV ships an Android APK with <uses-feature android:name="android.software.leanback"/>
// in the manifest. Same APK can serve phones if you drop the leanback requirement.
packageName: string;
appSku: string;
apkPath?: string;
deviceTargeting?: 'firetv-only' | 'firetv-and-phone';
}

const PLAN_FILE = 'firetv-package-plan.json';

function artifactPath(ctx: { outDir: string }, config: Config): string {
return config.apkPath ?? join(ctx.outDir, 'firetv', `${config.packageName}.apk`);
}

function targeting(config: Config): NonNullable<Config['deviceTargeting']> {
return config.deviceTargeting ?? 'firetv-only';
}

function buildPlan(ctx: { outDir: string; version: string; channel: string }, config: Config) {
const artifact = artifactPath(ctx, config);
const deviceTargeting = targeting(config);
return {
packageName: config.packageName,
appSku: config.appSku,
version: ctx.version,
channel: ctx.channel,
artifact,
deviceTargeting,
planFile: join(ctx.outDir, PLAN_FILE),
manifestChecks: [
{
path: 'AndroidManifest.xml',
requirement: 'uses-feature android:name="android.software.leanback"',
required: deviceTargeting === 'firetv-only',
},
{
path: 'AndroidManifest.xml',
requirement: 'category android:name="android.intent.category.LEANBACK_LAUNCHER"',
required: true,
},
{
path: 'AndroidManifest.xml',
requirement: 'uses-feature android:name="android.hardware.touchscreen" android:required="false"',
required: true,
},
],
commands: [
'./gradlew :app:assembleRelease',
`amazon-appstore edits.create appSku=${config.appSku}`,
`amazon-appstore apk.upload artifact=${artifact}`,
`amazon-appstore targeting.update device=${deviceTargeting}`,
'amazon-appstore edits.submit',
],
};
}

export default defineTarget<Config>({
id: 'tv-firetv',
kind: 'tv',
label: 'Amazon Appstore (Fire TV / Firestick)',
async build(ctx, config) {
ctx.log(`gradle :app:bundleRelease · targeting=${config.deviceTargeting ?? 'firetv-only'}`);
// TODO:
// - verify AndroidManifest declares leanback feature + launcher intent
// - gradle assembleRelease → signed APK (Amazon Appstore requires APK, not AAB)
return { artifact: config.apkPath ?? `${ctx.outDir}/app-release.apk` };
const plan = buildPlan(ctx, config);
ctx.log(`firetv plan ${config.appSku} -> ${plan.deviceTargeting}`);
await mkdir(ctx.outDir, { recursive: true });
await writeFile(plan.planFile, `${JSON.stringify(plan, null, 2)}\n`, 'utf-8');
return {
artifact: plan.artifact,
meta: {
planFile: plan.planFile,
deviceTargeting: plan.deviceTargeting,
manifestChecks: plan.manifestChecks,
},
};
},
async ship(ctx, config) {
ctx.log(`upload to Amazon Appstore · sku=${config.appSku}`);
if (ctx.dryRun) return { id: 'dry-run' };
// TODO: Amazon App Submission API (create edit → upload APK → submit)
const plan = buildPlan(ctx, config);
ctx.log(`upload to Amazon Appstore sku=${config.appSku}`);
if (ctx.dryRun) {
return {
id: 'dry-run',
meta: {
appSku: config.appSku,
packageName: config.packageName,
artifact: ctx.artifact,
deviceTargeting: plan.deviceTargeting,
commands: plan.commands.slice(1),
},
};
}
// TODO: Amazon App Submission API (create edit -> upload APK -> submit)
return {
id: `${config.appSku}@${ctx.version}`,
url: `https://www.amazon.com/gp/product/${config.appSku}`,
Expand All @@ -34,14 +101,13 @@ export default defineTarget<Config>({
},

setup: manualSetup({
label: "Fire TV (Amazon Appstore)",
vendorDocUrl: "https://developer.amazon.com/apps-and-games/console/app/list",
label: 'Fire TV (Amazon Appstore)',
vendorDocUrl: 'https://developer.amazon.com/docs/app-submission-api/overview.html',
steps: [
"Open developer.amazon.com/apps-and-games \u2014 register (free for Fire TV)",
"Complete tax + payout onboarding",
"Generate API keys in Account Settings \u2192 Security",
"Run: sh1pt secret set AMAZON_APPSTORE_CLIENT_ID <id>",
"Run: sh1pt secret set AMAZON_APPSTORE_CLIENT_SECRET <secret>",
'Open developer.amazon.com/apps-and-games and register for the Amazon Appstore.',
'Generate App Submission API credentials in Account Settings -> Security.',
'Run: sh1pt secret set AMAZON_APPSTORE_CLIENT_ID <id>',
'Run: sh1pt secret set AMAZON_APPSTORE_CLIENT_SECRET <secret>',
],
}),
});
Loading