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

MVP scope is a debug/unsigned-release APK. Keystore signing, AAB and Play upload hang off the same seam later — see docs/android-plan.md §9.

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"
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.



99
100
101
102
103
104
# File 'lib/everywhere/builders/android.rb', line 99

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.



463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# File 'lib/everywhere/builders/android.rb', line 463

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) ⇒ Object

Build the APK 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). Returns the path of the collected APK.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/everywhere/builders/android.rb', line 110

def build!(dist_dir:, dev: false, dev_url: nil)
  # 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, template: template)
  work = stage(template)
  stamp!(work)
  stamp_dev_url!(work, dev_url)
  variant = dev ? "debug" : "release"
  gradle!(work, variant, dist_dir)
  collect(work, variant, dist_dir)
end