Skip to content

Commit

Permalink
Add support for Android's system timezone database (#13666)
Browse files Browse the repository at this point in the history
  • Loading branch information
HertzDevil authored Jul 25, 2023
1 parent ffa176f commit 0efe3c1
Show file tree
Hide file tree
Showing 10 changed files with 215 additions and 53 deletions.
Binary file added spec/std/data/android_tzdata
Binary file not shown.
2 changes: 1 addition & 1 deletion spec/std/time/format_spec.cr
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
require "./spec_helper"
require "../spec_helper"
require "spec/helpers/string"

def parse_time(format, string)
Expand Down
44 changes: 37 additions & 7 deletions spec/std/time/location_spec.cr
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
require "./spec_helper"
require "../spec_helper"
require "../../support/time"

class Time::Location
Expand All @@ -22,11 +22,11 @@ class Time::Location
location.utc?.should be_false
location.fixed?.should be_false

with_env("TZ", nil) do
with_tz(nil) do
location.local?.should be_false
end

with_env("TZ", "Europe/Berlin") do
with_tz("Europe/Berlin") do
location.local?.should be_true
end

Expand Down Expand Up @@ -166,6 +166,36 @@ class Time::Location
end
end

describe ".load_android" do
it "loads Europe/Berlin" do
location = Location.load_android("Europe/Berlin", {datapath("android_tzdata")}).should_not be_nil

location.name.should eq "Europe/Berlin"
standard_time = location.lookup(Time.utc(2017, 11, 22))
standard_time.name.should eq "CET"
standard_time.offset.should eq 3600
standard_time.dst?.should be_false

summer_time = location.lookup(Time.utc(2017, 10, 22))
summer_time.name.should eq "CEST"
summer_time.offset.should eq 7200
summer_time.dst?.should be_true

location.utc?.should be_false
location.fixed?.should be_false
end

it "loads new data if tzdata file was changed" do
tzdata_path = datapath("android_tzdata")
location1 = Location.load_android("Europe/Berlin", {tzdata_path})
File.touch(tzdata_path)
location2 = Location.load_android("Europe/Berlin", {tzdata_path})

location1.should eq location2
location1.should_not be location2
end
end

it "UTC" do
location = Location::UTC
location.name.should eq "UTC"
Expand Down Expand Up @@ -195,7 +225,7 @@ class Time::Location

describe ".load_local" do
it "with unset TZ" do
with_env("TZ", nil) do
with_tz(nil) do
# This should generally be `Local`, but if `/etc/localtime` doesn't exist,
# `Crystal::System::Time.load_localtime` can't resolve a local time zone,
# making the return value default to `UTC`.
Expand All @@ -205,20 +235,20 @@ class Time::Location

it "with TZ" do
with_zoneinfo do
with_env("TZ", "Europe/Berlin") do
with_tz("Europe/Berlin") do
Location.load_local.name.should eq "Europe/Berlin"
end
end
with_zoneinfo(datapath("zoneinfo")) do
with_env("TZ", "Foo/Bar") do
with_tz("Foo/Bar") do
Location.load_local.name.should eq "Foo/Bar"
end
end
end

it "with empty TZ" do
with_zoneinfo do
with_env("TZ", "") do
with_tz("") do
Location.load_local.utc?.should be_true
end
end
Expand Down
34 changes: 0 additions & 34 deletions spec/std/time/spec_helper.cr

This file was deleted.

2 changes: 1 addition & 1 deletion spec/std/time/time_spec.cr
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
require "./spec_helper"
require "../spec_helper"
require "spec/helpers/iterate"

