Writing Android apps in Ruby in 2026: JRuby 10 and Ruboto
Ruboto lets you write Android apps in Ruby. It runs JRuby on the phone, and your activities are Ruby classes that call the Android API directly. The project went quiet: the last upstream commit is from May 2023 and targets JRuby 9.4.
I wanted it back, so I forked it: github.com/Largo/ruboto. The fork runs JRuby 10.1 (Ruby 3.4 compatible) on Android 6.0 up to Android 16, phones, tablets and Wear OS watches. You do not need Android Studio. You need a JDK, the Android SDK and Ruby.
The first real app I built with it runs on a Frameo digital photo frame. More on that below.
Quick start
You need JDK 17 or newer, the Android SDK (set ANDROID_HOME), Gradle and Ruby.
git clone https://github.com/Largo/ruboto.git
cd ruboto && bundle install && cd ..
# Generate an app. This also downloads the newest JRuby jars into libs/.
ruby -Iruboto/lib ruboto/bin/ruboto gen app --package org.example.todo --name Todo --path todo
# Use the sample todo list as the app
cp ruboto/assets/samples/todo_activity.rb todo/app/src/main/resources/todo_activity.rb
cd todo && gradle assembleDebug
adb install app/build/outputs/apk/debug/app-debug.apk
Run ruboto gen app with plain ruby, not under bundle exec. Bundler hides the jruby-jars gem, and then no JRuby gets copied into the app.
The whole app is one Ruby file in app/src/main/resources. Edit it, rebuild, reinstall. That is the development cycle.
What the code looks like
An activity is a Ruby class. ruboto/widget gives you a small layout DSL:
require 'ruboto/widget'
require 'ruboto/toast'
ruboto_import_widgets :Button, :EditText, :LinearLayout, :ListView
class TodoActivity
def onCreate(bundle)
super
set_title 'Ruboto Todo'
@todos = []
self.content_view =
linear_layout orientation: :vertical, fits_system_windows: true do
@new_todo = edit_text hint: 'What needs doing?'
button text: 'Add', on_click_listener: proc { add_todo }
@list = list_view list: @todos.dup
end
end
def add_todo
@todos << @new_todo.text.to_s
@list.reload_list(@todos.dup)
toast 'Added'
end
end
You can also skip the DSL and call the Android classes the way Java would. java_import 'android.widget.TextView', then TextView.new(self). Every Android API is there, there is no wrapper layer to wait for.
What I had to fix
JRuby 10 is written for a modern JVM. Android’s runtime, ART, is not one. Ruboto rewrites the JRuby jar while it installs it into your app, using the ASM library that already ships inside JRuby. The main points:
- No runtime class generation. ART cannot load bytecode generated at runtime. JRuby normally does that for method invokers, dynamic scopes and Java interface implementations. The fork pregenerates the invokers at build time, uses a fixed scope class, and implements Java interfaces with
java.lang.reflect.Proxy. Blocks, procs,Runnable.impl { }andinclude SomeJavaInterfaceall work as callbacks. - Missing JDK classes.
java.lang.ModuleandSwitchPointdo not exist on Android.ClassValueonly exists since Android 14, and JRuby 10.1 needs it at startup. Calls to JDK methods newer than the device, such asInputStream.readAllBytesorBigInteger.TWO, are retargeted to backports. - Android 6 to 12. JRuby 10 uses
java.lang.invokeeverywhere, andVarHandleis only public API from Android 13. For a lower--min-sdk, Ruboto moves those calls to a small stand-in built on reflection andsun.misc.Unsafe. Core library desugaring addsjava.nio.fileandjava.timebelow Android 8. - Fibers. JRuby runs Fibers and
Enumerator#nexton virtual threads. Android has none, so they run on platform threads. - The standard library. The Java parts of
stringio,strscan,jsonandpsychship as jars inside JRuby, and Android cannot load jars at runtime. Their classes get merged into the app. Since Ruby 3.4,bigdecimal,csv,logger,base64,rexmland others are bundled gems, which are normally found through RubyGems. Ruboto copies them onto the load path. - RubyGems is off. It cannot scan gem specifications inside an APK. Pure-Ruby gems still work: unpack the gem and put its
libfolder intoapp/src/main/resources. The Todo sample does this with the humanize gem.
Gems with C extensions do not work, and neither does openssl. Subclassing a Java class in Ruby (class MyList < java.util.ArrayList) still needs runtime class generation and fails. For HTTPS, java.net.HttpURLConnection works fine from Ruby.
Each change is tested with JVM tests that need no Android SDK, and a device smoke test with 65 checks for Ruby features, the stdlib, the bundled gems, sockets, net/http and Java integration. It passes on Android 13 and Android 6 emulators.
The Frameo photo frame
Frameo frames are cheap WiFi picture frames. Mine has a 1920×1200 screen, a 32-bit Rockchip RK312x with four Cortex-A7 cores, 1 GB of RAM and Android 6.0.1. It runs the Frameo app and nothing else. But it has a hidden switch: Settings › About › Beta program, then ADB access. After that, adb install works.
I wanted a frame that does not need the Frameo cloud. So I wrote one, in Ruby, about 1300 lines:
- Photos are shown full screen and cross-fade every 15 seconds to 30 minutes, shuffled or in order.
- A small web server on port 8080 lets phones and computers on the same WiFi upload, show and delete photos. It is password protected. The browser scales photos to the screen size before uploading, and the frame checks them again.
- Photos use at most a quarter of the storage and always leave 1 GB free.
- A small overlay shows the time in two cities and today’s weather from MeteoSwiss.
- Tapping the screen shows the web address, the password, and buttons for the next photo and for closing the app.
The web server is about 160 lines of Ruby on top of java.net.ServerSocket. It serves one connection at a time and streams uploads straight to disk, because 1 GB of RAM is shared with the Frameo app. Photos are decoded with Android’s BitmapFactory on a background executor, then handed to the UI thread with a Handler message:
@handler = Handler.new(Looper.getMainLooper, proc { |message| handle_message(message) })
@decoder = java.util.concurrent.Executors.newSingleThreadExecutor
def display(name)
path = PhotoFrame.store.path(name)
@decoder.execute(proc do
bitmap = decode(path)
post_ui(:fade_to, [name, bitmap]) if bitmap
end)
end
The Frameo firmware does not let apps start themselves at boot. The workaround is to register the activity as a home screen app with an android.intent.category.HOME intent filter. Android then asks which home app to use, and the frame starts my app after a reboot. “Close photo frame” sends a home intent to the Frameo app, which resumes its own slideshow.
It is not fast. JRuby takes about 20 seconds to start on this hardware, behind a splash screen. Below Android 13 the java.lang.invoke stand-in uses reflection, so instance variables and Java calls are slower. For a photo frame that does not matter: it starts once and then runs for weeks. Once JRuby is up, cross-fading 1920×1200 photos and serving uploads is smooth.
This app is why the fork supports Android 6. Before it, the minimum was Android 13.
Wear OS and IRB
The same apps run on Wear OS. I built an EPUB reader for my watch with it, and the fork has two IRB samples: one for phones that can install pure-Ruby gems at the prompt with gem install, and one sized for a watch, with dictation because typing Ruby on a watch is miserable.
Two things to know for Wear OS. Swiping back never calls the old onBackPressed. Set android:enableOnBackInvokedCallback="true" and register a proc with on_back_invoked_dispatcher. And Java fields keep their exact names: display_metrics.widthPixels works, width_pixels does not. JRuby only adds snake_case aliases for methods.
Should you use it?
For a personal tool, a kiosk, a device you own or an app you want to write in an afternoon: yes. You write Ruby, you get the whole Android API, and you skip Kotlin, Compose and Android Studio. JRuby on Android is interpreted, starting it takes a while (about 20 seconds on a watch or an old photo frame), and a debug APK is about 20 MB. For an app with a million users I would still pick Kotlin.
The fork is at github.com/Largo/ruboto. The README has the full quick start, the notes on older Android versions, gems and Java callbacks, and the IRB samples. Issues and pull requests are welcome.