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), + ) +}