CALENDAR_WEEK_TEST_DATA = [
Expand Down
36 changes: 36 additions & 0 deletions spec/support/time.cr
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
require "./env"

class Time::Location
def __cached_zone=(zone)
@cached_zone = zone
end

def self.__clear_location_cache
@@location_cache.clear
end
end

ZONEINFO_ZIP = datapath("zoneinfo.zip")

def with_zoneinfo(path = ZONEINFO_ZIP, &)
with_env("ZONEINFO": path) do
Time::Location.local = Time::Location.load_local
Time::Location.__clear_location_cache

yield
end
end

def with_tz(tz, &)
old_local = Time::Location.local
begin
with_env("TZ": tz) do
# Reset local time zone
Time::Location.local = Time::Location.load_local
yield
end
ensure
Time::Location.local = old_local
end
end

{% if flag?(:win32) %}
lib LibC
struct LUID
Expand Down
66 changes: 58 additions & 8 deletions src/crystal/system/unix/time.cr
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
require "c/sys/time"
require "c/time"

{% if flag?(:android) %}
# needed for accessing local timezone
require "c/sys/system_properties"
{% end %}

{% if flag?(:darwin) %}
# Darwin supports clock_gettime starting from macOS Sierra, but we can't
# use it because it would prevent running binaries built on macOS Sierra
Expand Down Expand Up @@ -61,25 +66,70 @@ module Crystal::System::Time
"/usr/share/lib/zoneinfo/",
"/usr/lib/locale/TZ/",
}
LOCALTIME = "/etc/localtime"

# Android Bionic C-specific locations. These are files rather than directories
# and use a different format (see `Time::Location.read_android_tzdata`).
ANDROID_TZDATA_SOURCES = {
"/apex/com.android.tzdata/etc/tz/tzdata",
"/system/usr/share/zoneinfo/tzdata",
}

def self.zone_sources : Enumerable(String)
ZONE_SOURCES
end

def self.android_tzdata_sources : Enumerable(String)
ANDROID_TZDATA_SOURCES
end

def self.load_iana_zone(iana_name : String) : ::Time::Location?
nil
end

def self.load_localtime : ::Time::Location?
if ::File.file?(LOCALTIME) && ::File.readable?(LOCALTIME)
::File.open(LOCALTIME) do |file|
::Time::Location.read_zoneinfo("Local", file)
rescue ::Time::Location::InvalidTZDataError
nil
{% if flag?(:android) %}
def self.load_localtime : ::Time::Location?
# NOTE: although reading a system property is expensive, we don't cache it
# here since it is expected that most code should only ever be calling
# `Time::Location.load`, which is already a cached class property, rather
# than `.load_local`. Bionic itself caches the property like this:
# https://android.googlesource.com/platform/bionic/+/master/libc/private/CachedProperty.h
return nil unless timezone = getprop("persist.sys.timezone")
return nil unless path = ::Time::Location.find_android_tzdata_file(android_tzdata_sources)

::File.open(path) do |file|
::Time::Location.read_android_tzdata(file, true) do |name, location|
return location if name == timezone
end
end
end
end

private def self.getprop(key : String) : String?
{% if LibC.has_method?("__system_property_read_callback") %}
pi = LibC.__system_property_find(key)
value = ""
LibC.__system_property_read_callback(pi, ->(data, _name, value, _serial) do
data.as(String*).value = String.new(value)
end, pointerof(value))
value.presence
{% else %}
buf = uninitialized LibC::Char[LibC::PROP_VALUE_MAX]
len = LibC.__system_property_get(key, buf)
String.new(buf.to_slice[0, len]) if len > 0
{% end %}
end
{% else %}
private LOCALTIME = "/etc/localtime"

def self.load_localtime : ::Time::Location?
if ::File.file?(LOCALTIME) && ::File.readable?(LOCALTIME)
::File.open(LOCALTIME) do |file|
::Time::Location.read_zoneinfo("Local", file)
rescue ::Time::Location::InvalidTZDataError
nil
end
end
end
{% end %}

{% if flag?(:darwin) %}
@@mach_timebase_info : LibC::MachTimebaseInfo?
Expand Down
12 changes: 12 additions & 0 deletions src/lib_c/aarch64-android/c/sys/system_properties.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
lib LibC
{% if ANDROID_API >= 26 %}
alias PropInfo = Void

fun __system_property_find(__name : Char*) : PropInfo*
fun __system_property_read_callback(__pi : PropInfo*, __callback : (Void*, Char*, Char*, UInt32 ->), __cookie : Void*)
{% else %}
PROP_VALUE_MAX = 92

