1import * as firebase from "@distilled.cloud/gcp/firebase_v1beta1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 hasOwnershipMarker,
11 lastSegment,
12 listAndroidApps,
13 ownedByAlchemy,
14 ownedDisplayName,
15 packageNameOf,
16 parseDisplayName,
17 projectParent,
18 ResourceNotResolved,
19 waitForOperation,
20} from "./internal.ts";
21
22export type AndroidAppProps = {
23 /**
24 * Canonical Android package name (`com.example.app`). Immutable —
25 * changing it replaces the app. If omitted, a unique
26 * `com.alchemy.test.{id}` name is generated.
27 */
28 packageName?: string;
29 /**
30 * User-assigned display name. Android apps have no labels field, so
31 * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it
32 * from attributes.
33 */
34 displayName?: string;
35 /**
36 * SHA-1 certificate hashes.
37 */
38 sha1Hashes?: string[];
39 /**
40 * SHA-256 certificate hashes.
41 */
42 sha256Hashes?: string[];
43 /**
44 * Google-assigned API key uid. Firebase associates one automatically
45 * when omitted.
46 */
47 apiKeyId?: string;
48};
49
50export type AndroidApp = Resource<
51 "GCP.Firebase.AndroidApp",
52 AndroidAppProps,
53 {
54 /** Full resource name `projects/{project}/androidApps/{appId}`. */
55 name: string;
56 /** Firebase-assigned app id. */
57 appId: string;
58 /** Parent project id. */
59 projectId: string;
60 /** Android package name. */
61 packageName: string | undefined;
62 /** User display name with the Alchemy ownership prefix stripped. */
63 displayName: string | undefined;
64 /** SHA-1 hashes. */
65 sha1Hashes: string[] | undefined;
66 /** SHA-256 hashes. */
67 sha256Hashes: string[] | undefined;
68 /** Lifecycle state. */
69 state: string | undefined;
70 /** Associated API key uid. */
71 apiKeyId: string | undefined;
72 /** Server etag. */
73 etag: string | undefined;
74 /** Expire time when the app is in `DELETED`. */
75 expireTime: string | undefined;
76 },
77 never,
78 Providers
79>;
80
81/**
82 * A Firebase Android app in a Firebase project.
83 *
84 * Apps have no labels — Alchemy stamps ownership into `displayName`.
85 * `packageName` is immutable. Delete uses `androidApps.remove` with
86 * `immediate: true` so tests do not leave a 30-day tombstone.
87 *
88 * ### Creating an Android App
89 * **Example:** Generated package name
90 * ```typescript
91 * const app = yield* GCP.Firebase.AndroidApp("Mobile", {
92 * displayName: "mobile",
93 * });
94 * ```
95 *
96 * **Example:** Explicit package
97 * ```typescript
98 * const app = yield* GCP.Firebase.AndroidApp("Mobile", {
99 * packageName: "com.example.mobile",
100 * displayName: "mobile",
101 * });
102 * ```
103 *
104 * @resource
105 * @category Firebase
106 */
107export const AndroidApp = Resource<AndroidApp>("GCP.Firebase.AndroidApp");
108
109const toAttrs = (app: firebase.AndroidApp) => ({
110 name: app.name ?? "",
111 appId: app.appId ?? lastSegment(app.name ?? ""),
112 projectId: app.projectId ?? "",
113 packageName: app.packageName,
114 displayName: parseDisplayName(app.displayName).displayName,
115 sha1Hashes: app.sha1Hashes,
116 sha256Hashes: app.sha256Hashes,
117 state: app.state,
118 apiKeyId: app.apiKeyId,
119 etag: app.etag,
120 expireTime: app.expireTime,
121});
122
123const getByName = (name: string) =>
124 name.length === 0
125 ? Effect.succeed(undefined)
126 : firebase
127 .getProjectsAndroidApps({ name })
128 .pipe(
129 Effect.catchTag(["NotFound", "AndroidAppNotFound"], () =>
130 Effect.succeed(undefined),
131 ),
132 );
133
134const findOwned = (
135 project: string,
136 displayName: string,
137 packageName?: string,
138) =>
139 listAndroidApps(project).pipe(
140 Effect.map((apps) =>
141 apps.find(
142 (app) =>
143 app.displayName === displayName ||
144 (packageName !== undefined && app.packageName === packageName),
145 ),
146 ),
147 );
148
149const sameList = (
150 left: readonly string[] | undefined,
151 right: readonly string[] | undefined,
152) =>
153 JSON.stringify([...(left ?? [])].sort()) ===
154 JSON.stringify([...(right ?? [])].sort());
155
156export const AndroidAppProvider = () =>
157 Provider.succeed(AndroidApp, {
158 stables: ["name", "appId", "projectId", "packageName"],
159
160 diff: Effect.fn(function* ({ news, olds, output }) {
161 if (!isResolved(news)) return undefined;
162 const previousPackage = olds?.packageName ?? output?.packageName;
163 if (
164 previousPackage !== undefined &&
165 news.packageName !== undefined &&
166 news.packageName !== previousPackage
167 ) {
168 return { action: "replace" as const, deleteFirst: true };
169 }
170 return undefined;
171 }),
172
173 read: Effect.fn(function* ({ id, olds, output }) {
174 const env = yield* GcpEnvironment.current;
175 let existing = yield* getByName(output?.name ?? "");
176 if (existing === undefined) {
177 const displayName = yield* ownedDisplayName(
178 id,
179 olds?.displayName,
180 parseDisplayName(output?.displayName).displayName,
181 );
182 existing = yield* findOwned(
183 env.project,
184 displayName,
185 olds?.packageName ?? output?.packageName,
186 );
187 }
188 if (existing === undefined || existing.state === "DELETED") {
189 return undefined;
190 }
191 const attrs = toAttrs(existing);
192 return (yield* ownedByAlchemy(id, existing.displayName))
193 ? attrs
194 : Unowned(attrs);
195 }),
196
197 list: () =>
198 Effect.gen(function* () {
199 const env = yield* GcpEnvironment.current;
200 const apps = yield* listAndroidApps(env.project);
201 return apps
202 .filter(
203 (app) =>
204 hasOwnershipMarker(app.displayName) && app.state !== "DELETED",
205 )
206 .map(toAttrs);
207 }),
208
209 reconcile: Effect.fn(function* ({ id, news, output }) {
210 const env = yield* GcpEnvironment.current;
211 const displayName = yield* ownedDisplayName(
212 id,
213 news.displayName,
214 parseDisplayName(output?.displayName).displayName,
215 );
216 const packageName = yield* packageNameOf(
217 id,
218 news.packageName,
219 output?.packageName,
220 );
221
222 let current = yield* getByName(output?.name ?? "");
223 if (current === undefined) {
224 current = yield* findOwned(env.project, displayName, packageName);
225 }
226
227 if (current === undefined) {
228 const operation = yield* firebase
229 .createProjectsAndroidApps({
230 parent: projectParent(env.project),
231 body: {
232 packageName,
233 displayName,
234 sha1Hashes: news.sha1Hashes,
235 sha256Hashes: news.sha256Hashes,
236 apiKeyId: news.apiKeyId,
237 },
238 })
239 .pipe(
240 Effect.catchTag("Conflict", () =>
241 findOwned(env.project, displayName, packageName).pipe(
242 Effect.map((app) =>
243 app
244 ? ({
245 done: true,
246 response: { name: app.name },
247 } satisfies firebase.Operation)
248 : undefined,
249 ),
250 ),
251 ),
252 );
253 if (operation) {
254 const done = yield* waitForOperation(operation);
255 const name =
256 (typeof done.response?.name === "string"
257 ? done.response.name
258 : undefined) ??
259 output?.name ??
260 "";
261 current =
262 (yield* getByName(name)) ??
263 (yield* findOwned(env.project, displayName, packageName));
264 }
265 }
266
267 if (current === undefined) {
268 return yield* new ResourceNotResolved({
269 name: output?.name ?? packageName,
270 });
271 }
272
273 const name = current.name ?? "";
274 const displayChanged = current.displayName !== displayName;
275 const sha1Changed =
276 news.sha1Hashes !== undefined &&
277 !sameList(current.sha1Hashes, news.sha1Hashes);
278 const sha256Changed =
279 news.sha256Hashes !== undefined &&
280 !sameList(current.sha256Hashes, news.sha256Hashes);
281 const apiKeyChanged =
282 news.apiKeyId !== undefined && current.apiKeyId !== news.apiKeyId;
283 const updateMask = [
284 displayChanged ? "displayName" : undefined,
285 sha1Changed ? "sha1Hashes" : undefined,
286 sha256Changed ? "sha256Hashes" : undefined,
287 apiKeyChanged ? "apiKeyId" : undefined,
288 ].filter((field): field is string => field !== undefined);
289
290 if (updateMask.length > 0) {
291 current = yield* firebase.patchProjectsAndroidApps({
292 name,
293 updateMask: updateMask.join(","),
294 body: {
295 displayName,
296 sha1Hashes: news.sha1Hashes,
297 sha256Hashes: news.sha256Hashes,
298 apiKeyId: news.apiKeyId,
299 },
300 });
301 }
302
303 return toAttrs(current);
304 }),
305
306 delete: Effect.fn(function* ({ output }) {
307 if (!output.name) return;
308 const operation = yield* firebase
309 .removeProjectsAndroidApps({
310 name: output.name,
311 body: { immediate: true, allowMissing: true },
312 })
313 .pipe(
314 Effect.catchTag(["NotFound", "AndroidAppNotFound"], () =>
315 Effect.succeed(undefined),
316 ),
317 );
318 if (operation) {
319 yield* waitForOperation(operation).pipe(
320 Effect.catchIf(
321 (error) => error._tag === "GCP.OperationFailed" && error.code === 5,
322 () => Effect.void,
323 ),
324 );
325 }
326 }),
327 });