mkit-cli 0.3.1__py3-none-any.whl

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.
mkit/commands/dev.py ADDED
@@ -0,0 +1,256 @@
1
+ """Everyday development: run on a device, emulators, logs, version bump."""
2
+ import os
3
+ import sys
4
+
5
+ from .. import device as dev
6
+ from .. import kinds, proc, toolchain
7
+ from ..errors import UsageError
8
+ from ..git import BUMP_PREFIX, Repo
9
+ from ..registry import Arg, command
10
+
11
+ DEVICE_ARG = Arg('device', 'adb serial to use when several are attached')
12
+
13
+
14
+ def default_log_tags(project):
15
+ tags = project.get('app.log_tags')
16
+ if tags is not None:
17
+ return list(tags)
18
+ return ['flutter:V', 'AndroidRuntime:E'] if project.kind == 'flutter' else []
19
+
20
+
21
+ @command('run', 'Build, install and launch the app on a device', risk='read',
22
+ args=(Arg('phone', 'use a physical phone', kind='flag', short='p'),
23
+ Arg('emulator', 'use a running emulator', kind='flag', short='e'),
24
+ Arg('clean', 'first-install state: uninstall for every user and wipe backup first',
25
+ kind='flag', short='c'),
26
+ Arg('test', "run the project's tests on the device instead", kind='flag', short='t'),
27
+ Arg('release', 'build the release variant', kind='flag', short='r'),
28
+ Arg('logs', "native apps: follow this app's logcat afterwards "
29
+ "(Shift+R rebuilds, installs and restarts)", kind='flag', short='l'),
30
+ Arg('install', 'Flutter apps: build, install and open without attaching '
31
+ '(no hot reload); used automatically when not at a terminal', kind='flag'),
32
+ DEVICE_ARG),
33
+ examples=('mkit run', 'mkit run -p', 'mkit run -c -p', 'mkit run -r', 'mkit run -t',
34
+ 'mkit run --install -l'),
35
+ description='Flutter apps run through `flutter run`: r hot reload, R hot restart, q quit, '
36
+ 'logs in the same terminal. Native apps are built with Gradle, installed and '
37
+ 'opened; -l then follows logcat, where Shift+R builds and installs the '
38
+ 'current code and restarts the app.')
39
+ def run(ctx, args):
40
+ if args.phone and args.emulator:
41
+ raise UsageError('pass only one of -p or -e')
42
+ out, project = ctx.out, ctx.project
43
+ kind = kinds.for_project(project)
44
+ adb = dev.Adb(project.root)
45
+ serial = dev.pick(adb, 'phone' if args.phone else 'emulator' if args.emulator else None, args.device)
46
+ env = dict(os.environ, ANDROID_SERIAL=serial)
47
+ model = adb.model(serial)
48
+ variant = 'release' if args.release else 'debug'
49
+ package = kind.installed_package(args.release)
50
+ if not args.release and package == project.package:
51
+ # Same id as the store build, and a different signature: Android can
52
+ # only install one over the other by removing it, data and all.
53
+ out.warn(f'debug builds install as {package}, the same package as the Play app, so '
54
+ 'installing replaces it. Give the debug build type an applicationIdSuffix '
55
+ '(".debug") to keep both.')
56
+
57
+ # flutter run stays attached to the terminal, so it is only for a person
58
+ # at one; an agent's shell gets the install-and-open path instead.
59
+ attached = not out.json and sys.stdin.isatty()
60
+ if project.kind == 'flutter' and not args.test and not args.install and attached:
61
+ if args.clean:
62
+ dev.clean_state(adb, serial, package)
63
+ out.line(f'flutter run on {model} ({serial}) r hot reload, R hot restart, q quit', 'bold')
64
+ ctx.exit_code = proc.interactive(
65
+ [toolchain.tool('flutter'), 'run', '-d', serial] + (['--release'] if args.release else []),
66
+ cwd=project.root, env=env)
67
+ return {'device': serial, 'flutter_run': True}
68
+
69
+ if args.test:
70
+ toolchain.find_jdk(project)
71
+ env.update({k: os.environ[k] for k in ('JAVA_HOME', 'ANDROID_HOME') if k in os.environ})
72
+ out.line(f'testing on {model} ({serial})', 'bold')
73
+ ctx.exit_code = kind.test(serial, env)
74
+ return {'device': serial, 'tests': 'passed' if ctx.exit_code == 0 else 'failed'}
75
+
76
+ steps = 4 if args.clean else 3
77
+ out.start(f'{project.name} run', steps, f'{variant} on {model}')
78
+ out.step(f'Build {variant} APK')
79
+ toolchain.find_jdk(project)
80
+ env.update({k: os.environ[k] for k in ('JAVA_HOME', 'ANDROID_HOME') if k in os.environ})
81
+ package, apk = kind.assemble(variant, env)
82
+ out.done(f'{apk.stat().st_size / 1_000_000:.1f} MB')
83
+
84
+ if args.clean:
85
+ out.step('Clean install state')
86
+ removed, transports = dev.clean_state(adb, serial, package)
87
+ out.done(f'removed for {removed} user(s), backup wiped on {transports} transport(s)'
88
+ if removed or transports else 'nothing installed')
89
+
90
+ def install(built):
91
+ result = adb('install', '-r', str(built), device=serial, timeout=600)
92
+ if 'Success' not in result:
93
+ raise UsageError(f'install failed: {result.strip()[-300:]}', code='INSTALL_FAILED',
94
+ hint='If the signatures differ, uninstall the app first (mkit run -c).')
95
+
96
+ out.step('Install')
97
+ install(apk)
98
+ out.done(package)
99
+
100
+ out.step('Launch')
101
+ component = dev.launch(adb, serial, package)
102
+ out.done(component)
103
+ out.finish(f'{package} running on {model}')
104
+ if args.logs:
105
+ def rebuild():
106
+ """What Shift+R does: the same build and install this command just did.
107
+
108
+ Shift+R used to relaunch whatever was already installed, so somebody who had edited
109
+ a file and pressed it watched the log of the code they had changed away from, with
110
+ nothing on screen saying why it had not taken.
111
+ """
112
+ _, built = kind.assemble(variant, env)
113
+ install(built)
114
+
115
+ dev.follow_logs(adb, serial, package, default_log_tags(project), rebuild=rebuild)
116
+ return {'device': serial, 'model': model, 'package': package, 'variant': variant, 'apk': str(apk)}
117
+
118
+
119
+ @command('devices', 'List attached devices and emulators', risk='read', needs_project=False)
120
+ def devices(ctx, args):
121
+ adb = dev.Adb(ctx.project.root if ctx.project else None)
122
+ rows = [{'serial': s, 'state': st, 'type': 'emulator' if dev.is_emulator(s) else 'phone',
123
+ 'model': adb.model(s) if st == 'device' else ''} for s, st in adb.devices()]
124
+ if not ctx.out.json:
125
+ if rows:
126
+ ctx.out.table([(r['serial'], r['type'], r['state'], r['model']) for r in rows],
127
+ ('serial', 'type', 'state', 'model'))
128
+ else:
129
+ ctx.out.line('no devices attached')
130
+ return {'devices': rows}
131
+
132
+
133
+ @command('logs', "Follow the app's logcat (Shift+R restarts the app)", risk='read',
134
+ args=(DEVICE_ARG, Arg('all', "the whole device log, not just this app's", kind='flag'),
135
+ Arg('release', 'the release (Play) install instead of the debug one',
136
+ kind='flag', short='r')),
137
+ description='Follows the debug install by default, the same one `mkit run` puts on the '
138
+ 'device; Shift+R restarts that app. -r switches to the release install.')
139
+ def logs(ctx, args):
140
+ adb = dev.Adb(ctx.project.root)
141
+ serial = dev.pick(adb, None, args.device)
142
+ # The debug install unless asked: it is the one being developed, and the
143
+ # store package used to be restarted by Shift+R here instead.
144
+ package = kinds.for_project(ctx.project).installed_package(args.release)
145
+ dev.follow_logs(adb, serial, package, default_log_tags(ctx.project), everything=args.all)
146
+ return {'device': serial, 'package': package}
147
+
148
+
149
+ @command('emu', 'Start an Android emulator (cold boot) and wait for it', risk='read',
150
+ needs_project=False,
151
+ args=(Arg('avd', 'emulator name (default: ask, or the only one)', kind='positional', nargs='?'),
152
+ Arg('list', 'only list the emulators', kind='flag')),
153
+ examples=('mkit emu', 'mkit emu Pixel_8_API_35', 'mkit emu --list'))
154
+ def emu(ctx, args):
155
+ root = ctx.project.root if ctx.project else None
156
+ exe, names = dev.avds(root)
157
+ out = ctx.out
158
+ if args.list or not names:
159
+ if not out.json:
160
+ for n in names:
161
+ out.line(f' {n}')
162
+ if not names:
163
+ out.line("no emulators; create one in Android Studio's Device Manager")
164
+ return {'avds': names}
165
+ if args.avd:
166
+ if args.avd not in names:
167
+ raise UsageError(f'unknown emulator "{args.avd}"; have: {", ".join(names)}')
168
+ avd = args.avd
169
+ elif len(names) == 1 or out.json:
170
+ avd = names[0]
171
+ else:
172
+ for i, n in enumerate(names, 1):
173
+ out.line(f' {i}) {n}')
174
+ choice = input(f'\nWhich one? (1-{len(names)}, Enter for 1) ').strip() or '1'
175
+ try:
176
+ avd = names[int(choice) - 1]
177
+ except (ValueError, IndexError):
178
+ raise UsageError(f'not one of the choices: {choice}') from None
179
+ out.start('emulator', 1, avd)
180
+ out.step(f'Boot {avd}')
181
+ serial = dev.start_emulator(dev.Adb(root), exe, avd, root)
182
+ out.done(serial)
183
+ out.finish(f'{avd} ready')
184
+ return {'avd': avd, 'serial': serial}
185
+
186
+
187
+ @command('studio', 'Open this app in Android Studio', risk='read',
188
+ args=(Arg('android', 'Flutter apps: open the android/ Gradle project instead',
189
+ kind='flag', short='a', long=False),),
190
+ examples=('mkit studio', 'mkit studio -a'),
191
+ description="Opens the folder Android Studio should have: the repo root for a Flutter "
192
+ "app, where the Flutter plugin gives Dart analysis, hot reload and the "
193
+ "device list, with android/ indexed underneath. -a opens android/ on its "
194
+ "own instead, a plain Gradle project for native work (manifests, AGP, "
195
+ "Gradle plugins) with no Dart support. Native apps open at their Gradle "
196
+ "root either way. Studio is left running when mkit exits.")
197
+ def studio(ctx, args):
198
+ project = ctx.project
199
+ kind = kinds.for_project(project)
200
+ folder = kind.android_dir if args.android else project.root
201
+ exe = toolchain.studio(project)
202
+ if ctx.dry_run:
203
+ if not ctx.out.json:
204
+ ctx.out.line(f'would open {folder} in {exe}')
205
+ return {'folder': str(folder), 'studio': exe, 'pid': None, 'dry_run': True}
206
+ pid = proc.detach([exe, str(folder)], cwd=project.root)
207
+ if not ctx.out.json:
208
+ where = folder if folder == project.root else folder.relative_to(project.root)
209
+ ctx.out.line(f'Android Studio opening {where}')
210
+ return {'folder': str(folder), 'studio': exe, 'pid': pid}
211
+
212
+
213
+ @command('version', "Show this app's version, as the version file has it", risk='read')
214
+ def version(ctx, args):
215
+ kind = kinds.for_project(ctx.project)
216
+ name, code = kind.current_version()
217
+ if not ctx.out.json:
218
+ ctx.out.line(f'{name} ({code}) {kind.version.path.relative_to(ctx.project.root)}')
219
+ return {'versionName': name, 'versionCode': code, 'file': str(kind.version.path)}
220
+
221
+
222
+ @command('bump', 'Bump the version name and code (and commit it, unless --no-commit)', risk='write',
223
+ args=(Arg('level', 'which part of the name', kind='positional', nargs='?',
224
+ choices=('major', 'minor', 'patch'), default='patch'),
225
+ Arg('no_commit', 'change the file only', kind='flag')),
226
+ examples=('mkit bump', 'mkit bump minor', 'mkit bump --no-commit'))
227
+ def bump(ctx, args):
228
+ kind = kinds.for_project(ctx.project)
229
+ old_name, old_code, name, code = kind.bump(args.level)
230
+ committed = None
231
+ if not args.no_commit:
232
+ repo = Repo(ctx.project.root)
233
+ committed = repo.commit_files([kind.version.path],
234
+ f'{BUMP_PREFIX}{name} (versionCode {code})')[:8]
235
+ if not ctx.out.json:
236
+ ctx.out.line(f'{old_name} ({old_code}) -> {name} ({code})' + (f' commit {committed}' if committed else ''))
237
+ return {'from': {'versionName': old_name, 'versionCode': old_code},
238
+ 'to': {'versionName': name, 'versionCode': code}, 'commit': committed}
239
+
240
+
241
+ @command('gradle', "Run Gradle tasks with the right JDK and folder (lint, clean, test, profiles...)",
242
+ risk='write',
243
+ args=(Arg('tasks', 'Gradle tasks and flags, passed through as-is', kind='positional', nargs='...'),),
244
+ examples=('mkit gradle :app:lintDebug', 'mkit gradle clean', 'mkit gradle :app:testDebugUnitTest',
245
+ 'mkit gradle :app:generateBaselineProfile'))
246
+ def gradle(ctx, args):
247
+ """Anything a project did through Gradle before, with the JDK mkit picks and
248
+ from the Gradle root (android/ for Flutter apps). Output streams as-is."""
249
+ if not args.tasks:
250
+ raise UsageError('name at least one Gradle task, e.g. mkit gradle :app:lintDebug')
251
+ kind = kinds.for_project(ctx.project)
252
+ jdk = toolchain.find_jdk(ctx.project)
253
+ env = dict(os.environ, JAVA_HOME=jdk)
254
+ ctx.exit_code = proc.interactive([toolchain.gradlew(kind.android_dir), *args.tasks],
255
+ cwd=kind.android_dir, env=env)
256
+ return {'tasks': args.tasks, 'exit': ctx.exit_code}
@@ -0,0 +1,191 @@
1
+ """`mkit doctor`: what this project uses, and whether each part can work.
2
+
3
+ Every feature in mkit.toml is shown as one of:
4
+
5
+ on switched on and everything it needs is present
6
+ broken switched on but a credential or tool is missing; steps using it
7
+ are skipped in pipelines and fail when asked for directly
8
+ off not in mkit.toml; this app does not use it
9
+
10
+ Credentials are opened and parsed, and the non-secret parts (the service
11
+ account email and its Cloud project) are shown, because "the key is there but
12
+ it belongs to another project" is the mistake worth catching early.
13
+ """
14
+ import os
15
+ import platform
16
+ import shutil
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from .. import __version__, config
21
+ from ..errors import FAILED, MkitError
22
+ from ..registry import command
23
+
24
+ # feature -> credentials it cannot work without
25
+ FEATURE_CREDENTIALS = {
26
+ 'play': ('PLAY_SERVICE_ACCOUNT',),
27
+ 'firebase': ('FIREBASE_SERVICE_ACCOUNT',),
28
+ 'notify.telegram': ('TELEGRAM',),
29
+ }
30
+
31
+ # Store keys with **no default**, and the commands that cannot run without
32
+ # them. Only these: `listing_dir` falls back to docs/store-listing, `art_dir` to
33
+ # store/ (or [art] out), and `icon` to the art folder's own icon.png, so their
34
+ # absence blocks nothing. The first version of this check listed all five and
35
+ # told two apps their art was broken when it was not.
36
+ #
37
+ # Not a problem either, even for these three: an app with no in-app purchases
38
+ # has no products file on purpose. It is a note about what this app cannot do.
39
+ STORE_KEYS = {
40
+ 'whatsnew_dir': "whatsnew check, and release notes on deploy",
41
+ 'datasafety': 'datasafety build/check/apply',
42
+ 'products': 'products plan/apply',
43
+ }
44
+
45
+ FEATURE_WHAT = {
46
+ 'play': 'Google Play: upload, tracks, listing, products, reviews, vitals',
47
+ 'store': 'store listing, art, what\'s new and data safety files',
48
+ 'firebase': 'Firestore rules, update doc, Remote Config, symbols',
49
+ 'notify.telegram': 'Telegram message after deploy and promote',
50
+ 'aso': 'keyword rank and winnability checks',
51
+ 'hooks': 'project-specific scripts at fixed pipeline points',
52
+ 'art': 'Play screenshots and the feature graphic, drawn from device captures',
53
+ }
54
+
55
+
56
+ def sdk_dir(project=None):
57
+ for key in ('ANDROID_HOME', 'ANDROID_SDK_ROOT'):
58
+ if os.environ.get(key):
59
+ return Path(os.environ[key])
60
+ if project:
61
+ for props in (project.root / 'local.properties', project.root / 'android' / 'local.properties'):
62
+ if props.is_file():
63
+ for line in props.read_text(encoding='utf-8', errors='replace').splitlines():
64
+ if line.startswith('sdk.dir='):
65
+ return Path(line[8:].replace('\\:', ':').replace('\\\\', '\\'))
66
+ guess = Path.home() / ('AppData/Local/Android/Sdk' if os.name == 'nt' else 'Android/Sdk')
67
+ return guess if guess.is_dir() else None
68
+
69
+
70
+ def find_tool(name, project=None):
71
+ if name == 'adb':
72
+ # The SDK's adb first: other tools (scrcpy, phone suites) ship their own
73
+ # adb on PATH, and two adb versions keep killing each other's server.
74
+ sdk = sdk_dir(project)
75
+ if sdk:
76
+ exe = sdk / 'platform-tools' / ('adb.exe' if os.name == 'nt' else 'adb')
77
+ if exe.is_file():
78
+ return str(exe)
79
+ return shutil.which(name)
80
+
81
+
82
+ def _credential(ctx, name):
83
+ source = ctx.secrets.source(name)
84
+ if not source:
85
+ return {'name': name, 'status': 'missing'}
86
+ try:
87
+ data = ctx.secrets.json(name)
88
+ except MkitError as e:
89
+ return {'name': name, 'status': 'unreadable', 'source': source, 'error': e.message}
90
+ info = {'name': name, 'status': 'ok', 'source': source}
91
+ for key in ('client_email', 'project_id'):
92
+ if data.get(key):
93
+ info[key] = data[key]
94
+ if name == 'TELEGRAM' and not (data.get('bot_token') and data.get('chat_id')):
95
+ info.update(status='unreadable', error='needs bot_token and chat_id')
96
+ return info
97
+
98
+
99
+ @command('doctor', 'Check tools, settings and credentials for this project', risk='read',
100
+ needs_project=False, examples=('mkit doctor', 'mkit doctor --json'))
101
+ def doctor(ctx, args):
102
+ out = ctx.out
103
+ project = ctx.project
104
+ report = {
105
+ 'engine': {'version': __version__, 'python': platform.python_version(),
106
+ 'executable': sys.executable, 'home': str(config.home())},
107
+ 'project': None, 'tools': [], 'features': [], 'problems': [], 'unavailable': [],
108
+ }
109
+
110
+ if project is None:
111
+ report['problems'].append('no mkit.toml here or above; run `mkit init` in the app folder')
112
+ else:
113
+ report['project'] = {'root': str(project.root), 'name': project.name,
114
+ 'kind': project.kind, 'package': project.package}
115
+ for key in ('app.kind', 'app.package'):
116
+ if not project.get(key):
117
+ report['problems'].append(f'{key} is not set in mkit.toml')
118
+
119
+ wanted = ['git', 'adb']
120
+ if project and project.kind == 'flutter':
121
+ wanted.append('flutter')
122
+ if project and project.section('firebase').get('rules'):
123
+ wanted.append('firebase')
124
+ for tool in wanted:
125
+ path = find_tool(tool, project)
126
+ report['tools'].append({'name': tool, 'path': path, 'status': 'ok' if path else 'missing'})
127
+ if not path:
128
+ report['problems'].append(f'{tool} not found on PATH')
129
+
130
+ if project:
131
+ for feature in config.FEATURES:
132
+ entry = {'feature': feature, 'what': FEATURE_WHAT.get(feature, ''), 'credentials': []}
133
+ if not project.has(feature):
134
+ entry['status'] = 'off'
135
+ else:
136
+ creds = [_credential(ctx, n) for n in FEATURE_CREDENTIALS.get(feature, ())]
137
+ entry['credentials'] = creds
138
+ bad = [c for c in creds if c['status'] != 'ok']
139
+ entry['status'] = 'broken' if bad else 'on'
140
+ if feature == 'store':
141
+ missing = [k for k in STORE_KEYS if not project.get(f'store.{k}')]
142
+ entry['missing'] = missing
143
+ for key in missing:
144
+ report['unavailable'].append(
145
+ f'[store] has no {key}, so `mkit {STORE_KEYS[key]}` cannot run')
146
+ for c in bad:
147
+ report['problems'].append(
148
+ f'[{feature}] {c["name"]} is {c["status"]}'
149
+ + (f': {c["error"]}' if c.get('error') else
150
+ f'; add MKIT_{c["name"]}_JSON_BASE64 to .env'))
151
+ report['features'].append(entry)
152
+
153
+ if not out.json:
154
+ _print_report(out, report)
155
+ if report['problems']:
156
+ ctx.exit_code = FAILED
157
+ return report
158
+
159
+
160
+ def _print_report(out, report):
161
+ eng = report['engine']
162
+ out.heading(f'mkit {eng["version"]}')
163
+ out.line(f' python {eng["python"]} user settings {eng["home"]}', 'dim')
164
+ proj = report['project']
165
+ if proj:
166
+ out.heading(f'{proj["name"]} {proj["kind"] or "?"} {proj["package"] or "?"}')
167
+ out.line(f' {proj["root"]}', 'dim')
168
+ out.heading('tools')
169
+ out.table([(t['name'], t['status'], t['path'] or '') for t in report['tools']],
170
+ ('tool', 'status', 'path'))
171
+ if report['features']:
172
+ out.heading('features')
173
+ rows = []
174
+ for f in report['features']:
175
+ creds = ', '.join(
176
+ f'{c["name"]}: {c["status"]}' + (f' ({c["client_email"]})' if c.get('client_email') else '')
177
+ for c in f['credentials'])
178
+ rows.append((f['feature'], f['status'], creds or f['what']))
179
+ out.table(rows, ('feature', 'status', 'details'))
180
+ out.line()
181
+ if report.get('unavailable'):
182
+ out.line('not configured, so these cannot run:', 'dim')
183
+ for note in report['unavailable']:
184
+ out.line(f' {note}', 'dim')
185
+ out.line()
186
+ if report['problems']:
187
+ out.line(f'{len(report["problems"])} problem(s):', 'yellow')
188
+ for p in report['problems']:
189
+ out.line(f' - {p}', 'yellow')
190
+ else:
191
+ out.line('All good.', 'green')
@@ -0,0 +1,194 @@
1
+ """Remote Config, Firebase app settings, internal app sharing."""
2
+ import json
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from .. import kinds, safety, toolchain
8
+ from ..cloud import auth, fbproject
9
+ from ..errors import DRIFT, MkitError, UsageError
10
+ from ..registry import Arg, command, group
11
+
12
+ group('remoteconfig', 'Firebase Remote Config template')
13
+ group('firebase', 'Firebase app settings (SHA fingerprints, google-services.json)')
14
+
15
+
16
+ @command('remoteconfig pull', 'Save the live Remote Config template to the template file', risk='write',
17
+ needs=('firebase',))
18
+ def rc_pull(ctx, args):
19
+ template, etag = fbproject.rc_get(ctx)
20
+ path = fbproject.template_path(ctx)
21
+ body = {k: template[k] for k in fbproject.TEMPLATE_KEYS if k in template}
22
+ path.write_text(json.dumps(body, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
23
+ version = template.get('version', {}).get('versionNumber')
24
+ if not ctx.out.json:
25
+ ctx.out.line(f'version {version}: {len(body.get("parameters", {}))} parameter(s) -> {path}')
26
+ return {'file': str(path), 'version': version}
27
+
28
+
29
+ @command('remoteconfig plan', 'Compare the template file with the live Remote Config', risk='plan',
30
+ needs=('firebase',))
31
+ def rc_plan(ctx, args):
32
+ remote, _ = fbproject.rc_get(ctx)
33
+ changes = fbproject.rc_diff(remote, fbproject.local_template(ctx))
34
+ if not ctx.out.json:
35
+ for c in changes:
36
+ ctx.out.line(f' {c["change"]:7} {c["parameter"]}')
37
+ if not changes:
38
+ ctx.out.line(' no changes', 'green')
39
+ if changes:
40
+ ctx.exit_code = DRIFT
41
+ return {'version': remote.get('version', {}).get('versionNumber'), 'changes': changes}
42
+
43
+
44
+ @command('remoteconfig apply', 'Publish the template file as the live Remote Config', risk='publish',
45
+ needs=('firebase',))
46
+ def rc_apply(ctx, args):
47
+ remote, etag = fbproject.rc_get(ctx)
48
+ local = fbproject.local_template(ctx)
49
+ changes = fbproject.rc_diff(remote, local)
50
+ if not changes:
51
+ return {'changes': [], 'changed': False}
52
+ safety.confirm(ctx, f'Publish Remote Config ({len(changes)} change(s)) to {fbproject.project_id(ctx)}',
53
+ [f'{c["change"]} {c["parameter"]}' for c in changes])
54
+ version = fbproject.rc_publish(ctx, local, etag, validate_only=ctx.dry_run)
55
+ if not ctx.out.json:
56
+ ctx.out.line('validated, not published (dry run)' if ctx.dry_run
57
+ else f'published version {version.get("versionNumber")}', 'green')
58
+ return {'changes': changes, 'changed': not ctx.dry_run, 'version': version.get('versionNumber')}
59
+
60
+
61
+ @command('remoteconfig versions', 'Recent Remote Config versions', risk='read', needs=('firebase',),
62
+ args=(Arg('limit', 'how many', type=int, default=10),))
63
+ def rc_versions(ctx, args):
64
+ versions = fbproject.rc_versions(ctx, args.limit)
65
+ if not ctx.out.json:
66
+ ctx.out.table([(v.get('versionNumber'), v.get('updateTime', '')[:19], v.get('updateType', ''),
67
+ (v.get('updateUser') or {}).get('email', ''), v.get('description', ''))
68
+ for v in versions], ('version', 'time', 'type', 'by', 'description'))
69
+ return {'versions': versions}
70
+
71
+
72
+ @command('remoteconfig rollback', 'Make an earlier Remote Config version live again', risk='publish',
73
+ needs=('firebase',), args=(Arg('version', 'version number', kind='positional', type=int),))
74
+ def rc_rollback(ctx, args):
75
+ safety.confirm(ctx, f'Roll Remote Config back to version {args.version}')
76
+ if ctx.dry_run:
77
+ return {'version': args.version, 'dry_run': True}
78
+ version = fbproject.rc_rollback(ctx, args.version)
79
+ return {'version': version.get('versionNumber'), 'rolledBackTo': args.version}
80
+
81
+
82
+ @command('firebase sha', 'SHA fingerprints registered for this app in Firebase', risk='read',
83
+ needs=('firebase',))
84
+ def fb_sha(ctx, args):
85
+ app, certs = fbproject.sha_list(ctx)
86
+ if not ctx.out.json:
87
+ ctx.out.line(f'{app.get("displayName") or app["packageName"]} ({app["appId"]})', 'bold')
88
+ for c in certs:
89
+ ctx.out.line(f' {c.get("certType")} {c.get("shaHash")}')
90
+ ctx.out.line('Add the Play App Signing key too: Play Console > Setup > App signing.', 'dim')
91
+ return {'appId': app['appId'], 'certificates': certs}
92
+
93
+
94
+ @command('firebase sha-add', 'Register a signing SHA-1/SHA-256 with Firebase (then pull the config)',
95
+ risk='write', needs=('firebase',), args=(Arg('sha', 'fingerprint, with or without colons', kind='positional'),))
96
+ def fb_sha_add(ctx, args):
97
+ result = fbproject.sha_add(ctx, args.sha)
98
+ if not ctx.out.json:
99
+ ctx.out.line(('added ' if result['changed'] else 'already registered: ') + result['certType'])
100
+ return result
101
+
102
+
103
+ def _write_config(ctx, out=None):
104
+ """Pull google-services.json into the app module. It lists every Android app
105
+ in the project, so it carries the debug client too once that exists."""
106
+ text = fbproject.config_file(ctx)
107
+ kind = kinds.for_project(ctx.project)
108
+ module_dir = kind.android_dir / ('app' if ctx.project.kind == 'flutter' else kind.module)
109
+ path = ctx.project.root / out if out else module_dir / 'google-services.json'
110
+ changed = not path.is_file() or path.read_text(encoding='utf-8') != text
111
+ if changed:
112
+ path.write_text(text, encoding='utf-8')
113
+ return path, changed
114
+
115
+
116
+ @command('firebase config', 'Download google-services.json for this app', risk='write', needs=('firebase',),
117
+ args=(Arg('out', 'where to write it (default: the Android app module)'),))
118
+ def fb_config(ctx, args):
119
+ path, changed = _write_config(ctx, args.out)
120
+ if not ctx.out.json:
121
+ ctx.out.line(f'{"wrote" if changed else "unchanged"} {path}')
122
+ return {'file': str(path), 'changed': changed}
123
+
124
+
125
+ def _debug_key_fingerprints(ctx):
126
+ """SHA-1 and SHA-256 of this computer's Android debug key, or {} without one."""
127
+ keystore = Path.home() / '.android' / 'debug.keystore'
128
+ if not keystore.is_file():
129
+ return {}
130
+ jdk = Path(toolchain.find_jdk(ctx.project))
131
+ keytool = jdk / 'bin' / ('keytool.exe' if os.name == 'nt' else 'keytool')
132
+ result = subprocess.run([str(keytool), '-list', '-v', '-keystore', str(keystore),
133
+ '-alias', 'androiddebugkey', '-storepass', 'android', '-keypass', 'android'],
134
+ capture_output=True, text=True, timeout=60)
135
+ return fbproject.parse_fingerprints(result.stdout) if result.returncode == 0 else {}
136
+
137
+
138
+ @command('firebase debug-app', "Register the debug build's package with Firebase, then pull the config",
139
+ risk='write', needs=('firebase',),
140
+ description='For a debug build type with an applicationIdSuffix: Firebase, and the '
141
+ 'google-services Gradle plugin, only accept packages registered in the '
142
+ "project. Registers it when missing, adds this computer's debug key "
143
+ '(SHA-1 and SHA-256, for Google sign-in) and writes google-services.json. '
144
+ 'Safe to run again.')
145
+ def fb_debug_app(ctx, args):
146
+ out, project = ctx.out, ctx.project
147
+ package = kinds.for_project(project).debug_package()
148
+ if package == project.package:
149
+ raise UsageError(f'debug builds install as {package}, the store package, so there is nothing '
150
+ 'to register',
151
+ hint='Give the debug build type an applicationIdSuffix (".debug") first.',
152
+ code='NO_DEBUG_SUFFIX')
153
+ app, created = fbproject.ensure_android_app(ctx, package, f'{project.name} (debug)')
154
+ certificates = []
155
+ for cert_type, sha in _debug_key_fingerprints(ctx).items():
156
+ certificates.append(fbproject.sha_add(ctx, sha, package))
157
+ path, changed = _write_config(ctx)
158
+ if not out.json:
159
+ out.line(f'{"registered" if created else "already registered"} {package}')
160
+ for c in certificates:
161
+ out.line(f' {c["certType"]} {"added" if c["changed"] else "already there"}')
162
+ if not certificates:
163
+ out.line(' no debug key found in ~/.android; Google sign-in in debug builds needs one', 'dim')
164
+ out.line(f'{"wrote" if changed else "unchanged"} {path}')
165
+ return {'package': package, 'appId': app.get('appId'), 'created': created,
166
+ 'certificates': certificates, 'file': str(path), 'config_changed': changed}
167
+
168
+
169
+ @command('share', 'Build the release bundle and get an internal app sharing link', risk='write',
170
+ needs=('play',), args=(Arg('no_build', 'upload the bundle already built', kind='flag'),))
171
+ def share(ctx, args):
172
+ kind = kinds.for_project(ctx.project)
173
+ out = ctx.out
174
+ out.start(f'{ctx.project.name} share', 2, 'internal app sharing')
175
+ out.step('Build release bundle')
176
+ if not args.no_build:
177
+ jdk = toolchain.find_jdk(ctx.project)
178
+ kind.bundle(dict(os.environ, JAVA_HOME=jdk))
179
+ module = kind.modules()[0]
180
+ if not module.aab.is_file():
181
+ raise MkitError(f'no bundle at {module.aab}', code='NO_BUNDLE')
182
+ out.done(f'{module.aab.stat().st_size / 1_000_000:.1f} MB')
183
+ out.step('Upload')
184
+ from googleapiclient.http import MediaFileUpload
185
+ svc = auth.play(ctx)
186
+ try:
187
+ result = svc.internalappsharingartifacts().uploadbundle(
188
+ packageName=ctx.project.package,
189
+ media_body=MediaFileUpload(str(module.aab), mimetype='application/octet-stream')).execute()
190
+ except Exception as e:
191
+ raise auth.translate(e, 'Play') from None
192
+ out.done()
193
+ out.finish(result.get('downloadUrl', ''))
194
+ return {'downloadUrl': result.get('downloadUrl'), 'sha256': result.get('sha256')}