android-publisher-cli 0.0.0-stage → 0.2.0-dev

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.
@@ -0,0 +1,189 @@
1
+ import { createHmac, randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import { Readable } from 'node:stream';
4
+ import { parseStrictJSON } from '../model.js';
5
+ import { hashFile } from '../private-file.js';
6
+ export const ORIGINS = {
7
+ huawei: 'https://connect-api.cloud.huawei.com',
8
+ honor: 'https://appmarket-openapi-drcn.cloud.honor.com',
9
+ xiaomi: 'https://api.developer.xiaomi.com',
10
+ oppo: 'https://oop-openapi-cn.heytapmobi.com',
11
+ vivo: 'https://developer-api.vivo.com.cn',
12
+ tencent: 'https://p.open.qq.com',
13
+ };
14
+ export class ChannelError extends Error {
15
+ code;
16
+ effect;
17
+ constructor(code, effect = 'unknown') {
18
+ super(`channel protocol: ${code} (${effect})`);
19
+ this.code = code;
20
+ this.effect = effect;
21
+ }
22
+ }
23
+ export const unknown = () => new ChannelError('missing_or_unrecognized_evidence');
24
+ export const object = (value) => {
25
+ if (!value || typeof value !== 'object' || Array.isArray(value))
26
+ throw unknown();
27
+ return value;
28
+ };
29
+ export const list = (value) => Array.isArray(value) ? value.map(object) : [];
30
+ export const str = (value) => typeof value === 'string' ? value : '';
31
+ export const id = (value) => {
32
+ if (typeof value === 'string')
33
+ return value;
34
+ if (typeof value === 'bigint' && value >= 0n)
35
+ return value.toString();
36
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
37
+ return String(value);
38
+ return '';
39
+ };
40
+ export const num = (value) => typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
41
+ export const decimal = (value) => typeof value === 'string' && /^[0-9]+$/.test(value) ? value :
42
+ typeof value === 'bigint' && value >= 0n ? value.toString() :
43
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? String(value) : '';
44
+ export const required = (...values) => { if (values.some(value => !value))
45
+ throw new ChannelError('missing_required_input', 'not_sent'); };
46
+ export function check(obj, key) {
47
+ const value = num(obj[key]);
48
+ if (value === null)
49
+ throw unknown();
50
+ if (value !== 0)
51
+ throw new ChannelError('vendor_business_error');
52
+ }
53
+ export function hmacSign(fields, secret, omit = []) {
54
+ const signed = Object.keys(fields).filter(k => !omit.includes(k)).sort().map(k => `${k}=${fields[k]}`).join('&');
55
+ return createHmac('sha256', secret).update(signed).digest('hex');
56
+ }
57
+ function allowedUpload(channel, endpoint) {
58
+ const host = endpoint.hostname.toLowerCase();
59
+ if (channel === 'tencent')
60
+ return host.endsWith('.myqcloud.com') && host.includes('.cos.');
61
+ if (channel === 'huawei')
62
+ return host === 'connect-api.cloud.huawei.com' || host === 'nsp-appgallery-agcfs-drcn.obs.cn-north-2.myhuaweicloud.cn' || host.endsWith('.dbankcdn.com');
63
+ if (channel === 'oppo')
64
+ return host === 'api.open.oppomobile.com' || host === 'oop-openapi-cn.heytapmobi.com' || host.endsWith('.heytapmobi.com');
65
+ if (channel === 'honor')
66
+ return host === 'appmarket-openapi-drcn.cloud.honor.com';
67
+ return false;
68
+ }
69
+ export class ChannelHTTP {
70
+ channel;
71
+ fetcher;
72
+ writeGuard;
73
+ constructor(channel, fetcher = fetch, writeGuard = () => false) {
74
+ this.channel = channel;
75
+ this.fetcher = fetcher;
76
+ this.writeGuard = writeGuard;
77
+ }
78
+ async send(q) {
79
+ if (q.class === 'write' && (!q.operationId || !this.writeGuard(q.operationId)))
80
+ throw new ChannelError('unlogged_write', 'not_sent');
81
+ if (q.class !== 'write' && q.operationId)
82
+ throw new ChannelError('unexpected_operation', 'not_sent');
83
+ if (q.path === undefined && q.url === undefined || q.path !== undefined && q.url !== undefined)
84
+ throw new ChannelError('invalid_endpoint', 'not_sent');
85
+ const endpoint = new URL(q.path === undefined ? q.url : `${ORIGINS[this.channel]}${q.path}`);
86
+ if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.hash || endpoint.port ||
87
+ (q.upload ? !allowedUpload(this.channel, endpoint) : endpoint.origin !== ORIGINS[this.channel] && !(this.channel === 'honor' && q.class === 'lifecycle' && endpoint.origin === 'https://iam.developer.honor.com')))
88
+ throw new ChannelError('endpoint_not_allowed', 'not_sent');
89
+ const body = q.body;
90
+ if (q.method === 'GET' && body)
91
+ throw new ChannelError('GET_body_rejected', 'not_sent');
92
+ const controller = new AbortController();
93
+ const timer = setTimeout(() => controller.abort(), q.class === 'read' ? 30000 : 90000);
94
+ let response;
95
+ try {
96
+ response = await this.fetcher(endpoint, { method: q.method, ...(q.headers ? { headers: q.headers } : {}),
97
+ body: body ? body : null, redirect: 'manual', signal: controller.signal,
98
+ ...(body instanceof Readable ? { duplex: 'half' } : {}) });
99
+ }
100
+ catch {
101
+ throw new ChannelError('network_or_timeout', q.class === 'read' ? 'not_sent' : 'unknown');
102
+ }
103
+ finally {
104
+ clearTimeout(timer);
105
+ }
106
+ if (response.status < 200 || response.status > 299)
107
+ throw new ChannelError('http_status', q.class === 'read' ? 'not_sent' : 'unknown');
108
+ if (q.rawUpload) {
109
+ if (response.status !== 200)
110
+ throw unknown();
111
+ return {};
112
+ }
113
+ const contentLength = Number(response.headers.get('content-length') ?? 0);
114
+ if (contentLength > 4 * 1024 * 1024)
115
+ throw unknown();
116
+ const chunks = [];
117
+ let size = 0;
118
+ if (!response.body)
119
+ throw unknown();
120
+ for await (const chunk of response.body) {
121
+ size += chunk.byteLength;
122
+ if (size > 4 * 1024 * 1024)
123
+ throw unknown();
124
+ chunks.push(chunk);
125
+ }
126
+ try {
127
+ return object(parseStrictJSON(Buffer.concat(chunks).toString('utf8')));
128
+ }
129
+ catch {
130
+ throw unknown();
131
+ }
132
+ }
133
+ json(path, method, kind, value, operationId, headers) {
134
+ const body = value === undefined ? undefined : Buffer.from(JSON.stringify(value));
135
+ return this.send({ method, path, class: kind, ...(operationId ? { operationId } : {}),
136
+ headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers }, ...(body ? { body, size: body.length } : {}) });
137
+ }
138
+ rawJSON(path, method, kind, source, operationId, headers) {
139
+ parseStrictJSON(source);
140
+ const body = Buffer.from(source);
141
+ return this.send({ method, path, class: kind, ...(operationId ? { operationId } : {}),
142
+ headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers }, body, size: body.length });
143
+ }
144
+ form(path, kind, fields, operationId, headers) {
145
+ const body = Buffer.from(new URLSearchParams(fields).toString());
146
+ return this.send({ method: 'POST', path, class: kind, ...(operationId ? { operationId } : {}),
147
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', ...headers }, body, size: body.length });
148
+ }
149
+ async upload(url, file, operationId, headers = {}) {
150
+ const current = hashFile(file.path);
151
+ if (current.sha256 !== file.sha256 || current.size !== file.size)
152
+ throw new ChannelError('frozen_apk_changed', 'not_sent');
153
+ await this.send({ method: 'PUT', url, class: 'write', operationId, upload: true, rawUpload: true, body: fs.createReadStream(file.path), size: file.size,
154
+ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(file.size), ...headers } });
155
+ }
156
+ async multipart(endpoint, fields, files, kind, operationId, headers = {}, upload = false) {
157
+ const boundary = `publisher-${randomBytes(16).toString('hex')}`;
158
+ const parts = [];
159
+ for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
160
+ if (!/^[A-Za-z0-9_]+$/.test(key))
161
+ throw new ChannelError('invalid_multipart_field', 'not_sent');
162
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${value}\r\n`));
163
+ }
164
+ for (const [key, file] of Object.entries(files).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
165
+ if (!/^[A-Za-z0-9_]+$/.test(key))
166
+ throw new ChannelError('invalid_multipart_field', 'not_sent');
167
+ const current = hashFile(file.path);
168
+ if (current.sha256 !== file.sha256 || current.size !== file.size || !/^[A-Za-z0-9._-]+$/.test(file.filename))
169
+ throw new ChannelError('invalid_frozen_file', 'not_sent');
170
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"; filename="${file.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
171
+ parts.push(file);
172
+ parts.push(Buffer.from('\r\n'));
173
+ }
174
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
175
+ const size = parts.reduce((total, part) => total + (Buffer.isBuffer(part) ? part.length : part.size), 0);
176
+ const body = Readable.from((async function* () {
177
+ for (const part of parts) {
178
+ if (Buffer.isBuffer(part))
179
+ yield part;
180
+ else
181
+ for await (const chunk of fs.createReadStream(part.path))
182
+ yield chunk;
183
+ }
184
+ })());
185
+ return this.send({ method: 'POST', ...(endpoint.startsWith('https://') ? { url: endpoint } : { path: endpoint }), class: kind,
186
+ ...(operationId ? { operationId } : {}), upload, body, size,
187
+ headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': String(size), ...headers } });
188
+ }
189
+ }
@@ -0,0 +1,138 @@
1
+ import { bearer } from './auth.js';
2
+ import { BaseAdapter } from './types.js';
3
+ import { check, decimal, list, object, required, str, unknown } from './http.js';
4
+ export class HuaweiAdapter extends BaseAdapter {
5
+ channel = 'huawei';
6
+ steps = ['authorize', 'upload', 'bind', 'metadata', 'submit'];
7
+ async call(path, method, kind, value, operationId) {
8
+ const token = await bearer('huawei', this.target.account, '', this.secrets, this.store, this.http);
9
+ const response = await this.http.json(path, method, kind, value, operationId, { Authorization: `Bearer ${token}` });
10
+ check(object(response.ret), 'code');
11
+ return response;
12
+ }
13
+ async details() {
14
+ const query = new URLSearchParams({ packageName: this.batch.package_name, packageTypes: '1' });
15
+ const bound = await this.call(`/api/publish/v2/appid-list?${query}`, 'GET', 'read');
16
+ const apps = list(bound.appids);
17
+ if (apps.length !== 1 || apps[0]?.value !== this.target.app_id)
18
+ throw unknown();
19
+ const response = await this.call(`/api/publish/v2/app-info?${new URLSearchParams({ appId: this.target.app_id })}`, 'GET', 'read');
20
+ const info = object(response.appInfo);
21
+ const version = decimal(info.versionCode);
22
+ if (!version || BigInt(version) <= 0n)
23
+ throw unknown();
24
+ const languages = list(response.languages);
25
+ let language = this.target.language_id ?? '';
26
+ if (!language) {
27
+ const matches = languages.filter(row => str(row.lang).replaceAll('_', '-').toLowerCase() === 'zh-cn');
28
+ if (matches.length !== 1)
29
+ throw new Error('Huawei Chinese language is missing or ambiguous');
30
+ language = str(matches[0]?.lang);
31
+ }
32
+ const selected = languages.filter(row => row.lang === language);
33
+ if (selected.length !== 1)
34
+ throw unknown();
35
+ const row = selected[0];
36
+ const metadata = { language_id: language, app_name: str(row.appName), intro: str(row.appDesc),
37
+ brief_intro: str(row.briefInfo), prior_notes: str(row.newFeatures) };
38
+ if (!metadata.app_name || !metadata.intro)
39
+ throw unknown();
40
+ return { version, metadata, versionId: str(info.versionId) };
41
+ }
42
+ async preflight() {
43
+ const details = await this.details();
44
+ if (BigInt(details.version) >= BigInt(this.batch.version_code))
45
+ throw new Error('Huawei target version must exceed the editable version');
46
+ return this.preflightRecord({ remote_version_code: details.version, remote_ref: details.versionId,
47
+ metadata: details.metadata });
48
+ }
49
+ async waitBeforeStep(step) {
50
+ if (step !== 'submit')
51
+ return;
52
+ const bound = this.store.latestOperation(this.batch.batch_id, 'huawei', 'bind');
53
+ if (!bound || bound.effect !== 'confirmed_applied')
54
+ throw new Error('Huawei bind receipt missing');
55
+ const remaining = Date.parse(bound.completed_at) + 120_000 - Date.now();
56
+ if (remaining > 0)
57
+ await new Promise(resolve => setTimeout(resolve, remaining));
58
+ }
59
+ async execute(step, operationId) {
60
+ const appId = this.target.app_id;
61
+ if (step === 'authorize') {
62
+ const started = Date.now();
63
+ const query = new URLSearchParams({ appId, fileName: this.target.artifact.filename,
64
+ sha256: this.target.artifact.sha256, contentLength: String(this.target.artifact.size), releaseType: '1' });
65
+ const response = await this.call(`/api/publish/v2/upload-url/for-obs?${query}`, 'GET', 'write', undefined, operationId);
66
+ const info = object(response.urlInfo);
67
+ const url = str(info.url);
68
+ const ref = str(info.objectId);
69
+ const method = str(info.method);
70
+ let endpoint;
71
+ try {
72
+ endpoint = new URL(url);
73
+ }
74
+ catch {
75
+ throw unknown();
76
+ }
77
+ const host = endpoint.hostname.toLowerCase();
78
+ if (!ref || method !== 'PUT' || endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.port || endpoint.hash ||
79
+ !(host === 'connect-api.cloud.huawei.com' || host === 'nsp-appgallery-agcfs-drcn.obs.cn-north-2.myhuaweicloud.cn' || host.endsWith('.dbankcdn.com')))
80
+ throw unknown();
81
+ const headers = object(info.headers);
82
+ const expected = ['Authorization', 'X-Amz-Content-Sha256', 'X-Amz-Date', 'Host', 'User-Agent', 'Content-Type'];
83
+ const values = {};
84
+ for (const name of expected) {
85
+ const key = Object.keys(headers).find(k => k.toLowerCase() === name.toLowerCase());
86
+ if (!key || typeof headers[key] !== 'string' || !headers[key] || /[\r\n]/.test(headers[key]))
87
+ throw unknown();
88
+ values[name] = headers[key];
89
+ }
90
+ if (!values.Authorization?.startsWith('AWS4-HMAC-SHA256 ') && !values.Authorization?.startsWith('OBS '))
91
+ throw unknown();
92
+ if (values['X-Amz-Content-Sha256'] !== this.target.artifact.sha256 || values.Host?.toLowerCase() !== endpoint.host.toLowerCase() || values['Content-Type'] !== 'application/octet-stream')
93
+ throw unknown();
94
+ const grant = { url, ref, headers: values, expires_at: new Date(started + 4 * 60_000).toISOString() };
95
+ return { ref, material: { value: grant, expiresAt: grant.expires_at } };
96
+ }
97
+ if (step === 'upload') {
98
+ const grant = this.material('authorize');
99
+ if (grant.ref !== this.ref('authorize'))
100
+ throw new Error('Huawei upload grant differs from authorization receipt');
101
+ const { Host: _host, ...headers } = grant.headers;
102
+ await this.http.upload(grant.url, this.target.artifact, operationId, headers);
103
+ return { ref: grant.ref };
104
+ }
105
+ if (step === 'bind') {
106
+ const ref = this.ref('upload');
107
+ await this.call(`/api/publish/v2/app-file-info?${new URLSearchParams({ appId })}`, 'PUT', 'write', { fileType: 5, files: [{ fileName: this.target.artifact.filename, fileDestUrl: ref }] }, operationId);
108
+ return { ref };
109
+ }
110
+ if (step === 'metadata') {
111
+ const baseline = this.savedPreflight().metadata;
112
+ if (!baseline)
113
+ throw new Error('Huawei language baseline missing');
114
+ required(baseline.language_id ?? '', baseline.app_name ?? '', baseline.intro ?? '', this.batch.release_notes);
115
+ if ([...this.batch.release_notes].length > 500)
116
+ throw new Error('Huawei notes exceed 500 characters');
117
+ await this.call(`/api/publish/v2/app-language-info?${new URLSearchParams({ appId, releaseType: '1' })}`, 'PUT', 'write', { lang: baseline.language_id, appName: baseline.app_name, appDesc: baseline.intro,
118
+ briefInfo: baseline.brief_intro ?? '', newFeatures: this.batch.release_notes }, operationId);
119
+ return { ref: `ack-${operationId}` };
120
+ }
121
+ if (step === 'submit') {
122
+ await this.call(`/api/publish/v2/app-submit?${new URLSearchParams({ appId, releaseType: '1' })}`, 'POST', 'write', undefined, operationId);
123
+ return { ref: `ack-${operationId}` };
124
+ }
125
+ throw new Error('unsupported Huawei step');
126
+ }
127
+ async observe(_remoteRef) {
128
+ const response = await this.call(`/api/publish/v2/app-info?${new URLSearchParams({ appId: this.target.app_id })}`, 'GET', 'read');
129
+ const app = object(response.appInfo);
130
+ const version = decimal(app.versionCode);
131
+ const onShelf = decimal(app.onShelfVersionCode);
132
+ if (version !== this.batch.version_code)
133
+ return this.observation('unknown', 'unknown', 'unconfirmed', 'huawei_app_info', undefined, { observed_version_code: version });
134
+ const state = decimal(app.releaseState);
135
+ const review = state === '0' ? 'approved' : ['1', '8', '13'].includes(state) ? 'rejected' : ['4', '5', '12'].includes(state) ? 'in_review' : 'unknown';
136
+ return this.observation(review, onShelf === this.batch.version_code ? 'live' : 'unknown', 'confirmed', 'huawei_target_version', str(app.versionId));
137
+ }
138
+ }
@@ -0,0 +1,17 @@
1
+ import { HuaweiAdapter } from './huawei.js';
2
+ import { HonorAdapter } from './honor.js';
3
+ import { XiaomiAdapter } from './xiaomi.js';
4
+ import { OPPOAdapter } from './oppo.js';
5
+ import { VivoAdapter } from './vivo.js';
6
+ import { TencentAdapter } from './tencent.js';
7
+ export function channelAdapter(batch, target, credentials, store, fetcher = fetch) {
8
+ const args = [batch, target, credentials, store, fetcher];
9
+ switch (target.channel) {
10
+ case 'huawei': return new HuaweiAdapter(...args);
11
+ case 'honor': return new HonorAdapter(...args);
12
+ case 'xiaomi': return new XiaomiAdapter(...args);
13
+ case 'oppo': return new OPPOAdapter(...args);
14
+ case 'vivo': return new VivoAdapter(...args);
15
+ case 'tencent': return new TencentAdapter(...args);
16
+ }
17
+ }
@@ -0,0 +1,169 @@
1
+ import { md5File } from '../private-file.js';
2
+ import { bearer } from './auth.js';
3
+ import { BaseAdapter } from './types.js';
4
+ import { check, decimal, hmacSign, id, object, required, str, unknown } from './http.js';
5
+ const metadataFields = ['app_name', 'app_subname', 'second_category_id', 'third_category_id', 'summary',
6
+ 'detail_desc', 'update_desc', 'privacy_source_url', 'icon_url', 'pic_url', 'landscape_pic_url',
7
+ 'video_url', 'online_type', 'sche_online_time', 'test_desc', 'electronic_cert_url', 'copyright_url',
8
+ 'icp_url', 'special_url', 'special_file_url', 'business_username', 'business_email', 'business_mobile',
9
+ 'business_qq', 'business_position', 'business_address', 'age_level', 'adaptive_equipment',
10
+ 'adaptive_type', 'customer_contact', 'audit_status', 'audit_status_name', 'state', 'apk_md5', 'version_id'];
11
+ const requiredFields = ['app_name', 'second_category_id', 'third_category_id', 'summary', 'detail_desc',
12
+ 'privacy_source_url', 'icon_url', 'pic_url', 'test_desc', 'business_username', 'business_email',
13
+ 'business_mobile', 'age_level', 'adaptive_equipment'];
14
+ export class OPPOAdapter extends BaseAdapter {
15
+ channel = 'oppo';
16
+ steps = ['authorize', 'upload', 'submit'];
17
+ async signed(fields) {
18
+ const clientId = this.target.client_id ?? this.target.account;
19
+ const token = await bearer('oppo', this.target.account, clientId, this.secrets, this.store, this.http);
20
+ const secret = this.secrets.client_secret ?? '';
21
+ required(token, secret);
22
+ const values = { ...fields, access_token: token, timestamp: String(Math.floor(Date.now() / 1000)) };
23
+ return { ...values, api_sign: hmacSign(values, secret) };
24
+ }
25
+ async query(path, fields) {
26
+ const response = await this.http.send({ method: 'GET', path: `${path}?${new URLSearchParams(await this.signed(fields))}`, class: 'read' });
27
+ check(response, 'errno');
28
+ return response;
29
+ }
30
+ async details(version = '') {
31
+ const fields = { pkg_name: this.batch.package_name };
32
+ if (version)
33
+ fields.version_code = version;
34
+ const response = await this.query('/resource/v1/app/info', fields);
35
+ const data = object(response.data);
36
+ const code = decimal(data.version_code);
37
+ const appId = id(data.app_id);
38
+ if (!code || BigInt(code) <= 0n || data.pkg_name !== this.batch.package_name || !appId || version && code !== version)
39
+ throw unknown();
40
+ const metadata = {};
41
+ for (const key of metadataFields) {
42
+ const value = data[key];
43
+ if (typeof value === 'string')
44
+ metadata[key] = value;
45
+ else if (typeof value === 'number' || typeof value === 'bigint')
46
+ metadata[key] = String(value);
47
+ }
48
+ return { version: code, appId, metadata };
49
+ }
50
+ copyright(metadata) {
51
+ if (metadata.copyright_url)
52
+ return metadata.copyright_url;
53
+ try {
54
+ if (new URL(metadata.electronic_cert_url ?? '').protocol === 'https:')
55
+ return metadata.electronic_cert_url ?? '';
56
+ }
57
+ catch { /* no usable existing proof */ }
58
+ return '';
59
+ }
60
+ updateFields(baseline, file) {
61
+ const fields = {};
62
+ for (const key of metadataFields) {
63
+ if (['audit_status', 'audit_status_name', 'state', 'apk_md5', 'version_id'].includes(key))
64
+ continue;
65
+ if (baseline[key])
66
+ fields[key] = baseline[key];
67
+ }
68
+ fields.update_desc = this.batch.release_notes;
69
+ fields.copyright_url = this.copyright(fields);
70
+ fields.online_type = '1';
71
+ delete fields.sche_online_time;
72
+ for (const field of requiredFields)
73
+ required(fields[field] ?? '');
74
+ required(fields.update_desc, fields.copyright_url);
75
+ if ([...(fields.summary ?? '')].length > 13 || /[\s\p{P}]/u.test(fields.summary ?? '') ||
76
+ [...(fields.detail_desc ?? '')].length < 20 || [...fields.update_desc].length < 5 ||
77
+ [...(fields.test_desc ?? '')].length > 400 || !['4', '5', '6'].includes(fields.adaptive_equipment ?? '') ||
78
+ ['second_category_id', 'third_category_id', 'age_level'].some(k => !/^[1-9][0-9]*$/.test(fields[k] ?? '')) ||
79
+ (fields.pic_url ?? '').split(',').length < 2)
80
+ throw new Error('OPPO baseline metadata is incomplete or invalid');
81
+ fields.pkg_name = this.batch.package_name;
82
+ fields.version_code = this.batch.version_code;
83
+ fields.apk_url = JSON.stringify([{ url: file.url, md5: file.md5, cpu_code: 0 }]);
84
+ return fields;
85
+ }
86
+ async preflight() {
87
+ const details = await this.details();
88
+ if (BigInt(details.version) >= BigInt(this.batch.version_code))
89
+ throw new Error('OPPO existing version differs');
90
+ const synthetic = { url: 'https://example.invalid/file', md5: '0'.repeat(32), ref: 'preview', size: 1 };
91
+ this.updateFields(details.metadata, synthetic);
92
+ return this.preflightRecord({ remote_version_code: details.version, remote_ref: details.appId,
93
+ metadata: details.metadata });
94
+ }
95
+ async execute(step, operationId) {
96
+ if (step === 'authorize') {
97
+ const signed = await this.signed({});
98
+ const response = await this.http.send({ method: 'GET', path: `/resource/v1/upload/get-upload-url?${new URLSearchParams(signed)}`,
99
+ class: 'write', operationId });
100
+ check(response, 'errno');
101
+ const data = object(response.data);
102
+ const url = str(data.upload_url);
103
+ const sign = str(data.sign);
104
+ let endpoint;
105
+ try {
106
+ endpoint = new URL(url);
107
+ }
108
+ catch {
109
+ throw unknown();
110
+ }
111
+ const host = endpoint.hostname.toLowerCase();
112
+ if (!sign || endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.port || endpoint.hash ||
113
+ !(host === 'api.open.oppomobile.com' || host === 'oop-openapi-cn.heytapmobi.com' || host.endsWith('.heytapmobi.com')))
114
+ throw unknown();
115
+ const grant = { url, sign, expires_at: new Date(Date.now() + 30_000).toISOString() };
116
+ return { ref: `grant-${operationId}`, material: { value: grant, expiresAt: grant.expires_at } };
117
+ }
118
+ if (step === 'upload') {
119
+ const grant = this.material('authorize');
120
+ const response = await this.http.multipart(grant.url, { type: 'apk', sign: grant.sign }, { file: this.target.artifact }, 'write', operationId, {}, true);
121
+ check(response, 'errno');
122
+ const data = object(response.data);
123
+ const fileUrl = str(data.url);
124
+ const size = Number(decimal(data.file_size));
125
+ const md5 = md5File(this.target.artifact.path, this.target.artifact.sha256);
126
+ const ref = id(data.id);
127
+ let endpoint;
128
+ try {
129
+ endpoint = new URL(fileUrl);
130
+ }
131
+ catch {
132
+ throw unknown();
133
+ }
134
+ if (!['https:', 'http:'].includes(endpoint.protocol) || !endpoint.host || endpoint.username || endpoint.password || endpoint.hash ||
135
+ size !== this.target.artifact.size || str(data.md5).toLowerCase() !== md5 || data.file_extension !== 'apk' || !ref)
136
+ throw unknown();
137
+ const uploaded = { url: fileUrl, md5, ref, size };
138
+ return { ref, material: { value: uploaded, expiresAt: '9999-12-31T00:00:00.000Z' } };
139
+ }
140
+ if (step === 'submit') {
141
+ const file = this.material('upload');
142
+ if (file.ref !== this.ref('upload'))
143
+ throw new Error('OPPO upload receipt differs from file');
144
+ const baseline = this.savedPreflight().metadata;
145
+ if (!baseline)
146
+ throw new Error('OPPO baseline missing');
147
+ const fields = this.updateFields(baseline, file);
148
+ const response = await this.http.form('/resource/v1/app/upd', 'write', await this.signed(fields), operationId);
149
+ check(response, 'errno');
150
+ if (object(response.data).success !== true)
151
+ throw unknown();
152
+ return { ref: `ack-${operationId}` };
153
+ }
154
+ throw new Error('unsupported OPPO step');
155
+ }
156
+ async observe(_remoteRef) {
157
+ const details = await this.details(this.batch.version_code);
158
+ const file = this.material('upload');
159
+ if (details.appId !== this.savedPreflight().remote_ref || details.metadata.apk_md5?.toLowerCase() !== file.md5 || !details.metadata.version_id)
160
+ return this.observation('unknown', 'unknown', 'unconfirmed', 'oppo_version_unbound');
161
+ const code = details.metadata.audit_status;
162
+ const review = ['1', '4', '00'].includes(code ?? '') ? 'in_review' :
163
+ ['2', '6', '7', '111'].includes(code ?? '') ? 'approved' :
164
+ ['3', '5', '-11', '444'].includes(code ?? '') ? 'rejected' : 'unknown';
165
+ const availability = code === '111' && details.metadata.state === '1' ? 'live' :
166
+ code === '222' && details.metadata.state === '2' ? 'not_live' : 'unknown';
167
+ return this.observation(review, availability, 'confirmed', 'oppo_version_audit', details.metadata.version_id);
168
+ }
169
+ }
@@ -0,0 +1,96 @@
1
+ import { md5File } from '../private-file.js';
2
+ import { BaseAdapter } from './types.js';
3
+ import { check, decimal, hmacSign, required, str, unknown } from './http.js';
4
+ export class TencentAdapter extends BaseAdapter {
5
+ channel = 'tencent';
6
+ steps = ['authorize', 'upload', 'submit'];
7
+ async call(verb, fields, operationId) {
8
+ const secret = this.secrets.api_secret ?? '';
9
+ const userId = this.target.client_id ?? '';
10
+ required(secret, userId);
11
+ const signed = { ...fields, user_id: userId, timestamp: String(Math.floor(Date.now() / 1000)) };
12
+ const form = { ...signed, sign: hmacSign(signed, secret) };
13
+ const response = await this.http.form(`/open_file/developer_api/${verb}`, operationId ? 'write' : 'read', form, operationId);
14
+ check(response, 'ret');
15
+ return response;
16
+ }
17
+ async details() {
18
+ const response = await this.call('query_app_detail', { pkg_name: this.batch.package_name, app_id: this.target.app_id });
19
+ if (response.pkg_name !== this.batch.package_name)
20
+ throw unknown();
21
+ let version = '';
22
+ for (const key of ['VersionCode', 'version_code']) {
23
+ if (!(key in response))
24
+ continue;
25
+ const raw = response[key];
26
+ if (!(typeof raw === 'number' && Number.isSafeInteger(raw) || typeof raw === 'bigint'))
27
+ throw unknown();
28
+ const next = decimal(raw);
29
+ if (!next || BigInt(next) <= 0n || BigInt(next) > 9223372036854775807n || version && version !== next)
30
+ throw unknown();
31
+ version = next;
32
+ }
33
+ if (!version)
34
+ throw unknown();
35
+ return { version, appName: str(response.app_name), description: str(response.introduce) };
36
+ }
37
+ async preflight() {
38
+ const details = await this.details();
39
+ if (BigInt(details.version) >= BigInt(this.batch.version_code))
40
+ throw new Error('Tencent target version must exceed the existing version');
41
+ return this.preflightRecord({ remote_version_code: details.version,
42
+ metadata: { app_name: details.appName, description: details.description } });
43
+ }
44
+ async execute(step, operationId) {
45
+ if (step === 'authorize') {
46
+ const response = await this.call('get_file_upload_info', { pkg_name: this.batch.package_name,
47
+ app_id: this.target.app_id, file_type: 'apk', file_name: this.target.artifact.filename }, operationId);
48
+ const url = str(response.pre_sign_url);
49
+ const serial = str(response.serial_number);
50
+ let parsed;
51
+ try {
52
+ parsed = new URL(url);
53
+ }
54
+ catch {
55
+ throw unknown();
56
+ }
57
+ if (parsed.protocol !== 'https:' || !parsed.hostname.endsWith('.myqcloud.com') || !parsed.hostname.includes('.cos.') || !serial)
58
+ throw unknown();
59
+ const signedTime = parsed.searchParams.get('q-sign-time')?.split(';') ?? [];
60
+ const end = Number(signedTime[1]);
61
+ if (signedTime.length !== 2 || !Number.isSafeInteger(end) || end <= Date.now() / 1000)
62
+ throw unknown();
63
+ const grant = { url, serial, expires_at: new Date(end * 1000).toISOString() };
64
+ return { ref: serial, material: { value: grant, expiresAt: grant.expires_at } };
65
+ }
66
+ if (step === 'upload') {
67
+ const grant = this.material('authorize');
68
+ if (grant.serial !== this.ref('authorize'))
69
+ throw new Error('Tencent upload grant does not match its receipt');
70
+ await this.http.upload(grant.url, this.target.artifact, operationId);
71
+ return { ref: grant.serial };
72
+ }
73
+ if (step === 'submit') {
74
+ const serial = this.ref('upload');
75
+ const abis = this.target.artifact.abis;
76
+ if (!abis.length || abis.some(abi => !['armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64'].includes(abi)))
77
+ throw new Error('Tencent APK ABI is missing or unsupported');
78
+ const slot = abis.some(abi => ['armeabi-v7a', 'x86'].includes(abi)) ? '32' : '64';
79
+ const fields = { pkg_name: this.batch.package_name, app_id: this.target.app_id,
80
+ feature: this.batch.release_notes, deploy_type: '1', apk32_flag: '2', apk64_flag: '2',
81
+ [`apk${slot}_flag`]: '1', [`apk${slot}_file_serial_number`]: serial,
82
+ [`apk${slot}_file_md5`]: md5File(this.target.artifact.path, this.target.artifact.sha256) };
83
+ await this.call('update_app', fields, operationId);
84
+ return { ref: `ack-${operationId}` };
85
+ }
86
+ throw new Error('unsupported Tencent step');
87
+ }
88
+ async observe(_remoteRef) {
89
+ const response = await this.call('query_app_update_status', { pkg_name: this.batch.package_name, app_id: this.target.app_id });
90
+ const code = decimal(response.audit_status);
91
+ if (!code)
92
+ throw unknown();
93
+ const label = { '1': 'in_review', '2': 'rejected', '3': 'approved', '8': 'withdrawn' };
94
+ return this.observation('unknown', 'unknown', 'unconfirmed', 'tencent_unversioned_audit', undefined, { audit_code: code, audit_status: label[code] ?? 'unknown' });
95
+ }
96
+ }