diff --git a/Android.bp b/Android.bp
index ab3771c..2acaf8f 100644
--- a/Android.bp
+++ b/Android.bp
@@ -9,6 +9,8 @@ android_app {
platform_apis: true,
kotlincflags: ["-Xplugin=external/kotlinc/lib/kotlinx-serialization-compiler-plugin.jar"],
+ // Store .bin resources uncompressed so they can be memory-mapped.
+ aaptflags: ["-0 .bin"],
static_libs: [
"androidx.core_core",
@@ -25,6 +27,7 @@ android_app {
jni_libs: [
"libnetwork_location_position_estimation_rust",
+ "libnetwork_location_population_density_rust",
],
required: [
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index f46b9a1..16f0945 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -52,6 +52,23 @@
android:value="true" />
+
+
+
+
+
+
+
+
+
diff --git a/CREDITS b/CREDITS
new file mode 100644
index 0000000..32217d3
--- /dev/null
+++ b/CREDITS
@@ -0,0 +1,404 @@
+res/raw/population_density_database.bin is based on [WorldPop The spatial distribution of population in 2015-2030 R2025A v1](https://hub.worldpop.org/geodata/summary?id=80032):
+
+The source file is global_pop_2026_CN_1km_R2025A_UA_v1.tif. Its full citation is:
+
+Bondarenko M., Priyatikanto R., Tejedor-Garavito N., Zhang W., McKeen T., Cunningham A., Woods T., Hilton J., Cihan D., Nosatiuk B., Brinkhoff T., Tatem A., Sorichetta A.. 2025. The spatial distribution of population in 2015-2030 at a resolution of 30 arc (approximately 1km at the equator) R2025A version v1. Global Demographic Data Project - Funded by The Bill and Melinda Gates Foundation (INV-045237). WorldPop - School of Geography and Environmental Science, University of Southampton. DOI: https://doi.org/10.5258/SOTON/WP00845
+
+The source file was converted to S2PD with population-density
+(https://github.com/GrapheneOS/platform_external_population-density) for use by the system population density provider.
+
+Attribution 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+ Considerations for licensors: Our public licenses are
+ intended for use by those authorized to give the public
+ permission to use material in ways otherwise restricted by
+ copyright and certain other rights. Our licenses are
+ irrevocable. Licensors should read and understand the terms
+ and conditions of the license they choose before applying it.
+ Licensors should also secure all rights necessary before
+ applying our licenses so that the public can reuse the
+ material as expected. Licensors should clearly mark any
+ material not subject to the license. This includes other CC-
+ licensed material, or material used under an exception or
+ limitation to copyright. More considerations for licensors:
+ wiki.creativecommons.org/Considerations_for_licensors
+
+ Considerations for the public: By using one of our public
+ licenses, a licensor grants the public permission to use the
+ licensed material under specified terms and conditions. If
+ the licensor's permission is not necessary for any reason--for
+ example, because of any applicable exception or limitation to
+ copyright--then that use is not regulated by the license. Our
+ licenses grant only permissions under copyright and certain
+ other rights that a licensor has authority to grant. Use of
+ the licensed material may still be restricted for other
+ reasons, including because others have copyright or other
+ rights in the material. A licensor may make special requests,
+ such as asking that all changes be marked or described.
+ Although not required by our licenses, you are encouraged to
+ respect those requests where reasonable. More considerations
+ for the public:
+ wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution 4.0 International Public License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution 4.0 International Public License ("Public License"). To the
+extent this Public License may be interpreted as a contract, You are
+granted the Licensed Rights in consideration of Your acceptance of
+these terms and conditions, and the Licensor grants You such rights in
+consideration of benefits the Licensor receives from making the
+Licensed Material available under these terms and conditions.
+
+
+Section 1 -- Definitions.
+
+ a. Adapted Material means material subject to Copyright and Similar
+ Rights that is derived from or based upon the Licensed Material
+ and in which the Licensed Material is translated, altered,
+ arranged, transformed, or otherwise modified in a manner requiring
+ permission under the Copyright and Similar Rights held by the
+ Licensor. For purposes of this Public License, where the Licensed
+ Material is a musical work, performance, or sound recording,
+ Adapted Material is always produced where the Licensed Material is
+ synched in timed relation with a moving image.
+
+ b. Adapter's License means the license You apply to Your Copyright
+ and Similar Rights in Your contributions to Adapted Material in
+ accordance with the terms and conditions of this Public License.
+
+ c. Copyright and Similar Rights means copyright and/or similar rights
+ closely related to copyright including, without limitation,
+ performance, broadcast, sound recording, and Sui Generis Database
+ Rights, without regard to how the rights are labeled or
+ categorized. For purposes of this Public License, the rights
+ specified in Section 2(b)(1)-(2) are not Copyright and Similar
+ Rights.
+
+ d. Effective Technological Measures means those measures that, in the
+ absence of proper authority, may not be circumvented under laws
+ fulfilling obligations under Article 11 of the WIPO Copyright
+ Treaty adopted on December 20, 1996, and/or similar international
+ agreements.
+
+ e. Exceptions and Limitations means fair use, fair dealing, and/or
+ any other exception or limitation to Copyright and Similar Rights
+ that applies to Your use of the Licensed Material.
+
+ f. Licensed Material means the artistic or literary work, database,
+ or other material to which the Licensor applied this Public
+ License.
+
+ g. Licensed Rights means the rights granted to You subject to the
+ terms and conditions of this Public License, which are limited to
+ all Copyright and Similar Rights that apply to Your use of the
+ Licensed Material and that the Licensor has authority to license.
+
+ h. Licensor means the individual(s) or entity(ies) granting rights
+ under this Public License.
+
+ i. Share means to provide material to the public by any means or
+ process that requires permission under the Licensed Rights, such
+ as reproduction, public display, public performance, distribution,
+ dissemination, communication, or importation, and to make material
+ available to the public including in ways that members of the
+ public may access the material from a place and at a time
+ individually chosen by them.
+
+ j. Sui Generis Database Rights means rights other than copyright
+ resulting from Directive 96/9/EC of the European Parliament and of
+ the Council of 11 March 1996 on the legal protection of databases,
+ as amended and/or succeeded, as well as other essentially
+ equivalent rights anywhere in the world.
+
+ k. You means the individual or entity exercising the Licensed Rights
+ under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+ a. License grant.
+
+ 1. Subject to the terms and conditions of this Public License,
+ the Licensor hereby grants You a worldwide, royalty-free,
+ non-sublicensable, non-exclusive, irrevocable license to
+ exercise the Licensed Rights in the Licensed Material to:
+
+ a. reproduce and Share the Licensed Material, in whole or
+ in part; and
+
+ b. produce, reproduce, and Share Adapted Material.
+
+ 2. Exceptions and Limitations. For the avoidance of doubt, where
+ Exceptions and Limitations apply to Your use, this Public
+ License does not apply, and You do not need to comply with
+ its terms and conditions.
+
+ 3. Term. The term of this Public License is specified in Section
+ 6(a).
+
+ 4. Media and formats; technical modifications allowed. The
+ Licensor authorizes You to exercise the Licensed Rights in
+ all media and formats whether now known or hereafter created,
+ and to make technical modifications necessary to do so. The
+ Licensor waives and/or agrees not to assert any right or
+ authority to forbid You from making technical modifications
+ necessary to exercise the Licensed Rights, including
+ technical modifications necessary to circumvent Effective
+ Technological Measures. For purposes of this Public License,
+ simply making modifications authorized by this Section 2(a)
+ (4) never produces Adapted Material.
+
+ 5. Downstream recipients.
+
+ a. Offer from the Licensor -- Licensed Material. Every
+ recipient of the Licensed Material automatically
+ receives an offer from the Licensor to exercise the
+ Licensed Rights under the terms and conditions of this
+ Public License.
+
+ b. No downstream restrictions. You may not offer or impose
+ any additional or different terms or conditions on, or
+ apply any Effective Technological Measures to, the
+ Licensed Material if doing so restricts exercise of the
+ Licensed Rights by any recipient of the Licensed
+ Material.
+
+ 6. No endorsement. Nothing in this Public License constitutes or
+ may be construed as permission to assert or imply that You
+ are, or that Your use of the Licensed Material is, connected
+ with, or sponsored, endorsed, or granted official status by,
+ the Licensor or others designated to receive attribution as
+ provided in Section 3(a)(1)(A)(i).
+
+ b. Other rights.
+
+ 1. Moral rights, such as the right of integrity, are not
+ licensed under this Public License, nor are publicity,
+ privacy, and/or other similar personality rights; however, to
+ the extent possible, the Licensor waives and/or agrees not to
+ assert any such rights held by the Licensor to the limited
+ extent necessary to allow You to exercise the Licensed
+ Rights, but not otherwise.
+
+ 2. Patent and trademark rights are not licensed under this
+ Public License.
+
+ 3. To the extent possible, the Licensor waives any right to
+ collect royalties from You for the exercise of the Licensed
+ Rights, whether directly or through a collecting society
+ under any voluntary or waivable statutory or compulsory
+ licensing scheme. In all other cases the Licensor expressly
+ reserves any right to collect such royalties.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+ a. Attribution.
+
+ 1. If You Share the Licensed Material (including in modified
+ form), You must:
+
+ a. retain the following if it is supplied by the Licensor
+ with the Licensed Material:
+
+ i. identification of the creator(s) of the Licensed
+ Material and any others designated to receive
+ attribution, in any reasonable manner requested by
+ the Licensor (including by pseudonym if
+ designated);
+
+ ii. a copyright notice;
+
+ iii. a notice that refers to this Public License;
+
+ iv. a notice that refers to the disclaimer of
+ warranties;
+
+ v. a URI or hyperlink to the Licensed Material to the
+ extent reasonably practicable;
+
+ b. indicate if You modified the Licensed Material and
+ retain an indication of any previous modifications; and
+
+ c. indicate the Licensed Material is licensed under this
+ Public License, and include the text of, or the URI or
+ hyperlink to, this Public License.
+
+ 2. You may satisfy the conditions in Section 3(a)(1) in any
+ reasonable manner based on the medium, means, and context in
+ which You Share the Licensed Material. For example, it may be
+ reasonable to satisfy the conditions by providing a URI or
+ hyperlink to a resource that includes the required
+ information.
+
+ 3. If requested by the Licensor, You must remove any of the
+ information required by Section 3(a)(1)(A) to the extent
+ reasonably practicable.
+
+ 4. If You Share Adapted Material You produce, the Adapter's
+ License You apply must not prevent recipients of the Adapted
+ Material from complying with this Public License.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+ a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+ to extract, reuse, reproduce, and Share all or a substantial
+ portion of the contents of the database;
+
+ b. if You include all or a substantial portion of the database
+ contents in a database in which You have Sui Generis Database
+ Rights, then the database in which You have Sui Generis Database
+ Rights (but not its individual contents) is Adapted Material; and
+
+ c. You must comply with the conditions in Section 3(a) if You Share
+ all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+ a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+ EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+ AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+ ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+ IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+ WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+ PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+ ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+ KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+ ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+ b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+ TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+ NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+ INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+ COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+ USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+ ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+ DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+ IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+ c. The disclaimer of warranties and limitation of liability provided
+ above shall be interpreted in a manner that, to the extent
+ possible, most closely approximates an absolute disclaimer and
+ waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+ a. This Public License applies for the term of the Copyright and
+ Similar Rights licensed here. However, if You fail to comply with
+ this Public License, then Your rights under this Public License
+ terminate automatically.
+
+ b. Where Your right to use the Licensed Material has terminated under
+ Section 6(a), it reinstates:
+
+ 1. automatically as of the date the violation is cured, provided
+ it is cured within 30 days of Your discovery of the
+ violation; or
+
+ 2. upon express reinstatement by the Licensor.
+
+ For the avoidance of doubt, this Section 6(b) does not affect any
+ right the Licensor may have to seek remedies for Your violations
+ of this Public License.
+
+ c. For the avoidance of doubt, the Licensor may also offer the
+ Licensed Material under separate terms or conditions or stop
+ distributing the Licensed Material at any time; however, doing so
+ will not terminate this Public License.
+
+ d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+ License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+ a. The Licensor shall not be bound by any additional or different
+ terms or conditions communicated by You unless expressly agreed.
+
+ b. Any arrangements, understandings, or agreements regarding the
+ Licensed Material not stated herein are separate from and
+ independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+ a. For the avoidance of doubt, this Public License does not, and
+ shall not be interpreted to, reduce, limit, restrict, or impose
+ conditions on any use of the Licensed Material that could lawfully
+ be made without permission under this Public License.
+
+ b. To the extent possible, if any provision of this Public License is
+ deemed unenforceable, it shall be automatically reformed to the
+ minimum extent necessary to make it enforceable. If the provision
+ cannot be reformed, it shall be severed from this Public License
+ without affecting the enforceability of the remaining terms and
+ conditions.
+
+ c. No term or condition of this Public License will be waived and no
+ failure to comply consented to unless expressly agreed to by the
+ Licensor.
+
+ d. Nothing in this Public License constitutes or may be interpreted
+ as a limitation upon, or waiver of, any privileges and immunities
+ that apply to the Licensor or You, including from the legal
+ processes of any jurisdiction or authority.
+
+
+=======================================================================
+
+Creative Commons is not a party to its public
+licenses. Notwithstanding, Creative Commons may elect to apply one of
+its public licenses to material it publishes and in those instances
+will be considered the “Licensor.” The text of the Creative Commons
+public licenses is dedicated to the public domain under the CC0 Public
+Domain Dedication. Except for the limited purpose of indicating that
+material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the
+public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
diff --git a/interop/population-density_rust/Android.bp b/interop/population-density_rust/Android.bp
new file mode 100644
index 0000000..0352fac
--- /dev/null
+++ b/interop/population-density_rust/Android.bp
@@ -0,0 +1,21 @@
+rust_defaults {
+ name: "libnetwork_location_population_density_rust_defaults",
+ crate_name: "network_location_population_density",
+ srcs: ["src/lib.rs"],
+ rustlibs: [
+ "libjni",
+ "libpopulation_density",
+ ],
+ edition: "2024",
+}
+
+rust_ffi_shared {
+ name: "libnetwork_location_population_density_rust",
+ defaults: ["libnetwork_location_population_density_rust_defaults"],
+}
+
+rust_test_host {
+ name: "libnetwork_location_population_density_rust_inline_tests",
+ defaults: ["libnetwork_location_population_density_rust_defaults"],
+ test_suites: ["general-tests"],
+}
diff --git a/interop/population-density_rust/src/database.rs b/interop/population-density_rust/src/database.rs
new file mode 100644
index 0000000..06490ec
--- /dev/null
+++ b/interop/population-density_rust/src/database.rs
@@ -0,0 +1,312 @@
+//! Maps and queries the packaged population density database.
+
+use population_density::QueryEngine;
+use population_density::memmap2::{Mmap, MmapOptions};
+use std::fmt::{self, Display, Formatter};
+use std::fs::File;
+use std::io;
+use std::os::fd::BorrowedFd;
+
+/// Describes a validated byte range for a database mapping.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct MappingRange {
+ offset: u64,
+ length: usize,
+ end: u64,
+}
+
+/// Reports a database initialization failure.
+#[derive(Debug)]
+pub(crate) enum InitializationError {
+ InvalidFileDescriptor(i32),
+ NegativeOffset(i64),
+ NonPositiveLength(i64),
+ UnrepresentableLength(u64),
+ RangeOverflow { offset: u64, length: u64 },
+ DuplicateFileDescriptor(io::Error),
+ InspectFileDescriptor(io::Error),
+ NonRegularFile,
+ RangeExceedsFile { end: u64, file_size: u64 },
+ MemoryMap(io::Error),
+ LoadQueryEngine(String),
+}
+
+impl Display for InitializationError {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidFileDescriptor(fd) => {
+ write!(formatter, "invalid database file descriptor: {fd}")
+ }
+ Self::NegativeOffset(offset) => {
+ write!(formatter, "database offset must be non-negative: {offset}")
+ }
+ Self::NonPositiveLength(length) => {
+ write!(formatter, "database length must be positive: {length}")
+ }
+ Self::UnrepresentableLength(length) => write!(
+ formatter,
+ "database length is not representable on this platform: {length}"
+ ),
+ Self::RangeOverflow { offset, length } => write!(
+ formatter,
+ "database byte range overflows: offset {offset}, length {length}"
+ ),
+ Self::DuplicateFileDescriptor(error) => {
+ write!(
+ formatter,
+ "failed to duplicate database file descriptor: {error}"
+ )
+ }
+ Self::InspectFileDescriptor(error) => {
+ write!(
+ formatter,
+ "failed to inspect database file descriptor: {error}"
+ )
+ }
+ Self::NonRegularFile => {
+ write!(
+ formatter,
+ "database file descriptor does not refer to a regular file"
+ )
+ }
+ Self::RangeExceedsFile { end, file_size } => write!(
+ formatter,
+ "database byte range exceeds file size: end {end}, size {file_size}"
+ ),
+ Self::MemoryMap(error) => {
+ write!(formatter, "failed to memory-map database file: {error}")
+ }
+ Self::LoadQueryEngine(error) => write!(
+ formatter,
+ "failed to load query engine from memory-mapped database: {error}"
+ ),
+ }
+ }
+}
+
+/// Reports a population density query failure.
+#[derive(Debug)]
+pub(crate) enum QueryError {
+ Uninitialized,
+ Query(String),
+}
+
+impl Display for QueryError {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Uninitialized => write!(formatter, "query engine is not initialized"),
+ Self::Query(error) => write!(formatter, "failed to query population density: {error}"),
+ }
+ }
+}
+
+/// Loads a query engine from a borrowed raw file descriptor.
+///
+/// The packaged range must contain the exact S2PD resource that passed strict and exhaustive
+/// verification before packaging.
+///
+/// # Safety
+///
+/// `fd` must be open and readable until this function returns. No process may modify or truncate
+/// the backing file while the returned engine remains alive.
+pub(crate) unsafe fn load_query_engine(
+ fd: i32,
+ offset: i64,
+ length: i64,
+) -> Result {
+ validate_file_descriptor(fd)?;
+
+ // SAFETY: The caller keeps the descriptor valid for this call.
+ let borrowed_fd = unsafe { BorrowedFd::borrow_raw(fd) };
+ let owned_fd = borrowed_fd
+ .try_clone_to_owned()
+ .map_err(InitializationError::DuplicateFileDescriptor)?;
+ let file = File::from(owned_fd);
+ let mmap = map_database(&file, offset, length)?;
+
+ QueryEngine::from_verified_mmap(mmap)
+ .map_err(|error| InitializationError::LoadQueryEngine(error.to_string()))
+}
+
+/// Maps a validated range from a regular file.
+fn map_database(file: &File, offset: i64, length: i64) -> Result {
+ let mapping_range = validate_mapping_range(offset, length)?;
+ let metadata = file
+ .metadata()
+ .map_err(InitializationError::InspectFileDescriptor)?;
+ if !metadata.is_file() {
+ return Err(InitializationError::NonRegularFile);
+ }
+ if mapping_range.end > metadata.len() {
+ return Err(InitializationError::RangeExceedsFile {
+ end: mapping_range.end,
+ file_size: metadata.len(),
+ });
+ }
+
+ // SAFETY: The checked range lies within a regular file. The caller of `load_query_engine`
+ // guarantees immutable backing for the mapping lifetime; tests retain their temporary file.
+ unsafe {
+ MmapOptions::new()
+ .offset(mapping_range.offset)
+ .len(mapping_range.length)
+ .map(file)
+ .map_err(InitializationError::MemoryMap)
+ }
+}
+
+/// Validates a raw file descriptor before it is borrowed.
+fn validate_file_descriptor(fd: i32) -> Result<(), InitializationError> {
+ if fd < 0 {
+ return Err(InitializationError::InvalidFileDescriptor(fd));
+ }
+ Ok(())
+}
+
+/// Validates and converts a requested mapping range.
+fn validate_mapping_range(offset: i64, length: i64) -> Result {
+ let offset = u64::try_from(offset).map_err(|_| InitializationError::NegativeOffset(offset))?;
+ let length_u64 = match u64::try_from(length) {
+ Ok(length) if length > 0 => length,
+ _ => return Err(InitializationError::NonPositiveLength(length)),
+ };
+ let length = usize::try_from(length_u64)
+ .map_err(|_| InitializationError::UnrepresentableLength(length_u64))?;
+ let end = offset
+ .checked_add(length_u64)
+ .ok_or(InitializationError::RangeOverflow {
+ offset,
+ length: length_u64,
+ })?;
+ Ok(MappingRange {
+ offset,
+ length,
+ end,
+ })
+}
+
+/// Queries an initialized engine for a population density ancestor.
+pub(crate) fn query_engine(
+ engine: Option<&QueryEngine>,
+ s2_cell_id: u64,
+) -> Result {
+ engine
+ .ok_or(QueryError::Uninitialized)?
+ .query(s2_cell_id)
+ .map_err(|error| QueryError::Query(error.to_string()))
+}
+
+#[cfg(test)]
+mod tests {
+ //! Exercises mapping validation without mutating process-global JNI state.
+
+ use super::*;
+ use std::fs::{OpenOptions, remove_file};
+ use std::io::Write;
+ use std::path::PathBuf;
+ use std::process;
+
+ /// Owns a deterministic temporary file and removes it when dropped.
+ struct TemporaryFile {
+ file: File,
+ path: PathBuf,
+ }
+
+ impl TemporaryFile {
+ /// Creates a deterministic temporary file containing `contents`.
+ fn create(name: &str, contents: &[u8]) -> Self {
+ let path = std::env::temp_dir().join(format!(
+ "network_location_population_density_{name}_{}",
+ process::id()
+ ));
+ let _ = remove_file(&path);
+ let mut file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create_new(true)
+ .open(&path)
+ .expect("temporary test file should be created");
+ file.write_all(contents)
+ .expect("temporary test file should be written");
+ Self { file, path }
+ }
+ }
+
+ impl Drop for TemporaryFile {
+ fn drop(&mut self) {
+ let _ = remove_file(&self.path);
+ }
+ }
+
+ /// Verifies that negative file descriptors are rejected.
+ #[test]
+ fn rejects_negative_file_descriptor() {
+ assert!(matches!(
+ validate_file_descriptor(-1),
+ Err(InitializationError::InvalidFileDescriptor(-1))
+ ));
+ }
+
+ /// Verifies that negative mapping offsets are rejected.
+ #[test]
+ fn rejects_negative_offset() {
+ assert!(matches!(
+ validate_mapping_range(-1, 1),
+ Err(InitializationError::NegativeOffset(-1))
+ ));
+ }
+
+ /// Verifies that zero and negative mapping lengths are rejected.
+ #[test]
+ fn rejects_non_positive_length() {
+ for candidate_length in [0, -1] {
+ assert!(matches!(
+ validate_mapping_range(0, candidate_length),
+ Err(InitializationError::NonPositiveLength(rejected_length))
+ if rejected_length == candidate_length
+ ));
+ }
+ }
+
+ /// Verifies that non-regular files are rejected.
+ #[test]
+ fn rejects_non_regular_file() {
+ let directory =
+ File::open(std::env::temp_dir()).expect("temporary directory should be readable");
+ assert!(matches!(
+ map_database(&directory, 0, 1),
+ Err(InitializationError::NonRegularFile)
+ ));
+ }
+
+ /// Verifies that mappings cannot extend past the end of a file.
+ #[test]
+ fn rejects_range_past_end_of_file() {
+ let temporary_file = TemporaryFile::create("truncated", &[0; 4]);
+ assert!(matches!(
+ map_database(&temporary_file.file, 2, 3),
+ Err(InitializationError::RangeExceedsFile {
+ end: 5,
+ file_size: 4
+ })
+ ));
+ }
+
+ /// Verifies that non-page-aligned resource offsets are mapped correctly.
+ #[test]
+ fn maps_unaligned_subrange() {
+ let temporary_file = TemporaryFile::create("unaligned", &[9, 1, 2, 3, 8]);
+ let mmap =
+ map_database(&temporary_file.file, 1, 3).expect("unaligned subrange should be mapped");
+ assert_eq!(&mmap[..], &[1, 2, 3]);
+ }
+
+ /// Verifies that a query requires an initialized engine.
+ #[test]
+ fn rejects_query_without_initialized_engine() {
+ assert!(matches!(
+ query_engine(None, 1),
+ Err(QueryError::Uninitialized)
+ ));
+ }
+}
diff --git a/interop/population-density_rust/src/jni.rs b/interop/population-density_rust/src/jni.rs
new file mode 100644
index 0000000..ea08d20
--- /dev/null
+++ b/interop/population-density_rust/src/jni.rs
@@ -0,0 +1,83 @@
+//! Exposes the population density query engine through JNI.
+
+use crate::database::{InitializationError, load_query_engine, query_engine};
+use jni::{
+ JNIEnv,
+ objects::JClass,
+ sys::{jboolean, jint, jlong},
+};
+use population_density::QueryEngine;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::sync::OnceLock;
+
+/// Stores the process-global query engine after successful initialization.
+static QUERY_ENGINE: OnceLock = OnceLock::new();
+
+/// Initializes the process-global query engine from a packaged database range.
+///
+/// The first successful initialization wins. The Kotlin caller serializes legitimate attempts and
+/// always supplies the same immutable packaged resource.
+///
+/// # Safety
+///
+/// The JVM must provide valid JNI handles. `fd` must remain open and readable until this function
+/// returns, and no process may modify or truncate its backing file while the process remains alive.
+#[unsafe(no_mangle)]
+pub unsafe extern "system" fn Java_app_grapheneos_populationdensity_PopulationDensityLocalDataSource_nativeInit(
+ mut env: JNIEnv,
+ _class: JClass,
+ fd: jint,
+ offset: jlong,
+ length: jlong,
+) -> jboolean {
+ let result = catch_unwind(AssertUnwindSafe(|| {
+ if QUERY_ENGINE.get().is_some() {
+ return Ok(());
+ }
+
+ // SAFETY: Kotlin keeps the descriptor open and the installed APK immutable.
+ let engine = unsafe { load_query_engine(fd, offset, length) }?;
+ // Kotlin serializes calls; a defensive race maps equivalent packaged bytes.
+ let _ = QUERY_ENGINE.set(engine);
+ Ok::<(), InitializationError>(())
+ }));
+
+ match result {
+ Ok(Ok(())) => jni::sys::JNI_TRUE,
+ Ok(Err(error)) => initialization_error(&mut env, error),
+ Err(_) => initialization_error(&mut env, "population density native init panicked"),
+ }
+}
+
+/// Returns the deepest qualifying population density ancestor for an S2 cell ID.
+///
+/// A failure throws `IllegalStateException` and returns zero. Android uses `panic=abort`, so device
+/// correctness depends on explicit error returns rather than this host-only unwind firewall.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_app_grapheneos_populationdensity_PopulationDensityLocalDataSource_nativeQuery(
+ mut env: JNIEnv,
+ _class: JClass,
+ s2_cell_id: jlong,
+) -> jlong {
+ let result = catch_unwind(AssertUnwindSafe(|| {
+ query_engine(QUERY_ENGINE.get(), s2_cell_id as u64)
+ }));
+
+ match result {
+ Ok(Ok(coarsened_s2_cell_id)) => coarsened_s2_cell_id as jlong,
+ Ok(Err(error)) => query_error(&mut env, error),
+ Err(_) => query_error(&mut env, "population density native query panicked"),
+ }
+}
+
+/// Throws an initialization exception and returns `JNI_FALSE`.
+fn initialization_error(env: &mut JNIEnv, error: impl ToString) -> jboolean {
+ let _ = env.throw_new("java/io/IOException", error.to_string());
+ jni::sys::JNI_FALSE
+}
+
+/// Throws a query exception and returns the invalid S2 cell sentinel.
+fn query_error(env: &mut JNIEnv, error: impl ToString) -> jlong {
+ let _ = env.throw_new("java/lang/IllegalStateException", error.to_string());
+ 0
+}
diff --git a/interop/population-density_rust/src/lib.rs b/interop/population-density_rust/src/lib.rs
new file mode 100644
index 0000000..a8e8937
--- /dev/null
+++ b/interop/population-density_rust/src/lib.rs
@@ -0,0 +1,4 @@
+//! Provides JNI bindings for the population density provider.
+
+mod database;
+mod jni;
diff --git a/res/raw/population_density_database.bin b/res/raw/population_density_database.bin
new file mode 100644
index 0000000..e219984
Binary files /dev/null and b/res/raw/population_density_database.bin differ
diff --git a/src/app/grapheneos/populationdensity/GeographicConstants.kt b/src/app/grapheneos/populationdensity/GeographicConstants.kt
new file mode 100644
index 0000000..f894fb0
--- /dev/null
+++ b/src/app/grapheneos/populationdensity/GeographicConstants.kt
@@ -0,0 +1,8 @@
+package app.grapheneos.populationdensity
+
+const val MIN_LATITUDE = -90.0
+const val MAX_LATITUDE = 90.0
+const val MIN_LONGITUDE = -180.0
+const val MAX_LONGITUDE = 180.0
+const val S2_CELL_ID_NONE = 0L
+const val MAX_DATABASE_S2_LEVEL = 12
diff --git a/src/app/grapheneos/populationdensity/PopulationDensityDataSource.kt b/src/app/grapheneos/populationdensity/PopulationDensityDataSource.kt
new file mode 100644
index 0000000..49c7e5a
--- /dev/null
+++ b/src/app/grapheneos/populationdensity/PopulationDensityDataSource.kt
@@ -0,0 +1,12 @@
+package app.grapheneos.populationdensity
+
+/** Supplies coarse S2 cells derived from population density data. */
+fun interface PopulationDensityDataSource {
+ /**
+ * Finds the deepest database cell containing the supplied coordinate.
+ */
+ fun getCoarseLocationCellId(
+ latitude: Double,
+ longitude: Double,
+ ): Long
+}
diff --git a/src/app/grapheneos/populationdensity/PopulationDensityLocalDataSource.kt b/src/app/grapheneos/populationdensity/PopulationDensityLocalDataSource.kt
new file mode 100644
index 0000000..14e76e4
--- /dev/null
+++ b/src/app/grapheneos/populationdensity/PopulationDensityLocalDataSource.kt
@@ -0,0 +1,94 @@
+package app.grapheneos.populationdensity
+
+import android.content.Context
+import android.content.res.Resources
+import app.grapheneos.networklocation.R
+import com.android.internal.location.geometry.S2CellIdUtils
+import java.io.IOException
+import java.util.concurrent.atomic.AtomicBoolean
+
+/** Queries the packaged population density database through a process-global native engine. */
+class PopulationDensityLocalDataSource(
+ context: Context,
+) : PopulationDensityDataSource {
+ init {
+ ensureInitialized(context)
+ }
+
+ override fun getCoarseLocationCellId(
+ latitude: Double,
+ longitude: Double,
+ ): Long {
+ require(latitude in MIN_LATITUDE..MAX_LATITUDE) {
+ "latitude must be in range [$MIN_LATITUDE, $MAX_LATITUDE]"
+ }
+ require(longitude in MIN_LONGITUDE..MAX_LONGITUDE) {
+ "longitude must be in range [$MIN_LONGITUDE, $MAX_LONGITUDE]"
+ }
+
+ val s2CellId = S2CellIdUtils.fromLatLngDegrees(latitude, longitude)
+ val coarsenedS2CellId = nativeQuery(s2CellId)
+ check(coarsenedS2CellId != S2_CELL_ID_NONE) {
+ "population density query returned an invalid S2 cell ID"
+ }
+ return coarsenedS2CellId
+ }
+
+ companion object {
+ private val initialized = AtomicBoolean(false)
+ private val initializationLock = Any()
+
+ init {
+ System.loadLibrary("network_location_population_density_rust")
+ }
+
+ /** Initializes the process-global query engine exactly once. */
+ private fun ensureInitialized(context: Context) {
+ if (initialized.get()) {
+ return
+ }
+ synchronized(initializationLock) {
+ if (initialized.get()) {
+ return
+ }
+
+ val assetFileDescriptor =
+ try {
+ context.resources.openRawResourceFd(R.raw.population_density_database)
+ } catch (exception: Resources.NotFoundException) {
+ throw IOException(
+ "unable to open uncompressed population density database resource",
+ exception,
+ )
+ }
+ assetFileDescriptor.use {
+ val offset = assetFileDescriptor.startOffset
+ val length = assetFileDescriptor.length
+ if (offset < 0 || length <= 0) {
+ throw IOException(
+ "invalid population density database range: offset $offset, " +
+ "length $length",
+ )
+ }
+
+ val fileDescriptor = assetFileDescriptor.parcelFileDescriptor.fd
+ if (!nativeInit(fileDescriptor, offset, length)) {
+ // A JNI exception normally supersedes this defensive fallback.
+ throw IOException("failed to initialize native query engine")
+ }
+ initialized.set(true)
+ }
+ }
+ }
+
+ @JvmStatic
+ private external fun nativeInit(
+ fileDescriptor: Int,
+ offset: Long,
+ length: Long,
+ ): Boolean
+
+ @JvmStatic
+ private external fun nativeQuery(s2CellId: Long): Long
+ }
+}
diff --git a/src/app/grapheneos/populationdensity/PopulationDensityProviderImpl.kt b/src/app/grapheneos/populationdensity/PopulationDensityProviderImpl.kt
new file mode 100644
index 0000000..ed7a6ab
--- /dev/null
+++ b/src/app/grapheneos/populationdensity/PopulationDensityProviderImpl.kt
@@ -0,0 +1,279 @@
+package app.grapheneos.populationdensity
+
+import android.content.Context
+import android.location.provider.PopulationDensityProviderBase
+import android.os.Handler
+import android.os.IBinder
+import android.os.OutcomeReceiver
+import android.os.SystemClock
+import android.util.Log
+import app.grapheneos.verboseLog
+import com.android.internal.os.BackgroundThread
+import java.io.IOException
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.Executor
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.TimeoutException
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+import kotlin.time.Duration.Companion.nanoseconds
+
+private const val TAG = "PopulationDensityProviderImpl"
+private const val VERY_VERBOSE_TAG = "PopulationDensityProviderImplVV"
+
+/** Serves coarsening cells from a background-initialized population density data source. */
+class PopulationDensityProviderImpl(
+ context: Context,
+ private val initializationExecutor: Executor = BackgroundThread.getExecutor(),
+ private val populationDensityDataSourceFactory: () -> PopulationDensityDataSource = {
+ PopulationDensityLocalDataSource(context)
+ },
+) : PopulationDensityProviderBase(context, TAG),
+ PopulationDensityServiceProvider {
+ private val initializationWaitTimeoutHandler = Handler.createAsync(context.mainLooper)
+
+ private sealed interface DataSourceState {
+ data object NotReady : DataSourceState
+
+ class Initializing : DataSourceState {
+ val completion = CompletableFuture()
+ val waitTimeoutArmed = AtomicBoolean()
+ }
+
+ data class Ready(
+ val dataSource: PopulationDensityDataSource,
+ ) : DataSourceState
+
+ data class Failed(
+ val error: Throwable,
+ ) : DataSourceState
+ }
+
+ private val dataSourceState = AtomicReference(DataSourceState.NotReady)
+
+ override fun getServiceBinder(): IBinder? = binder
+
+ /** Initializes the data source for eager background prewarming. */
+ override fun prewarmDataSource() {
+ val initializingState = beginInitialization() ?: return
+ initializeDataSource(initializingState)
+ }
+
+ private fun beginInitialization(): DataSourceState.Initializing? {
+ while (true) {
+ val currentState = dataSourceState.get()
+ if (currentState is DataSourceState.Initializing ||
+ currentState is DataSourceState.Ready
+ ) {
+ return null
+ }
+ val initializingState = DataSourceState.Initializing()
+ if (dataSourceState.compareAndSet(currentState, initializingState)) {
+ return initializingState
+ }
+ }
+ }
+
+ private fun initializeDataSource(initializingState: DataSourceState.Initializing) {
+ try {
+ val dataSource = populationDensityDataSourceFactory()
+ val readyState = DataSourceState.Ready(dataSource)
+ check(dataSourceState.compareAndSet(initializingState, readyState)) {
+ "population density data source state changed during initialization"
+ }
+ initializingState.completion.complete(readyState)
+ } catch (exception: IOException) {
+ dataSourceState.compareAndSet(initializingState, DataSourceState.Failed(exception))
+ Log.e(TAG, "population density data source initialization failed", exception)
+ initializingState.completion.completeExceptionally(exception)
+ } catch (error: LinkageError) {
+ dataSourceState.compareAndSet(initializingState, DataSourceState.Failed(error))
+ Log.wtf(TAG, "population density native library initialization failed", error)
+ initializingState.completion.completeExceptionally(error)
+ } catch (throwable: Throwable) {
+ dataSourceState.compareAndSet(initializingState, DataSourceState.NotReady)
+ initializingState.completion.completeExceptionally(throwable)
+ throw throwable
+ }
+ }
+
+ override fun onGetDefaultCoarseningLevel(callback: OutcomeReceiver): Unit =
+ callback.onError(
+ UnsupportedOperationException("default coarsening level is not supported"),
+ )
+
+ override fun onGetCoarsenedS2Cells(
+ latitudeDegrees: Double,
+ longitudeDegrees: Double,
+ numAdditionalCells: Int,
+ callback: OutcomeReceiver,
+ ) {
+ verboseLog(TAG) {
+ "onGetCoarsenedS2Cells numAdditionalCells: $numAdditionalCells"
+ }
+
+ if (numAdditionalCells < 0) {
+ callback.onError(
+ IllegalArgumentException("numAdditionalCells must be non-negative"),
+ )
+ return
+ }
+
+ if (latitudeDegrees !in MIN_LATITUDE..MAX_LATITUDE ||
+ longitudeDegrees !in MIN_LONGITUDE..MAX_LONGITUDE
+ ) {
+ callback.onError(IllegalArgumentException("coordinates are out of bounds"))
+ return
+ }
+
+ queryDataSource(latitudeDegrees, longitudeDegrees, callback)
+ }
+
+ private fun queryDataSource(
+ latitudeDegrees: Double,
+ longitudeDegrees: Double,
+ callback: OutcomeReceiver,
+ ) {
+ while (true) {
+ when (val currentState = dataSourceState.get()) {
+ is DataSourceState.Ready -> {
+ queryReadyDataSource(currentState, latitudeDegrees, longitudeDegrees, callback)
+ return
+ }
+
+ is DataSourceState.Failed -> {
+ callback.onError(currentState.error)
+ return
+ }
+
+ is DataSourceState.Initializing -> {
+ queryWhenInitializationCompletes(
+ currentState,
+ latitudeDegrees,
+ longitudeDegrees,
+ callback,
+ )
+ return
+ }
+
+ DataSourceState.NotReady -> {
+ val initializingState = beginInitialization() ?: continue
+ queryWhenInitializationCompletes(
+ initializingState,
+ latitudeDegrees,
+ longitudeDegrees,
+ callback,
+ )
+ scheduleDataSourceInitialization(initializingState)
+ return
+ }
+ }
+ }
+ }
+
+ private fun queryWhenInitializationCompletes(
+ initializingState: DataSourceState.Initializing,
+ latitudeDegrees: Double,
+ longitudeDegrees: Double,
+ callback: OutcomeReceiver,
+ ) {
+ initializingState.completion.whenComplete { _, error ->
+ try {
+ if (error == null) {
+ queryDataSource(latitudeDegrees, longitudeDegrees, callback)
+ } else {
+ callback.onError(error)
+ }
+ } catch (exception: RuntimeException) {
+ Log.wtf(TAG, "population density deferred result delivery failed", exception)
+ }
+ }
+ if (initializingState.waitTimeoutArmed.compareAndSet(false, true)) {
+ val timeout =
+ Runnable {
+ initializingState.completion.completeExceptionally(
+ TimeoutException("population density initialization timed out"),
+ )
+ }
+ initializationWaitTimeoutHandler.postDelayed(
+ timeout,
+ PopulationDensityProviderBase.QUERY_TIMEOUT_MILLIS,
+ )
+ initializingState.completion.whenComplete { _, _ ->
+ initializationWaitTimeoutHandler.removeCallbacks(timeout)
+ }
+ }
+ }
+
+ private fun scheduleDataSourceInitialization(
+ initializingState: DataSourceState.Initializing,
+ ) {
+ try {
+ initializationExecutor.execute { initializeDataSource(initializingState) }
+ } catch (exception: RejectedExecutionException) {
+ dataSourceState.compareAndSet(initializingState, DataSourceState.Failed(exception))
+ Log.e(TAG, "population density data source initialization scheduling failed", exception)
+ initializingState.completion.completeExceptionally(exception)
+ }
+ }
+
+ private fun queryReadyDataSource(
+ readyState: DataSourceState.Ready,
+ latitudeDegrees: Double,
+ longitudeDegrees: Double,
+ callback: OutcomeReceiver,
+ ) {
+ val dataSource = readyState.dataSource
+ val isVerbose = Log.isLoggable(TAG, Log.VERBOSE)
+ val queryStartElapsedRealtimeNanos =
+ if (isVerbose) SystemClock.elapsedRealtimeNanos() else 0L
+ val coarsenedS2CellId =
+ try {
+ dataSource.getCoarseLocationCellId(latitudeDegrees, longitudeDegrees)
+ } catch (exception: RuntimeException) {
+ dataSourceState.compareAndSet(readyState, DataSourceState.Failed(exception))
+ Log.wtf(TAG, "population density query failed", exception)
+ callback.onError(exception)
+ return
+ } catch (error: LinkageError) {
+ dataSourceState.compareAndSet(readyState, DataSourceState.Failed(error))
+ Log.wtf(TAG, "population density native query failed", error)
+ callback.onError(error)
+ return
+ }
+
+ if (isVerbose) {
+ val queryElapsedTime =
+ (SystemClock.elapsedRealtimeNanos() - queryStartElapsedRealtimeNanos).nanoseconds
+ verboseLog(TAG) {
+ "query took ${queryElapsedTime.inWholeMicroseconds} microseconds"
+ }
+ }
+
+ if (coarsenedS2CellId == S2_CELL_ID_NONE) {
+ val exception =
+ IllegalStateException(
+ "population density query returned an invalid S2 cell ID",
+ )
+ dataSourceState.compareAndSet(readyState, DataSourceState.Failed(exception))
+ Log.wtf(TAG, "population density database query returned an invalid cell", exception)
+ callback.onError(exception)
+ return
+ }
+
+ val currentState = dataSourceState.get()
+ if (currentState !== readyState) {
+ val error =
+ if (currentState is DataSourceState.Failed) {
+ currentState.error
+ } else {
+ IllegalStateException("population density data source changed during query")
+ }
+ callback.onError(error)
+ return
+ }
+
+ verboseLog(VERY_VERBOSE_TAG) { "coarsenedS2CellId: $coarsenedS2CellId" }
+ callback.onResult(longArrayOf(coarsenedS2CellId))
+ }
+}
diff --git a/src/app/grapheneos/populationdensity/PopulationDensityService.kt b/src/app/grapheneos/populationdensity/PopulationDensityService.kt
new file mode 100644
index 0000000..708670b
--- /dev/null
+++ b/src/app/grapheneos/populationdensity/PopulationDensityService.kt
@@ -0,0 +1,51 @@
+package app.grapheneos.populationdensity
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import android.util.Log
+import com.android.internal.os.BackgroundThread
+import java.util.concurrent.Executor
+
+private const val TAG = "PopulationDensityService"
+
+/** Supplies the provider lifecycle operations used by [PopulationDensityService]. */
+interface PopulationDensityServiceProvider {
+ /** Returns the Binder published by the service. */
+ fun getServiceBinder(): IBinder?
+
+ /** Attempts to initialize the provider's data source on a background thread. */
+ fun prewarmDataSource()
+}
+
+/** Hosts the population density provider and schedules best-effort eager initialization. */
+class PopulationDensityService(
+ private val populationDensityProviderFactory: (Context) -> PopulationDensityServiceProvider =
+ { context -> PopulationDensityProviderImpl(context.applicationContext) },
+ private val initializationExecutor: Executor = BackgroundThread.getExecutor(),
+) : Service() {
+ private lateinit var populationDensityProvider: PopulationDensityServiceProvider
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.d(TAG, "onCreate")
+ populationDensityProvider = populationDensityProviderFactory(this)
+ schedulePrewarm()
+ }
+
+ override fun onBind(intent: Intent): IBinder? {
+ Log.d(TAG, "onBind: $intent")
+ schedulePrewarm()
+ return populationDensityProvider.getServiceBinder()
+ }
+
+ override fun onDestroy() {
+ Log.d(TAG, "onDestroy")
+ super.onDestroy()
+ }
+
+ private fun schedulePrewarm() {
+ initializationExecutor.execute(populationDensityProvider::prewarmDataSource)
+ }
+}
diff --git a/tests/Android.bp b/tests/Android.bp
new file mode 100644
index 0000000..3ec819e
--- /dev/null
+++ b/tests/Android.bp
@@ -0,0 +1,53 @@
+android_test {
+ name: "NetworkLocationTests",
+
+ srcs: ["src/**/*.kt"],
+ manifest: "AndroidManifest.xml",
+
+ instrumentation_for: "NetworkLocation",
+ certificate: "platform",
+ platform_apis: true,
+
+ libs: [
+ "com.android.location.provider.impl",
+ ],
+
+ static_libs: [
+ "androidx.test.core",
+ "androidx.test.ext.junit",
+ "androidx.test.rules",
+ "androidx.test.runner",
+ "junit",
+ ],
+
+ test_suites: ["device-tests"],
+}
+
+android_test {
+ name: "NetworkLocationPopulationDensityColdStartTests",
+
+ srcs: [
+ "cold/src/**/*.kt",
+ "src/app/grapheneos/populationdensity/PopulationDensityTestUtils.kt",
+ ],
+ manifest: "cold/AndroidManifest.xml",
+ test_config: "cold/AndroidTest.xml",
+
+ instrumentation_for: "NetworkLocation",
+ certificate: "platform",
+ platform_apis: true,
+
+ libs: [
+ "com.android.location.provider.impl",
+ ],
+
+ static_libs: [
+ "androidx.test.core",
+ "androidx.test.ext.junit",
+ "androidx.test.rules",
+ "androidx.test.runner",
+ "junit",
+ ],
+
+ test_suites: ["device-tests"],
+}
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
new file mode 100644
index 0000000..5abd539
--- /dev/null
+++ b/tests/AndroidManifest.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/cold/AndroidManifest.xml b/tests/cold/AndroidManifest.xml
new file mode 100644
index 0000000..72d969a
--- /dev/null
+++ b/tests/cold/AndroidManifest.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/tests/cold/AndroidTest.xml b/tests/cold/AndroidTest.xml
new file mode 100644
index 0000000..45f8ca1
--- /dev/null
+++ b/tests/cold/AndroidTest.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/cold/src/app/grapheneos/populationdensity/PopulationDensityColdStartTest.kt b/tests/cold/src/app/grapheneos/populationdensity/PopulationDensityColdStartTest.kt
new file mode 100644
index 0000000..4319e98
--- /dev/null
+++ b/tests/cold/src/app/grapheneos/populationdensity/PopulationDensityColdStartTest.kt
@@ -0,0 +1,57 @@
+package app.grapheneos.populationdensity
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.provider.IPopulationDensityProvider
+import android.os.SystemClock
+import android.util.Log
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.ServiceTestRule
+import kotlin.time.Duration.Companion.nanoseconds
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val TAG = "PopulationDensityColdStartTest"
+
+/** Enables, binds, and queries the installed provider service in a dedicated cold process. */
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PopulationDensityColdStartTest {
+ @get:Rule val serviceRule = ServiceTestRule()
+
+ private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
+
+ @Test
+ fun coldServiceInitializationAndFirstQuerySucceed() {
+ val startElapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
+ val componentName = ComponentName(context, PopulationDensityService::class.java)
+ context.packageManager.setComponentEnabledSetting(
+ componentName,
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+ PackageManager.DONT_KILL_APP,
+ )
+ val serviceIntent = Intent(context, PopulationDensityService::class.java)
+ val binder = serviceRule.bindService(serviceIntent)
+ val provider = IPopulationDensityProvider.Stub.asInterface(binder)
+ val s2CellId = provider.getCoarsenedS2CellId(TEST_QUERY_LATITUDE, TEST_QUERY_LONGITUDE)
+ val elapsedTime =
+ (SystemClock.elapsedRealtimeNanos() - startElapsedRealtimeNanos).nanoseconds
+
+ Log.i(
+ TAG,
+ "cold service initialization and first query took " +
+ "${elapsedTime.inWholeMilliseconds} ms",
+ )
+ assertContainingDatabaseCell(
+ TEST_QUERY_LATITUDE,
+ TEST_QUERY_LONGITUDE,
+ s2CellId,
+ TEST_QUERY_EXPECTED_LEVEL,
+ )
+ }
+}
diff --git a/tests/cold/src/app/grapheneos/populationdensity/PopulationDensityColdStateTest.kt b/tests/cold/src/app/grapheneos/populationdensity/PopulationDensityColdStateTest.kt
new file mode 100644
index 0000000..4be59b7
--- /dev/null
+++ b/tests/cold/src/app/grapheneos/populationdensity/PopulationDensityColdStateTest.kt
@@ -0,0 +1,67 @@
+package app.grapheneos.populationdensity
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val STATE_PREFERENCES = "population_density_cold_test_state"
+private const val PRIOR_SERVICE_STATE = "prior_service_state"
+
+/** Saves, disables, and exactly restores the provider service around the cold-start test. */
+@RunWith(AndroidJUnit4::class)
+class PopulationDensityColdStateTest {
+ private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
+ private val componentName = ComponentName(context, PopulationDensityService::class.java)
+ private val preferences =
+ context
+ .createDeviceProtectedStorageContext()
+ .getSharedPreferences(STATE_PREFERENCES, Context.MODE_PRIVATE)
+
+ @Test
+ fun disableProviderService() {
+ check(!preferences.contains(PRIOR_SERVICE_STATE)) {
+ "prior provider service state has not been restored"
+ }
+ val priorServiceState = context.packageManager.getComponentEnabledSetting(componentName)
+ check(preferences.edit().putInt(PRIOR_SERVICE_STATE, priorServiceState).commit()) {
+ "failed to persist prior provider service state"
+ }
+ context.packageManager.setComponentEnabledSetting(
+ componentName,
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP,
+ )
+ }
+
+ @Test
+ fun restoreProviderService() {
+ check(preferences.contains(PRIOR_SERVICE_STATE)) {
+ "prior provider service state is unavailable"
+ }
+ restorePriorServiceState()
+ }
+
+ @Test
+ fun restoreProviderServiceIfNeeded() {
+ if (!preferences.contains(PRIOR_SERVICE_STATE)) {
+ return
+ }
+ restorePriorServiceState()
+ }
+
+ private fun restorePriorServiceState() {
+ val priorServiceState = preferences.getInt(PRIOR_SERVICE_STATE, Int.MIN_VALUE)
+ context.packageManager.setComponentEnabledSetting(
+ componentName,
+ priorServiceState,
+ PackageManager.DONT_KILL_APP,
+ )
+ check(preferences.edit().remove(PRIOR_SERVICE_STATE).commit()) {
+ "failed to clear prior provider service state"
+ }
+ }
+}
diff --git a/tests/src/app/grapheneos/populationdensity/PopulationDensityLocalDataSourceTest.kt b/tests/src/app/grapheneos/populationdensity/PopulationDensityLocalDataSourceTest.kt
new file mode 100644
index 0000000..674c8ee
--- /dev/null
+++ b/tests/src/app/grapheneos/populationdensity/PopulationDensityLocalDataSourceTest.kt
@@ -0,0 +1,88 @@
+package app.grapheneos.populationdensity
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import kotlin.random.Random
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val FUZZ_ITERATION_COUNT = 2_000
+
+/** Exercises the packaged database through the installed APK and JNI library. */
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PopulationDensityLocalDataSourceTest {
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val dataSource by lazy { PopulationDensityLocalDataSource(context) }
+
+ @Test
+ fun knownLocationsReturnExpectedDensityLevels() {
+ val locations =
+ arrayOf(
+ Triple(40.7128, -74.0060, 12),
+ Triple(27.9881, 86.9250, 10),
+ Triple(23.4162, 25.6628, 5),
+ Triple(0.0, -150.0, 2),
+ Triple(43.4799, -110.7624, 12),
+ Triple(38.5733, -109.5498, 12),
+ Triple(71.2906, -156.7887, 12),
+ Triple(36.3013, -116.4146, 7),
+ )
+
+ for ((latitude, longitude, expectedLevel) in locations) {
+ val s2CellId = dataSource.getCoarseLocationCellId(latitude, longitude)
+ assertContainingDatabaseCell(latitude, longitude, s2CellId, expectedLevel)
+ }
+ }
+
+ @Test
+ fun geographicBoundariesReturnValidLevels() {
+ val boundaries =
+ arrayOf(
+ 90.0 to 0.0,
+ -90.0 to 0.0,
+ 0.0 to 0.0,
+ 0.0 to 180.0,
+ 0.0 to -180.0,
+ )
+
+ for ((latitude, longitude) in boundaries) {
+ val s2CellId = dataSource.getCoarseLocationCellId(latitude, longitude)
+ assertContainingDatabaseCell(latitude, longitude, s2CellId)
+ }
+ }
+
+ @Test
+ fun deterministicGlobalQueriesReturnValidLevels() {
+ val random = Random(0)
+
+ repeat(FUZZ_ITERATION_COUNT) {
+ val latitude = random.nextDouble(MIN_LATITUDE, MAX_LATITUDE)
+ val longitude = random.nextDouble(MIN_LONGITUDE, MAX_LONGITUDE)
+ val s2CellId = dataSource.getCoarseLocationCellId(latitude, longitude)
+ assertContainingDatabaseCell(latitude, longitude, s2CellId)
+ }
+ }
+
+ @Test
+ fun outOfRangeCoordinatesAreRejected() {
+ val invalidCoordinates =
+ arrayOf(
+ 90.5 to 0.0,
+ -90.5 to 0.0,
+ 0.0 to 180.5,
+ 0.0 to -180.5,
+ Double.NaN to 0.0,
+ 0.0 to Double.NaN,
+ )
+
+ for ((latitude, longitude) in invalidCoordinates) {
+ assertThrows(IllegalArgumentException::class.java) {
+ dataSource.getCoarseLocationCellId(latitude, longitude)
+ }
+ }
+ }
+}
diff --git a/tests/src/app/grapheneos/populationdensity/PopulationDensityProviderImplTest.kt b/tests/src/app/grapheneos/populationdensity/PopulationDensityProviderImplTest.kt
new file mode 100644
index 0000000..2d9a601
--- /dev/null
+++ b/tests/src/app/grapheneos/populationdensity/PopulationDensityProviderImplTest.kt
@@ -0,0 +1,679 @@
+package app.grapheneos.populationdensity
+
+import android.content.Context
+import android.os.OutcomeReceiver
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import java.io.IOException
+import java.util.ArrayDeque
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import java.util.concurrent.atomic.AtomicInteger
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val ARBITRARY_CELL_ID = 0x0fed_cba9_8765_4321L
+private const val VALID_LATITUDE = 40.7128
+private const val VALID_LONGITUDE = -74.0060
+private const val ASYNC_TEST_TIMEOUT_SECONDS = 5L
+
+/** Tests provider readiness, failure handling, and Binder-facing query behavior. */
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class PopulationDensityProviderImplTest {
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ private class QueuedExecutor : Executor {
+ private val commands = ArrayDeque()
+
+ val pendingCommandCount: Int
+ get() = commands.size
+
+ override fun execute(command: Runnable) {
+ commands.addLast(command)
+ }
+
+ fun runNext() {
+ commands.removeFirst().run()
+ }
+ }
+
+ private class RecordingCallback : OutcomeReceiver {
+ private val invocation = CountDownLatch(1)
+
+ var invocationCount = 0
+ private set
+ var result: ResultT? = null
+ private set
+ var error: Throwable? = null
+ private set
+
+ override fun onResult(result: ResultT) {
+ invocationCount++
+ this.result = result
+ invocation.countDown()
+ }
+
+ override fun onError(error: Throwable) {
+ invocationCount++
+ this.error = error
+ invocation.countDown()
+ }
+
+ fun awaitInvocation(): Boolean =
+ invocation.await(ASYNC_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ }
+
+ @Test
+ fun defaultCoarseningLevelReportsError() {
+ val provider = PopulationDensityProviderImpl(context)
+ val callback = RecordingCallback()
+
+ provider.onGetDefaultCoarseningLevel(callback)
+
+ assertTrue(callback.error is UnsupportedOperationException)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ }
+
+ @Test
+ fun notReadyQueriesShareBackgroundInitialization() {
+ val initializationExecutor = QueuedExecutor()
+ var factoryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context, initializationExecutor) {
+ factoryInvocationCount++
+ PopulationDensityDataSource { _, _ -> ARBITRARY_CELL_ID }
+ }
+ val firstCallback = RecordingCallback()
+ val secondCallback = RecordingCallback()
+
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, firstCallback)
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, secondCallback)
+
+ assertEquals(0, firstCallback.invocationCount)
+ assertEquals(0, secondCallback.invocationCount)
+ assertEquals(0, factoryInvocationCount)
+ assertEquals(1, initializationExecutor.pendingCommandCount)
+
+ initializationExecutor.runNext()
+
+ assertArrayEquals(longArrayOf(ARBITRARY_CELL_ID), firstCallback.result)
+ assertNull(firstCallback.error)
+ assertEquals(1, firstCallback.invocationCount)
+ assertArrayEquals(longArrayOf(ARBITRARY_CELL_ID), secondCallback.result)
+ assertNull(secondCallback.error)
+ assertEquals(1, secondCallback.invocationCount)
+ assertEquals(1, factoryInvocationCount)
+ }
+
+ @Test
+ fun initializationWaitTimesOutWithoutCancellingInitialization() {
+ val initializationExecutor = QueuedExecutor()
+ var factoryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context, initializationExecutor) {
+ factoryInvocationCount++
+ PopulationDensityDataSource { _, _ -> ARBITRARY_CELL_ID }
+ }
+ val firstCallback = RecordingCallback()
+
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, firstCallback)
+
+ assertTrue(firstCallback.awaitInvocation())
+ assertTrue(firstCallback.error is TimeoutException)
+ assertNull(firstCallback.result)
+ assertEquals(1, firstCallback.invocationCount)
+ assertEquals(0, factoryInvocationCount)
+
+ val secondCallback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, secondCallback)
+ assertSame(firstCallback.error, secondCallback.error)
+ assertNull(secondCallback.result)
+ assertEquals(1, secondCallback.invocationCount)
+
+ initializationExecutor.runNext()
+ assertEquals(1, firstCallback.invocationCount)
+ assertEquals(1, secondCallback.invocationCount)
+ assertEquals(1, factoryInvocationCount)
+
+ val successfulCallback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(
+ VALID_LATITUDE,
+ VALID_LONGITUDE,
+ 0,
+ successfulCallback,
+ )
+ assertArrayEquals(longArrayOf(ARBITRARY_CELL_ID), successfulCallback.result)
+ assertNull(successfulCallback.error)
+ assertEquals(1, successfulCallback.invocationCount)
+ }
+
+ @Test
+ fun initializingQueryCompletesAfterPrewarmWithoutInvokingFactoryAgain() {
+ val factoryStarted = CountDownLatch(1)
+ val releaseFactory = CountDownLatch(1)
+ var factoryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ factoryInvocationCount++
+ factoryStarted.countDown()
+ check(
+ releaseFactory.await(
+ ASYNC_TEST_TIMEOUT_SECONDS,
+ TimeUnit.SECONDS,
+ ),
+ )
+ PopulationDensityDataSource { _, _ -> ARBITRARY_CELL_ID }
+ }
+ val initializationExecutor = Executors.newSingleThreadExecutor()
+
+ try {
+ initializationExecutor.execute(provider::prewarmDataSource)
+ assertTrue(
+ factoryStarted.await(ASYNC_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ )
+ provider.prewarmDataSource()
+ assertEquals(1, factoryInvocationCount)
+
+ val callback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+ assertEquals(0, callback.invocationCount)
+ assertEquals(1, factoryInvocationCount)
+
+ releaseFactory.countDown()
+ assertTrue(callback.awaitInvocation())
+ assertArrayEquals(longArrayOf(ARBITRARY_CELL_ID), callback.result)
+ assertNull(callback.error)
+ assertEquals(1, callback.invocationCount)
+ } finally {
+ releaseFactory.countDown()
+ initializationExecutor.shutdown()
+ assertTrue(
+ initializationExecutor.awaitTermination(
+ ASYNC_TEST_TIMEOUT_SECONDS,
+ TimeUnit.SECONDS,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun initializingQueryReportsInitializationFailure() {
+ val factoryStarted = CountDownLatch(1)
+ val releaseFactory = CountDownLatch(1)
+ val initializationFailure = IOException("database loading failed")
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ factoryStarted.countDown()
+ check(
+ releaseFactory.await(
+ ASYNC_TEST_TIMEOUT_SECONDS,
+ TimeUnit.SECONDS,
+ ),
+ )
+ throw initializationFailure
+ }
+ val initializationExecutor = Executors.newSingleThreadExecutor()
+
+ try {
+ initializationExecutor.execute(provider::prewarmDataSource)
+ assertTrue(
+ factoryStarted.await(ASYNC_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ )
+
+ val callback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+ assertEquals(0, callback.invocationCount)
+
+ releaseFactory.countDown()
+ assertTrue(callback.awaitInvocation())
+ assertSame(initializationFailure, callback.error)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ } finally {
+ releaseFactory.countDown()
+ initializationExecutor.shutdown()
+ assertTrue(
+ initializationExecutor.awaitTermination(
+ ASYNC_TEST_TIMEOUT_SECONDS,
+ TimeUnit.SECONDS,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun deferredQueryReportsRuntimeFailure() {
+ val initializationExecutor = QueuedExecutor()
+ val queryFailure = IllegalArgumentException("unexpected query failure")
+ val provider =
+ PopulationDensityProviderImpl(context, initializationExecutor) {
+ PopulationDensityDataSource { _, _ -> throw queryFailure }
+ }
+ val callback = RecordingCallback()
+
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+ assertEquals(0, callback.invocationCount)
+
+ initializationExecutor.runNext()
+
+ assertSame(queryFailure, callback.error)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ }
+
+ @Test
+ fun deferredResultCallbackFailureDoesNotInvokeErrorCallback() {
+ val initializationExecutor = QueuedExecutor()
+ val provider =
+ PopulationDensityProviderImpl(context, initializationExecutor) {
+ PopulationDensityDataSource { _, _ -> ARBITRARY_CELL_ID }
+ }
+ val callback =
+ object : OutcomeReceiver {
+ var resultInvocationCount = 0
+ var errorInvocationCount = 0
+ var result: LongArray? = null
+
+ override fun onResult(result: LongArray) {
+ resultInvocationCount++
+ this.result = result
+ throw IllegalStateException("callback failed")
+ }
+
+ override fun onError(error: Throwable) {
+ errorInvocationCount++
+ }
+ }
+
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+ initializationExecutor.runNext()
+
+ assertEquals(1, callback.resultInvocationCount)
+ assertEquals(0, callback.errorInvocationCount)
+ assertArrayEquals(longArrayOf(ARBITRARY_CELL_ID), callback.result)
+ }
+
+ @Test
+ fun notReadyQueryReportsInitializationSchedulingFailure() {
+ val schedulingFailure = RejectedExecutionException("executor rejected initialization")
+ var factoryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context, Executor { throw schedulingFailure }) {
+ factoryInvocationCount++
+ PopulationDensityDataSource { _, _ -> ARBITRARY_CELL_ID }
+ }
+ val callback = RecordingCallback()
+
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+
+ assertSame(schedulingFailure, callback.error)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ assertEquals(0, factoryInvocationCount)
+ }
+
+ @Test
+ fun failedQueryDoesNotRetryUntilBackgroundPrewarm() {
+ val initializationFailure = IOException("database loading failed")
+ var factoryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ factoryInvocationCount++
+ if (factoryInvocationCount == 1) {
+ throw initializationFailure
+ }
+ PopulationDensityDataSource { _, _ -> ARBITRARY_CELL_ID }
+ }
+
+ provider.prewarmDataSource()
+ repeat(2) {
+ val callback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+ assertSame(initializationFailure, callback.error)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ }
+ assertEquals(1, factoryInvocationCount)
+
+ provider.prewarmDataSource()
+ val successfulCallback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(
+ VALID_LATITUDE,
+ VALID_LONGITUDE,
+ 0,
+ successfulCallback,
+ )
+ assertArrayEquals(longArrayOf(ARBITRARY_CELL_ID), successfulCallback.result)
+ assertNull(successfulCallback.error)
+ assertEquals(2, factoryInvocationCount)
+ }
+
+ @Test
+ fun linkageFailureIsReportedWithoutQueryRetry() {
+ val linkageFailure = UnsatisfiedLinkError("native library loading failed")
+ var factoryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ factoryInvocationCount++
+ throw linkageFailure
+ }
+
+ provider.prewarmDataSource()
+ val callback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+
+ assertSame(linkageFailure, callback.error)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ assertEquals(1, factoryInvocationCount)
+ }
+
+ @Test
+ fun unexpectedInitializationFailureIsReportedAndAllowsLaterQueryToRetry() {
+ val initializationFailure = IllegalArgumentException("unexpected factory failure")
+ var executorFailure: Throwable? = null
+ val initializationExecutor =
+ Executor { command ->
+ try {
+ command.run()
+ } catch (throwable: Throwable) {
+ executorFailure = throwable
+ }
+ }
+ var factoryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context, initializationExecutor) {
+ factoryInvocationCount++
+ if (factoryInvocationCount == 1) {
+ throw initializationFailure
+ }
+ PopulationDensityDataSource { _, _ -> ARBITRARY_CELL_ID }
+ }
+
+ val failedCallback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, failedCallback)
+ assertSame(initializationFailure, executorFailure)
+ assertSame(initializationFailure, failedCallback.error)
+ assertNull(failedCallback.result)
+ assertEquals(1, failedCallback.invocationCount)
+ assertEquals(1, factoryInvocationCount)
+
+ val successfulCallback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, successfulCallback)
+
+ assertArrayEquals(longArrayOf(ARBITRARY_CELL_ID), successfulCallback.result)
+ assertNull(successfulCallback.error)
+ assertEquals(1, successfulCallback.invocationCount)
+ assertEquals(2, factoryInvocationCount)
+ }
+
+ @Test
+ fun validCoordinatesReturnPrewarmedCell() {
+ var factoryInvocationCount = 0
+ var queryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ factoryInvocationCount++
+ PopulationDensityDataSource { latitude, longitude ->
+ queryInvocationCount++
+ assertEquals(VALID_LATITUDE, latitude, 0.0)
+ assertEquals(VALID_LONGITUDE, longitude, 0.0)
+ ARBITRARY_CELL_ID
+ }
+ }
+ provider.prewarmDataSource()
+ provider.prewarmDataSource()
+ val callback = RecordingCallback()
+
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+
+ assertArrayEquals(longArrayOf(ARBITRARY_CELL_ID), callback.result)
+ assertNull(callback.error)
+ assertEquals(1, callback.invocationCount)
+ assertEquals(1, factoryInvocationCount)
+ assertEquals(1, queryInvocationCount)
+ }
+
+ @Test
+ fun invalidCoordinatesAreRejectedBeforeReadinessCheck() {
+ var factoryInvoked = false
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ factoryInvoked = true
+ PopulationDensityDataSource { _, _ -> ARBITRARY_CELL_ID }
+ }
+ val invalidCoordinates =
+ arrayOf(
+ 90.5 to 0.0,
+ -90.5 to 0.0,
+ 0.0 to 180.5,
+ 0.0 to -180.5,
+ Double.NaN to 0.0,
+ 0.0 to Double.NaN,
+ )
+
+ for ((latitude, longitude) in invalidCoordinates) {
+ val callback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(latitude, longitude, 0, callback)
+ assertTrue(callback.error is IllegalArgumentException)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ }
+ assertFalse(factoryInvoked)
+ }
+
+ @Test
+ fun negativeAdditionalCellCountIsRejectedBeforeReadinessCheck() {
+ var factoryInvoked = false
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ factoryInvoked = true
+ PopulationDensityDataSource { _, _ -> ARBITRARY_CELL_ID }
+ }
+ val callback = RecordingCallback()
+
+ provider.onGetCoarsenedS2Cells(
+ VALID_LATITUDE,
+ VALID_LONGITUDE,
+ -1,
+ callback,
+ )
+
+ assertTrue(callback.error is IllegalArgumentException)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ assertFalse(factoryInvoked)
+ }
+
+ @Test
+ fun runtimeQueryFailureTransitionsProviderToFailed() {
+ val queryFailure = IllegalArgumentException("unexpected query failure")
+ var queryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ PopulationDensityDataSource { _, _ ->
+ queryInvocationCount++
+ throw queryFailure
+ }
+ }
+ provider.prewarmDataSource()
+
+ repeat(2) {
+ val callback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+ assertSame(queryFailure, callback.error)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ }
+ assertEquals(1, queryInvocationCount)
+ }
+
+ @Test
+ fun queryLinkageFailureTransitionsProviderToFailed() {
+ val queryFailure = UnsatisfiedLinkError("native query failed")
+ var queryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ PopulationDensityDataSource { _, _ ->
+ queryInvocationCount++
+ throw queryFailure
+ }
+ }
+ provider.prewarmDataSource()
+
+ repeat(2) {
+ val callback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(VALID_LATITUDE, VALID_LONGITUDE, 0, callback)
+ assertSame(queryFailure, callback.error)
+ assertNull(callback.result)
+ assertEquals(1, callback.invocationCount)
+ }
+ assertEquals(1, queryInvocationCount)
+ }
+
+ @Test
+ fun concurrentSuccessFailsClosedAfterAnotherQueryFails() {
+ val firstQueryStarted = CountDownLatch(1)
+ val secondQueryStarted = CountDownLatch(1)
+ val releaseFirstQuery = CountDownLatch(1)
+ val releaseSecondQuery = CountDownLatch(1)
+ val queryInvocationCount = AtomicInteger()
+ val queryFailure = IllegalStateException("native query failed")
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ PopulationDensityDataSource { _, _ ->
+ when (queryInvocationCount.incrementAndGet()) {
+ 1 -> {
+ firstQueryStarted.countDown()
+ check(
+ releaseFirstQuery.await(
+ ASYNC_TEST_TIMEOUT_SECONDS,
+ TimeUnit.SECONDS,
+ ),
+ )
+ throw queryFailure
+ }
+
+ 2 -> {
+ secondQueryStarted.countDown()
+ check(
+ releaseSecondQuery.await(
+ ASYNC_TEST_TIMEOUT_SECONDS,
+ TimeUnit.SECONDS,
+ ),
+ )
+ ARBITRARY_CELL_ID
+ }
+
+ else -> {
+ error("unexpected query invocation")
+ }
+ }
+ }
+ }
+ provider.prewarmDataSource()
+ val queryExecutor = Executors.newFixedThreadPool(2)
+ val firstCallback = RecordingCallback()
+ val secondCallback = RecordingCallback()
+
+ try {
+ val firstQuery =
+ queryExecutor.submit {
+ provider.onGetCoarsenedS2Cells(
+ VALID_LATITUDE,
+ VALID_LONGITUDE,
+ 0,
+ firstCallback,
+ )
+ }
+ assertTrue(
+ firstQueryStarted.await(ASYNC_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ )
+ val secondQuery =
+ queryExecutor.submit {
+ provider.onGetCoarsenedS2Cells(
+ VALID_LATITUDE,
+ VALID_LONGITUDE,
+ 0,
+ secondCallback,
+ )
+ }
+ assertTrue(
+ secondQueryStarted.await(ASYNC_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ )
+
+ releaseFirstQuery.countDown()
+ firstQuery.get(ASYNC_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ releaseSecondQuery.countDown()
+ secondQuery.get(ASYNC_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+
+ assertSame(queryFailure, firstCallback.error)
+ assertNull(firstCallback.result)
+ assertEquals(1, firstCallback.invocationCount)
+ assertSame(queryFailure, secondCallback.error)
+ assertNull(secondCallback.result)
+ assertEquals(1, secondCallback.invocationCount)
+ assertEquals(2, queryInvocationCount.get())
+ } finally {
+ releaseFirstQuery.countDown()
+ releaseSecondQuery.countDown()
+ queryExecutor.shutdownNow()
+ assertTrue(
+ queryExecutor.awaitTermination(
+ ASYNC_TEST_TIMEOUT_SECONDS,
+ TimeUnit.SECONDS,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun invalidCellTransitionsProviderToFailed() {
+ var queryInvocationCount = 0
+ val provider =
+ PopulationDensityProviderImpl(context) {
+ PopulationDensityDataSource { _, _ ->
+ queryInvocationCount++
+ S2_CELL_ID_NONE
+ }
+ }
+ provider.prewarmDataSource()
+
+ val firstCallback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(
+ VALID_LATITUDE,
+ VALID_LONGITUDE,
+ 0,
+ firstCallback,
+ )
+ assertTrue(firstCallback.error is IllegalStateException)
+ assertNull(firstCallback.result)
+ assertEquals(1, firstCallback.invocationCount)
+
+ val secondCallback = RecordingCallback()
+ provider.onGetCoarsenedS2Cells(
+ VALID_LATITUDE,
+ VALID_LONGITUDE,
+ 0,
+ secondCallback,
+ )
+ assertSame(firstCallback.error, secondCallback.error)
+ assertNull(secondCallback.result)
+ assertEquals(1, secondCallback.invocationCount)
+ assertEquals(1, queryInvocationCount)
+ }
+}
diff --git a/tests/src/app/grapheneos/populationdensity/PopulationDensityServiceTest.kt b/tests/src/app/grapheneos/populationdensity/PopulationDensityServiceTest.kt
new file mode 100644
index 0000000..17a508e
--- /dev/null
+++ b/tests/src/app/grapheneos/populationdensity/PopulationDensityServiceTest.kt
@@ -0,0 +1,102 @@
+package app.grapheneos.populationdensity
+
+import android.content.Context
+import android.content.Intent
+import android.location.provider.IPopulationDensityProvider
+import android.os.Binder
+import android.os.IBinder
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.rule.ServiceTestRule
+import java.util.ArrayDeque
+import java.util.concurrent.Executor
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests service scheduling through injection and the installed service binding path. */
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PopulationDensityServiceTest {
+ @get:Rule val serviceRule = ServiceTestRule()
+
+ private class QueuedExecutor : Executor {
+ private val commands = ArrayDeque()
+
+ val pendingCommandCount: Int
+ get() = commands.size
+
+ override fun execute(command: Runnable) {
+ commands.addLast(command)
+ }
+
+ /** Runs the next queued command. */
+ fun runNext() {
+ commands.removeFirst().run()
+ }
+ }
+
+ private class FakeServiceProvider : PopulationDensityServiceProvider {
+ val binder = Binder()
+ var prewarmInvocationCount = 0
+ private set
+
+ override fun getServiceBinder(): IBinder = binder
+
+ override fun prewarmDataSource() {
+ prewarmInvocationCount++
+ }
+ }
+
+ @Test
+ fun onCreateAndBindSchedulePrewarmWithoutRunningItInline() {
+ val queuedExecutor = QueuedExecutor()
+ val fakeProvider = FakeServiceProvider()
+ var factoryContext: Context? = null
+ val service =
+ PopulationDensityService(
+ populationDensityProviderFactory = { context ->
+ factoryContext = context
+ fakeProvider
+ },
+ initializationExecutor = queuedExecutor,
+ )
+
+ service.onCreate()
+
+ assertSame(service, factoryContext)
+ assertEquals(1, queuedExecutor.pendingCommandCount)
+ assertEquals(0, fakeProvider.prewarmInvocationCount)
+ assertSame(fakeProvider.binder, service.onBind(Intent()))
+ assertEquals(2, queuedExecutor.pendingCommandCount)
+ assertEquals(0, fakeProvider.prewarmInvocationCount)
+
+ queuedExecutor.runNext()
+ assertEquals(1, fakeProvider.prewarmInvocationCount)
+ assertEquals(1, queuedExecutor.pendingCommandCount)
+ queuedExecutor.runNext()
+ assertEquals(2, fakeProvider.prewarmInvocationCount)
+ assertEquals(0, queuedExecutor.pendingCommandCount)
+ service.onDestroy()
+ }
+
+ @Test
+ fun installedServiceReturnsContainingDatabaseCell() {
+ val context: Context = ApplicationProvider.getApplicationContext()
+ val serviceIntent = Intent(context, PopulationDensityService::class.java)
+
+ val binder = serviceRule.bindService(serviceIntent)
+ val provider = IPopulationDensityProvider.Stub.asInterface(binder)
+ val s2CellId = provider.getCoarsenedS2CellId(TEST_QUERY_LATITUDE, TEST_QUERY_LONGITUDE)
+
+ assertContainingDatabaseCell(
+ TEST_QUERY_LATITUDE,
+ TEST_QUERY_LONGITUDE,
+ s2CellId,
+ TEST_QUERY_EXPECTED_LEVEL,
+ )
+ }
+}
diff --git a/tests/src/app/grapheneos/populationdensity/PopulationDensityTestUtils.kt b/tests/src/app/grapheneos/populationdensity/PopulationDensityTestUtils.kt
new file mode 100644
index 0000000..64ab1d0
--- /dev/null
+++ b/tests/src/app/grapheneos/populationdensity/PopulationDensityTestUtils.kt
@@ -0,0 +1,38 @@
+package app.grapheneos.populationdensity
+
+import com.android.internal.location.geometry.S2CellIdUtils
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+
+internal const val TEST_QUERY_LATITUDE = 40.7128
+internal const val TEST_QUERY_LONGITUDE = -74.0060
+internal const val TEST_QUERY_EXPECTED_LEVEL = 12
+
+/** Verifies that a database cell is a valid ancestor containing the queried coordinate. */
+internal fun assertContainingDatabaseCell(
+ latitude: Double,
+ longitude: Double,
+ s2CellId: Long,
+ expectedLevel: Int? = null,
+) {
+ val level = S2CellIdUtils.getLevel(s2CellId)
+ val coordinateDescription = "($latitude, $longitude)"
+ assertTrue(
+ "invalid S2 level $level for coordinates $coordinateDescription",
+ level in 0..MAX_DATABASE_S2_LEVEL,
+ )
+ if (expectedLevel != null) {
+ assertEquals(
+ "unexpected S2 level for coordinates $coordinateDescription",
+ expectedLevel,
+ level,
+ )
+ }
+
+ val queriedLeafCellId = S2CellIdUtils.fromLatLngDegrees(latitude, longitude)
+ assertEquals(
+ "returned S2 cell does not contain coordinates $coordinateDescription",
+ s2CellId,
+ S2CellIdUtils.getParent(queriedLeafCellId, level),
+ )
+}