Class: Everywhere::Builders::Android

Inherits:
Object
  • Object
show all
Defined in:
lib/everywhere/builders/android.rb

Overview

Assembles a Hotwire Native Android app around an already-deployed (remote mode) app — the Android half of Builders::Ios, and deliberately the same five steps: copy the gem's frozen Gradle template, stamp an explicit list of per-app files (never a build script), and hand it to Gradle.

The stamped list, and nothing else:

app/everywhere.properties                       identity (app/build.gradle.kts reads it)
app/native-packages.gradle.kts                  native.android.packages
app/src/stamped/AndroidManifest.xml             deep links, auth scheme, permissions
app/src/main/assets/everywhere.json             Config#to_shell_json
app/src/main/assets/json/path-configuration.json baked tabs + rules
app/src/main/assets/fonts/                      the selected Material icon font
app/src/main/res/values/colors.xml              tint / background, light (replaced in place)
app/src/main/res/values-night/colors.xml        tint / background, dark
app/src/main/res/mipmap-*/                      launcher icon layers + anydpi-v26
app/src/stamped/res/drawable*/                  native/android/assets/** as drawables
app/src/main/java/com/rubyeverywhere/shell/extensions/   native/android/*.kt + registry

Signing and the format Play accepts are driven from the environment, not from everywhere.yml — see SIGNING_ENV.

Constant Summary collapse

GRADLE_MODULE =

Fixed by the template contract: one Gradle module, always :app, so build paths and task names never depend on per-app values.

":app"
FORMATS =

What each package format is called in the three places it has a name: the Gradle verb, the outputs/ subdirectory and the file extension.

{
  apk: { task: "assemble", dir: "apk", extension: "apk" },
  aab: { task: "bundle", dir: "bundle", extension: "aab" }
}.freeze
SIGNING_ENV =

Release signing is read from the environment and handed to Gradle as environment, never stamped into everywhere.properties: that file lives in the work dir for the life of the app, and a keystore password written there outlives the build that needed it. The keystore itself stays where the machine put it — nothing is copied into the project.

%w[EVERY_ANDROID_KEYSTORE EVERY_ANDROID_KEYSTORE_PASSWORD
EVERY_ANDROID_KEY_ALIAS EVERY_ANDROID_KEY_PASSWORD].freeze
REQUIRED_SIGNING_ENV =

The two the keystore can't be used without. EVERY_ANDROID_KEY_PASSWORD is not among them: a key generated without -keypass shares the store's.

%w[EVERY_ANDROID_KEYSTORE_PASSWORD EVERY_ANDROID_KEY_ALIAS].freeze
VERSION_CODE_ENV =
"EVERY_ANDROID_VERSION_CODE"
SHELL_PACKAGE =

The Kotlin package the shell's sources live in. NOT the applicationId: the app id is stamped per app, the code's package is frozen, and mixing them up is the classic Android manifest mistake — so anything we generate that names a class spells the package out in full.

"com.rubyeverywhere.shell"
EXTENSIONS_PACKAGE =
"#{SHELL_PACKAGE}.extensions"
MAIN_ACTIVITY =

The activity every stamped intent-filter attaches to. Its element already exists in the frozen main manifest; the stamped source set only adds filters to it.

"#{SHELL_PACKAGE}.MainActivity"
ICON_FONT_COMMIT =

google/material-design-icons, pinned by commit for the same reason the iOS side pins Package.resolved: an icon set that silently changes under a cached build is a rendering bug nobody can reproduce. Each entry carries the SHA-256 of both files, so a corrupted or substituted download fails loudly instead of shipping.

"528cb964c01fb2b09bc3b9208f82b6d8f8c1c1e2"
ICON_FONT_BASE =
"https://raw.githubusercontent.com/google/material-design-icons"
FETCHED_ICON_FONTS =

Keyed by the asset filename Config::ANDROID_ICON_FONTS names; "path" is the repo path without an extension (upstream spells the variable fonts with their axis list in brackets — we ship them under the plain name the shell asks Typeface for).

{
  "MaterialSymbolsOutlined.ttf" => {
    "path" => "variablefont/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght]",
    "ttf" => "b5126c4655e0756f334d684104156b98b82ef7d61212a81c041b9e6cfa8ba925",
    "codepoints" => "8567a3d0512ce8a735a739d325e83ee7010d9bf7f6f8ed6a699c07c817ea907d"
  },
  "MaterialSymbolsRounded.ttf" => {
    "path" => "variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght]",
    "ttf" => "8817f9df195b582d45a6031c1d9e1533595dd7a6f0e76abc309bdc3cc4d72ee1",
    "codepoints" => "8567a3d0512ce8a735a739d325e83ee7010d9bf7f6f8ed6a699c07c817ea907d"
  },
  "MaterialSymbolsSharp.ttf" => {
    "path" => "variablefont/MaterialSymbolsSharp[FILL,GRAD,opsz,wght]",
    "ttf" => "c56a37227b580aa30bcbf352a770a2598dc86bca43bf060ff8d0550c081827e6",
    "codepoints" => "8567a3d0512ce8a735a739d325e83ee7010d9bf7f6f8ed6a699c07c817ea907d"
  }
}.freeze
BUNDLED_FONT_DIRS =

Where the bundled (classic) font sits inside the template. Two spellings because the template ships it as the default asset, and a template that keeps its fonts out of assets/ still resolves.

[%w[app src main assets fonts], %w[fonts]].freeze
STAGE_MARKER =

Marks which template a work dir was staged from. A gem upgrade can add, rename or delete template files, and staging merges rather than wipes (see #stage), so a stale Kotlin file from an older template would keep compiling in. Version mismatch = wipe once, then merge as usual.

".everywhere-template"
APPLICATION_ID =

What AGP accepts as an applicationId: two or more segments, each a Java identifier starting with a letter. No hyphens, unlike a bundle id.

/\A[a-zA-Z]\w*(\.[a-zA-Z]\w*)+\z/

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config:, root:, target:, template_dir: nil) ⇒ Android

Returns a new instance of Android.



120
121
122
123
124
125
# File 'lib/everywhere/builders/android.rb', line 120

def initialize(config:, root:, target:, template_dir: nil)
  @config = config
  @root = root
  @target = target
  @template_dir = template_dir
end

Class Method Details

.fetch!(url, dest, sha256:, redirects: 3) ⇒ Object

Download to a sibling .part file, verify, then rename — the atomicity the cache-hit rule above depends on. A class method so tests (and the offline path) have one seam to stub instead of a live socket.



555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# File 'lib/everywhere/builders/android.rb', line 555

def self.fetch!(url, dest, sha256:, redirects: 3)
  require "net/http"
  require "uri"
  require "digest"

  uri = URI.parse(url)
  part = "#{dest}.part"
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
    http.request(Net::HTTP::Get.new(uri)) do |response|
      case response
      when Net::HTTPRedirection
        raise "too many redirects for #{url}" if redirects <= 0

        return fetch!(response["location"], dest, sha256: sha256, redirects: redirects - 1)
      when Net::HTTPSuccess
        File.open(part, "wb") { |f| response.read_body { |chunk| f.write(chunk) } }
      else
        raise "HTTP #{response.code} for #{url}"
      end
    end
  end

  digest = Digest::SHA256.file(part).hexdigest
  raise "#{File.basename(dest)} checksum mismatch (got #{digest[0, 12]}…)" unless digest == sha256

  File.rename(part, dest)
  dest
ensure
  FileUtils.rm_f("#{dest}.part")
end

Instance Method Details

#build!(dist_dir:, dev: false, dev_url: nil, format: :apk) ⇒ Object

Build the app and copy it into dist_dir. dev builds the debug variant and relaxes the remote-mode requirement (the dev URL is stamped into a debug asset each run — Android has no SIMCTL_CHILD_* equivalent). format is :apk (installable) or :aab (what Play accepts). Returns the path of the collected artifact.



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/everywhere/builders/android.rb', line 132

def build!(dist_dir:, dev: false, dev_url: nil, format: :apk)
  # The template is resolved before preflight, not after it like iOS:
  # preflight has to settle the icon font, and the bundled font lives in
  # the template. Everything that can fail slowly (a font download) or
  # cheaply (a typo'd icon name) is therefore decided before Gradle — the
  # difference between a 5-second error and one that lands 90 seconds in.
  template = @template_dir ? File.expand_path(@template_dir) : Paths.android_dir!
  preflight!(dev: dev, format: format, template: template)
  work = stage(template)
  stamp!(work)
  stamp_dev_url!(work, dev_url)
  variant = dev ? "debug" : "release"
  gradle!(work, variant, format, dist_dir)
  collect(work, variant, format, dist_dir)
end