fun __system_property_get(__name : Char*, __value : Char*) : Int
{% end %}
end
6 changes: 6 additions & 0 deletions src/time/location.cr
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ class Time::Location
return location
end

{% if flag?(:android) %}
if location = load_android(name, Crystal::System::Time.android_tzdata_sources)
return location
end
{% end %}

# If none of the database sources contains a suitable location,
# try getting it from the operating system.
# This is only implemented on Windows. Unix systems usually have a
Expand Down
66 changes: 64 additions & 2 deletions src/time/location/loader.cr
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ class Time::Location
end
end

# :nodoc:
def self.load_android(name : String, sources : Enumerable(String)) : Time::Location?
if path = find_android_tzdata_file(sources)
load_from_android_tzdata(name, path) || raise InvalidLocationNameError.new(name, path)
end
end

# :nodoc:
def self.load_from_dir_or_zip(name : String, source : String) : Time::Location?
if source.ends_with?(".zip")
Expand All @@ -41,6 +48,23 @@ class Time::Location
end
end

# :nodoc:
def self.load_from_android_tzdata(name : String, path : String) : Time::Location?
return nil unless File.exists?(path)

mtime = File.info(path).modification_time
if (cache = @@location_cache[name]?) && cache[:time] == mtime
cache[:location]
else
File.open(path) do |file|
read_android_tzdata(file, false) do |location_name, location|
@@location_cache[location_name] = {time: mtime, location: location}
end
@@location_cache[name].try &.[:location]
end
end
end

private def self.open_file_cached(name : String, path : String, &)
return nil unless File.exists?(path)

Expand Down Expand Up @@ -72,11 +96,17 @@ class Time::Location
end
end

# :nodoc:
def self.find_android_tzdata_file(sources : Enumerable(String)) : String?
sources.find do |path|
File.exists?(path) && File.file?(path) && File.readable?(path)
end
end

# :nodoc:
# Parse "zoneinfo" time zone file.
# This is the standard file format used by most operating systems.
# See https://data.iana.org/time-zones/tz-link.html, https://github.com/eggert/tz, tzfile(5)

# :nodoc:
def self.read_zoneinfo(location_name : String, io : IO) : Time::Location
raise InvalidTZDataError.new unless io.read_string(4) == "TZif"

Expand Down Expand Up @@ -150,6 +180,38 @@ class Time::Location
raise InvalidTZDataError.new(cause: exc)
end

private ANDROID_TZDATA_NAME_LENGTH = 40
private ANDROID_TZDATA_ENTRY_SIZE = ANDROID_TZDATA_NAME_LENGTH + 12

# :nodoc:
# Reads a packed tzdata file for Android's Bionic C runtime. Defined in
# https://android.googlesource.com/platform/bionic/+/master/libc/tzcode/bionic.cpp
def self.read_android_tzdata(io : IO, local : Bool, & : String, Time::Location ->)
header = io.read_string(12)
raise InvalidTZDataError.new unless header.starts_with?("tzdata") && header.ends_with?('\0')

index_offset = read_int32(io)
data_offset = read_int32(io)
io.skip(4) # final_offset
unless index_offset <= data_offset && (data_offset - index_offset).divisible_by?(ANDROID_TZDATA_ENTRY_SIZE)
raise InvalidTZDataError.new
end

io.seek(index_offset)
entries = Array.new((data_offset - index_offset) // ANDROID_TZDATA_ENTRY_SIZE) do
name = io.read_string(40).rstrip('\0')
start = read_int32(io)
length = read_int32(io)
io.skip(4) # unused
{name, start, length}
end

entries.each do |(name, start, length)|
io.seek(start + data_offset)
yield name, read_zoneinfo(local ? "Local" : name, read_buffer(io, length))
end
end

private def self.read_int32(io : IO)
io.read_bytes(Int32, IO::ByteFormat::BigEndian)
end
Expand Down

0 comments on commit 0efe3c1

Please sign in to comment.