Repository navigation
feat(updates): add verified updates and native announcements #1107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
54f5584
feat(updates): add verified updates and reviewed announcements
ashwkun 6ea1f2b
ci(release): preserve authored notes and add announcement review tooling
ashwkun b5a2ee7
chore: split release tooling into a prerequisite review
ashwkun df2b4bf
fix(admin): isolate announcement preview frame sizing
ashwkun 238f149
Merge branch 'one/release-review-tools' into one/release-publishing
ashwkun f398dde
fix(admin): refresh cached preview assets
ashwkun 99dbe04
Merge branch 'one/release-review-tools' into one/release-publishing
ashwkun 650c46b
fix(release): align copy validation and secure announcement previews
ashwkun 57c4dc0
Merge branch 'one/release-review-tools' into one/release-publishing
ashwkun 4373de7
refactor(prefs): split storage helpers and preserve persisted contracts
ashwkun 945e63a
fix(release): resolve publishing and updater review findings
ashwkun 7af30e5
fix(release): reject duplicate impact labels before normalization
ashwkun d2735f9
Merge branch 'one/release-review-tools' into one/release-publishing
ashwkun cf07af7
refactor(updates): simplify Markdown and appearance hydration
ashwkun 8a26ca7
chore: reconcile stacked release PR with master
ashwkun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| #!/usr/bin/env python3 | ||
| """Create the direct-update manifest from already-verified release inputs. No publication.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import hashlib | ||
| import html | ||
| import json | ||
| import re | ||
| from pathlib import Path | ||
|
|
||
| from prepare_release import read_app_version | ||
| import update_changelog | ||
|
|
||
|
|
||
| def listener_markdown(fragment: str) -> str: | ||
| """Preserve authored emphasis and links from the generated listener HTML.""" | ||
| fragment = re.sub(r'<a\b[^>]*href=[\'"]([^\'"]+)[\'"][^>]*>(.*?)</a>', | ||
| lambda m: '[' + re.sub(r'<[^>]+>', '', m[2]) + '](' + html.unescape(m[1]) + ')', | ||
| fragment, flags=re.DOTALL | re.IGNORECASE) | ||
| for tag, marker in (("b", "**"), ("strong", "**"), ("i", "*"), ("em", "*"), ("code", "`")): | ||
| fragment = re.sub(r'</?' + tag + r'\b[^>]*>', lambda _: marker, fragment, flags=re.IGNORECASE) | ||
| fragment = re.sub(r'<br\s*/?>', '\n', fragment, flags=re.IGNORECASE) | ||
| return html.unescape(re.sub(r'<[^>]+>', '', fragment)).strip() | ||
|
|
||
|
|
||
| def listener_notes(readme: str, tag: str | None = None) -> str: | ||
| region = update_changelog._extract_marked_region( | ||
| readme, update_changelog.RELEASE_WHATS_NEW_START, | ||
| update_changelog.RELEASE_WHATS_NEW_END, | ||
| ) | ||
| if not region: | ||
| return "" | ||
| metadata = update_changelog._parse_release_meta(region) | ||
| if tag and metadata and metadata[0] != tag: | ||
| return "" # An artifacts-only release must not claim the previous release's notes. | ||
| # Preserve every authored listener bullet, without shortening or rewriting. | ||
| return "\n".join("- " + listener_markdown(bullet) for bullet in re.findall( | ||
| r"<li\b[^>]*>(.*?)</li>", region, flags=re.DOTALL, | ||
| )) | ||
|
|
||
|
|
||
| def upcoming_notes(readme: str) -> str: | ||
| region = update_changelog._extract_marked_region( | ||
| readme, update_changelog.RELEASE_UPCOMING_START, update_changelog.RELEASE_UPCOMING_END, | ||
| ) or "" | ||
| parts = [] | ||
| for match in re.finditer(r'<b\b[^>]*>(.*?)</b>|<li\b[^>]*>(.*?)</li>', region, flags=re.DOTALL): | ||
| if match[1] is not None: | ||
| parts.append("\n## " + listener_markdown(match[1]).rstrip(":")) | ||
| else: | ||
| parts.append("- " + listener_markdown(match[2])) | ||
| value = "\n".join(parts).strip() if "<li" in region else "" | ||
| if len(value.encode("utf-16-le")) // 2 > 6_000: | ||
| raise ValueError("Upcoming preview exceeds 6,000 characters") | ||
| return value | ||
|
|
||
|
|
||
| def build_manifest(gradle: str, apk: Path, asset: str, notes: str = "") -> dict: | ||
| version = read_app_version(gradle) | ||
| if not re.fullmatch(r"[A-Za-z0-9_.-]+\.apk", asset): | ||
| raise ValueError("APK asset must be a plain .apk filename") | ||
| sdk = re.search(r"\bminSdk\s*=\s*(\d+)", gradle) | ||
| if sdk is None or int(sdk.group(1)) < 31: | ||
| raise ValueError("Could not resolve supported minimum Android version") | ||
| size = apk.stat().st_size | ||
| if not 0 < size <= 512 * 1024 * 1024: | ||
| raise ValueError("APK is missing, empty or larger than the updater limit") | ||
| if len(notes.encode("utf-16-le")) // 2 > 24_000: | ||
| raise ValueError("Release notes exceed 24,000 characters; supply concise reviewed copy") | ||
| with apk.open("rb") as binary: | ||
| digest = hashlib.file_digest(binary, "sha256").hexdigest() | ||
| base = "https://github.com/boxcreate/boxlore/releases" | ||
| manifest = { | ||
| "schemaVersion": 1, "packageName": "cx.aswin.boxlore", | ||
| "versionCode": version.code, "versionName": version.name, | ||
| "minSdk": int(sdk.group(1)), "apkUrl": f"{base}/download/{version.tag}/{asset}", | ||
| "apkSha256": digest, "apkBytes": size, "notesUrl": f"{base}/tag/{version.tag}", | ||
| "notes": notes, | ||
| } | ||
| if len(encode_manifest(manifest)) > 64 * 1024: | ||
| raise ValueError("Update manifest exceeds 64 KiB; shorten the reviewed release notes") | ||
| return manifest | ||
|
|
||
|
|
||
| def encode_manifest(manifest: dict) -> bytes: | ||
| return (json.dumps(manifest, indent=2, ensure_ascii=False) + "\n").encode("utf-8") | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--apk", required=True, type=Path) | ||
| parser.add_argument("--asset", required=True) | ||
| parser.add_argument("--gradle", type=Path, default=Path("app/build.gradle.kts")) | ||
| parser.add_argument("--readme", type=Path, default=Path("README.md")) | ||
| parser.add_argument("--output", required=True, type=Path) | ||
| args = parser.parse_args() | ||
| try: | ||
| gradle = args.gradle.read_text() | ||
| manifest = build_manifest(gradle, args.apk, args.asset, | ||
| listener_notes(args.readme.read_text(), read_app_version(gradle).tag)) | ||
| manifest["upcoming"] = upcoming_notes(args.readme.read_text()) | ||
| encoded = encode_manifest(manifest) | ||
| if len(encoded) > 64 * 1024: | ||
| raise ValueError("Update manifest exceeds 64 KiB with upcoming preview") | ||
| args.output.write_bytes(encoded) | ||
| except (OSError, ValueError) as error: | ||
| parser.exit(1, f"Update manifest not prepared: {error}. Fix the candidate and rerun publication; no update announcement was sent.\n") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,77 +1,166 @@ | ||
| """Preview, validate or send a legacy-compatible manual announcement. | ||
|
|
||
| Preview mode has no Firebase dependency. Test mode only accepts debug_users or test_users; | ||
| it never silently redirects a request intended for a production audience. | ||
| """ | ||
|
|
||
| import argparse | ||
| import os | ||
| import hashlib | ||
| import json | ||
| import firebase_admin | ||
| from firebase_admin import credentials | ||
| from firebase_admin import messaging | ||
| import os | ||
| import sys | ||
| from pathlib import Path | ||
| from urllib.parse import urlparse | ||
|
|
||
| def send_notification(args): | ||
| creds_json = os.environ.get('FIREBASE_CREDENTIALS') | ||
| if not creds_json: | ||
| raise ValueError("FIREBASE_CREDENTIALS environment variable is not set") | ||
|
|
||
| cred_dict = json.loads(creds_json) | ||
| cred = credentials.Certificate(cred_dict) | ||
| firebase_admin.initialize_app(cred) | ||
|
|
||
| decoded_body = args.body.replace('\\n', '\n') | ||
|
|
||
| data_payload = { | ||
| 'title': args.title, | ||
| 'body': decoded_body, | ||
| 'type': args.type, | ||
| 'sound': getattr(args, 'sound', 'default'), | ||
| 'action_label': getattr(args, 'action_label', 'View'), | ||
| 'show_action_in_push': getattr(args, 'show_action_in_push', 'true'), | ||
| 'show_action_in_app': getattr(args, 'show_action_in_app', 'true'), | ||
| 'category': getattr(args, 'category', "WHAT'S NEW") | ||
| TOPIC_PAYLOAD_LIMIT = 2048 # FCM counts UTF-8 data keys and values for topics. | ||
| AUDIENCES = ("all_users", "debug_users", "prod_users", "test_users", "direct_users") | ||
|
|
||
|
|
||
| def boolean(value, field): | ||
| if value not in ("true", "false"): | ||
| raise ValueError(f"{field} must be true or false") | ||
| return value == "true" | ||
|
|
||
|
|
||
| def notification_plan(args): | ||
| """Pure validation and payload construction, shared by previews and sends.""" | ||
| topic = getattr(args, "target", "all_users") | ||
| if topic not in AUDIENCES: | ||
| raise ValueError("Choose a supported announcement audience") | ||
| test_mode = boolean(getattr(args, "test_mode", "false"), "test_mode") | ||
| if test_mode and topic not in ("debug_users", "test_users"): | ||
| raise ValueError("Test mode only permits debug_users or test_users. Change the target before retrying.") | ||
| if args.type not in ("push", "in-app", "both"): | ||
| raise ValueError("Message type must be push, in-app or both") | ||
| if not args.title.strip() or not args.body.strip(): | ||
| raise ValueError("Add a title and message before previewing or sending") | ||
| if getattr(args, "image", ""): | ||
| image = urlparse(args.image) | ||
| if image.scheme != "https" or not image.netloc or image.username or image.password: | ||
| raise ValueError("Use an HTTPS image URL without embedded credentials") | ||
| sound = getattr(args, "sound", "default") | ||
| if sound not in ("default", "chime", "announcement", "silent"): | ||
| raise ValueError("Choose a supported notification sound") | ||
| data = { | ||
| "title": args.title, | ||
| "body": args.body.replace("\\n", "\n"), | ||
| "type": args.type, | ||
| "sound": sound, | ||
| "action_label": getattr(args, "action_label", "View"), | ||
| "show_action_in_push": getattr(args, "show_action_in_push", "true"), | ||
| "show_action_in_app": getattr(args, "show_action_in_app", "true"), | ||
| "category": getattr(args, "category", "ANNOUNCEMENT"), | ||
| } | ||
|
|
||
| if args.route: | ||
| data_payload['route'] = args.route | ||
|
|
||
| if getattr(args, 'image', None): | ||
| data_payload['image'] = args.image | ||
|
|
||
| topic = getattr(args, 'target', 'all_users') | ||
|
|
||
| android_config = None | ||
| collapse_key = getattr(args, 'collapse_key', '') | ||
| if collapse_key: | ||
| android_config = messaging.AndroidConfig(collapse_key=collapse_key) | ||
|
|
||
| message = messaging.Message( | ||
| data=data_payload, | ||
| topic=topic, | ||
| android=android_config | ||
| for field in ("show_action_in_push", "show_action_in_app"): | ||
| boolean(data[field], field) | ||
| if getattr(args, "release_url", ""): | ||
| url = urlparse(args.release_url) | ||
| if url.scheme != "https" or url.netloc != "github.com" or url.query or url.fragment or not (url.path == "/boxcreate/boxlore/releases/latest" or url.path.startswith("/boxcreate/boxlore/releases/tag/")): | ||
| raise ValueError("Use a boxlore GitHub release page") | ||
| for field in ("route", "image"): | ||
| if getattr(args, field, ""): | ||
| data[field] = getattr(args, field) | ||
| for field, choices, default in ( | ||
| ("presentation", ("compact", "fullscreen"), "compact"), | ||
| ("tone", ("primary", "secondary", "tertiary", "error"), "primary"), | ||
| ("image_style", ("banner", "cover"), "banner"), | ||
| ): | ||
| value = getattr(args, field, default) | ||
| if value not in choices: raise ValueError(f"Choose a supported {field}") | ||
| data[field] = value | ||
| for field in ("release_alert", "include_play"): | ||
| data[field] = str(boolean(getattr(args, field, "false"), field)).lower() | ||
| if data["release_alert"] == "false" and getattr(args, "release_url", ""): | ||
| raise ValueError("Enable Release alert to use a GitHub release page, or clear the release URL") | ||
| if data["release_alert"] == "true" and getattr(args, "release_url", ""): | ||
| data["release_url"] = args.release_url | ||
| data["test_mode"] = str(test_mode).lower() | ||
| code = getattr(args, "release_version_code", "0") | ||
| if not str(code).isdigit() or int(code) > 2147483647: raise ValueError("Release version code must be a nonnegative whole number") | ||
| data["release_version_code"] = str(int(code)) | ||
| if topic == "direct_users" and data["include_play"] == "true": | ||
| raise ValueError("Choose all_users to include Google Play installs") | ||
| if data["release_alert"] == "true" and topic not in ("direct_users", "debug_users", "test_users") and data["include_play"] != "true": | ||
| raise ValueError("Release alerts must target direct_users unless Google Play is explicitly included") | ||
| size = sum(len(key.encode("utf-8")) + len(value.encode("utf-8")) for key, value in data.items()) | ||
| if size > TOPIC_PAYLOAD_LIMIT: | ||
| raise ValueError( | ||
| f"Message data uses {size} bytes; topic messages allow {TOPIC_PAYLOAD_LIMIT}. " | ||
| "Shorten the message or link to the full notes, then preview again." | ||
| ) | ||
| return { | ||
| "topic": topic, | ||
| "data": data, | ||
| "collapse_key": getattr(args, "collapse_key", ""), | ||
| "dry_run": boolean(getattr(args, "dry_run", "false"), "dry_run"), | ||
| "test_mode": test_mode, | ||
| "payload_bytes": size, | ||
| } | ||
|
|
||
|
|
||
| def plan_digest(plan): | ||
| return hashlib.sha256(json.dumps(plan, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest() | ||
|
|
||
|
|
||
| def _firebase_send(plan): | ||
| # Only the sending path imports/initializes Firebase. Offline previews cannot send. | ||
| import firebase_admin | ||
| from firebase_admin import credentials, messaging | ||
|
|
||
| creds_json = os.environ.get("FIREBASE_CREDENTIALS") | ||
| if not creds_json: | ||
| raise ValueError("FIREBASE_CREDENTIALS is required for provider validation or sending") | ||
| firebase_admin.initialize_app(credentials.Certificate(json.loads(creds_json))) | ||
| android = messaging.AndroidConfig(collapse_key=plan["collapse_key"]) if plan["collapse_key"] else None | ||
| audience = {"condition": "'direct_users' in topics && !('play_users' in topics) && !('test_users' in topics)"} if plan["topic"] == "direct_users" else {"topic": plan["topic"]} | ||
| return messaging.send( | ||
| messaging.Message(data=plan["data"], android=android, **audience), | ||
| dry_run=plan["dry_run"], | ||
| ) | ||
|
|
||
| dry_run_bool = (getattr(args, 'dry_run', 'false') == 'true') | ||
| try: | ||
| response = messaging.send(message, dry_run=dry_run_bool) | ||
| if dry_run_bool: | ||
| print(f"Dry run validation succeeded for topic '{topic}': {response}") | ||
|
|
||
|
|
||
| def send_notification(args, *, sender=None): | ||
| plan = notification_plan(args) | ||
| if boolean(getattr(args, "preview_only", "false"), "preview_only"): | ||
| preview = {**plan, "approval_digest": plan_digest(plan), "mode": "offline-preview", "sent": False} | ||
| rendered = json.dumps(preview, ensure_ascii=False, indent=2) + "\n" | ||
| output = getattr(args, "preview_output", "") | ||
| if output: | ||
| Path(output).write_text(rendered, encoding="utf-8") | ||
| else: | ||
| print(f"Successfully sent message to topic '{topic}': {response}") | ||
| except Exception as e: | ||
| print(f"Failed to send message: {e}") | ||
| raise e | ||
| print(rendered, end="") | ||
| return preview | ||
| if not plan["dry_run"] and getattr(args, "approval_digest", "") != plan_digest(plan): | ||
| raise ValueError("Review the current preview and approve it before sending. Edited drafts need a new approval.") | ||
| response = (sender or _firebase_send)(plan) | ||
| if plan["dry_run"]: | ||
| print(f"Provider validation passed for '{plan['topic']}'. No message sent. ID: {response}") | ||
| else: | ||
| print(f"FCM accepted the message for '{plan['topic']}'. Device delivery is not confirmed. ID: {response}") | ||
| return {**plan, "mode": "provider-validation" if plan["dry_run"] else "send", "response": response} | ||
|
|
||
|
|
||
| def argument_parser(): | ||
| parser = argparse.ArgumentParser(description="Preview, validate or send an FCM announcement") | ||
| parser.add_argument("--title", required=True) | ||
| parser.add_argument("--body", required=True) | ||
| parser.add_argument("--type", required=True) | ||
| for name, default in ( | ||
| ("route", ""), ("image", ""), ("target", "all_users"), | ||
| ("dry-run", "false"), ("test-mode", "false"), ("preview-only", "false"), | ||
| ("preview-output", ""), ("collapse-key", ""), ("sound", "default"), | ||
| ("presentation", "compact"), ("tone", "primary"), ("image-style", "banner"), | ||
| ("release-url", ""), ("release-alert", "false"), ("include-play", "false"), ("release-version-code", "0"), ("approval-digest", ""), | ||
| ("action-label", "View"), ("show-action-in-push", "true"), | ||
| ("show-action-in-app", "true"), ("category", "ANNOUNCEMENT"), | ||
| ): | ||
| parser.add_argument("--" + name, default=default) | ||
| return parser | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| parser = argparse.ArgumentParser(description='Send FCM Notification') | ||
| parser.add_argument('--title', required=True, help='Notification Title') | ||
| parser.add_argument('--body', required=True, help='Notification Body') | ||
| parser.add_argument('--type', required=True, help='Where to show (push/in-app/both)') | ||
| parser.add_argument('--route', required=False, default="", help='Optional Deep Link Route') | ||
| parser.add_argument('--image', required=False, default="", help='Optional Big Image URL') | ||
| parser.add_argument('--target', required=False, default="all_users", help='Target audience topic') | ||
| parser.add_argument('--dry-run', required=False, default="false", help='Validate only') | ||
| parser.add_argument('--collapse-key', required=False, default="", help='Optional Android collapse key') | ||
| parser.add_argument('--sound', required=False, default="default", help='Notification sound') | ||
| parser.add_argument('--action-label', required=False, default="View", help='Action Button Text') | ||
| parser.add_argument('--show-action-in-push', required=False, default="true", help='Show action in push') | ||
| parser.add_argument('--show-action-in-app', required=False, default="true", help='Show action in app') | ||
| parser.add_argument('--category', required=False, default="WHAT'S NEW", help='In-App category badge label') | ||
|
|
||
| args = parser.parse_args() | ||
| send_notification(args) | ||
| try: | ||
| send_notification(argument_parser().parse_args()) | ||
| except (ValueError, OSError) as error: | ||
| print(f"Announcement stopped: {error}", file=sys.stderr) | ||
| sys.exit(1) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| """Render an exported announcement on an isolated updateTest build over ADB. | ||
|
|
||
| No Firebase, workflow dispatch, topic message or production release is involved. | ||
| The receiver exists only in updateTest and requires Android's shell permission. | ||
| Delivery eligibility is tested separately: this local visual preview resets the | ||
| release code to 0 so an already-current test device can display the same layout. | ||
| """ | ||
| import argparse | ||
| import json | ||
| import shlex | ||
| import subprocess | ||
| from pathlib import Path | ||
|
|
||
| COMPONENT = 'cx.aswin.boxlore/.testing.AnnouncementTestReceiver' | ||
| ACTION = 'cx.aswin.boxlore.TEST_ANNOUNCEMENT' | ||
|
|
||
|
|
||
| def preview(preview_path, adb='adb', serial=None, runner=subprocess.run): | ||
| value = json.loads(Path(preview_path).read_text(encoding='utf-8')) | ||
| data = value.get('data') | ||
| if not isinstance(data, dict) or not data.get('title') or not data.get('body'): | ||
| raise ValueError('Choose an exported announcement-preview JSON file') | ||
| command = [adb] + (['-s', serial] if serial else []) | ||
| # ADB forwards a shell command. Quote every remote argument so copy remains | ||
| # exact, including spaces, apostrophes and shell metacharacters. | ||
| remote = shlex.join(['am', 'broadcast', '-n', COMPONENT, '-a', ACTION, | ||
| '--es', 'payload', json.dumps(data, ensure_ascii=False)]) | ||
| result = runner(command + ['shell', remote], check=True, text=True, capture_output=True) | ||
| # An absent isolated receiver is a failure, not a successful preview on a normal install. | ||
| if 'result=-1' not in result.stdout or any(word in result.stdout + result.stderr for word in ('Error', 'Exception', 'Permission Denial')): | ||
| raise ValueError('Install the isolated updateTest APK first, then retry this local preview') | ||
| return result.stdout | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument('--preview', type=Path, required=True) | ||
| parser.add_argument('--adb', default='adb') | ||
| parser.add_argument('--serial') | ||
| args = parser.parse_args() | ||
| try: | ||
| print(preview(args.preview, args.adb, args.serial)) | ||
| print('Local preview requested. Open boxlore on the test device; no message was sent to users.') | ||
| except (ValueError, OSError, subprocess.CalledProcessError) as error: | ||
| parser.exit(1, f'Preview stopped: {error}\n') |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.