Skip to content

About

Offline Tanzanian addresses for Kotlin Multiplatform: region to kitongoji browsing, postcode lookup, type-ahead search, ward map positions (latitude/longitude), and Compose widgets.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

TZ Address Kit

Offline Tanzanian address data for Kotlin Multiplatform: type-ahead search, postcode lookup and cascading Region → District → Ward → Mtaa/Village → Kitongoji browsing, with an optional Compose picker. No network, no API key. The data ships inside the library.

Search Picker Picker (Swahili)
Search Picker Picker in Swahili

What data it covers

The mainland regions come from TCRA's regional postcode lists (Government Gazette Notice No. 240 of 22 April 2016); Zanzibar comes from the 2012-07-30 list, which is the only one that has it: 31 regions, 168 districts, 4,058 wards (5-digit postcodes), 17,039 mtaa/villages/shehia and 64,262 kitongoji. A shehia is stored as an mtaa.

Wards also carry a position (latitude, longitude) for centring a map: 3,194 of the 4,058 wards have one, the rest are null. It comes from OpenStreetMap ward boundaries, so it is under the ODbL and needs a credit (see Licence).

A ward's postcode is its id. Between the 2012 list and this one, 162 wards got a new postcode, so postcodes you stored from 0.1.x may no longer match; dataset/edition-changes.md lists each old and new postcode. Sources and attribution are in DATA_SOURCE.md.

Artifacts

Artifact For Needs
io.github.omarshehe:tz-address-core the model and the AddressRepository interface Kotlin 2.2+ (iOS: 2.4+)
io.github.omarshehe:tz-address-data the bundled database and the repository (includes core) Kotlin 2.2+ (iOS: 2.4+)
io.github.omarshehe:tz-address-ui AddressSearchField and AddressPicker (Compose) Compose Multiplatform 1.12, a recent Kotlin with the Compose plugin; pulls in io.github.omarshehe:forminput 2.1.0

Targets: Android (minSdk 21 for core, 23 for data and ui), JVM 17, iOS (arm64 and simulator arm64). Backends and headless apps use core + data and never pull in Compose. iOS libraries carry the version of the compiler that built them (2.4.20), hence Kotlin 2.4+ there.

Install

// Android: dependencies { … }.  Multiplatform: commonMain.dependencies { … }
implementation("io.github.omarshehe:tz-address-data:0.2.0")
implementation("io.github.omarshehe:tz-address-ui:0.2.0") // optional, the Compose widgets
  • Repositories: mavenCentral() and google(). On a JVM backend google() is needed too, because androidx.sqlite is only on Google's Maven.
  • Multiplatform: commonMain sees the AddressRepository interface; each platform creates the store.
  • iOS: the Kotlin artifact has no data inside it, because iOS has no classpath. Download tz-address.db and tz-address.db.version from the matching GitHub release and add both files to your app target (Xcode: Build Phases → Copy Bundle Resources). Without them, createAddressRepository() fails with "Bundled resource … not found".

Use it

Every call is a suspend function (add org.jetbrains.kotlinx:kotlinx-coroutines-core if you do not have it). Create the store once, share it, and close it when done: it holds an open database connection.

val addresses: AddressStore = createAddressRepository(context)                  // Android
// val addresses: AddressStore = createAddressRepository("/var/lib/myapp/tz")   // JVM: folder for the working copy of the database
// val addresses: AddressStore = createAddressRepository()                      // iOS

val hits = addresses.search("kivu")            // ranked suggestions: label, full path, postcode
val ward = addresses.byPostcode("11101")       // AddressPath? (region, district, ward) or null
val wards = addresses.byPrefix("111")          // every ward whose postcode starts with 111
val valid = addresses.isValidPostcode("11101") // true only for ward postcodes in the dataset

val regions = addresses.regions()              // browse, ordered by name
val districts = addresses.districts(regions.first().code)

addresses.close()

search matches names and ancestor names, ignoring case and apostrophes (jangombe finds Jang'ombe, ilala kariakoo finds Kariakoo under Ilala). A blank query returns an empty list; limit is clamped to 1..100. The first call installs the bundled database into app storage (about 9 MB); later calls reuse it.

Ward positions

val ward = addresses.byPostcode("11101")?.ward
val lat = ward?.latitude   // Double?, null when the ward has no known position
val lon = ward?.longitude  // both are set, or both are null

The position is a point inside the ward, to centre a map when someone picks a ward. It is not the location of a house or a street, so let people place an exact point themselves. ward-points-report.md and ward-points-anomalies.csv in dataset/ list the wards without a position and why.

Your own places

A place the dataset lacks (a village added since 2016, a ward with a newer postcode) can be added when you create the store. It then shows up in browse lists, lookups and search beside the built-in places, with the same ordering and ranking.

val addresses = createAddressRepository(
    context,
    extraPlaces = listOf(
        ExtraPlace(Level.MTAA, "Soko Jipya", parentId = "11101"),                  // a village under ward 11101
        ExtraPlace(Level.WARD, "Mji Mpya", parentId = "11", postcode = "11110"),   // a ward under district 11
    ),
)
addresses.extraPlaceStatuses()   // each entry with its id and ACTIVE or SHADOWED
  • Wards, mtaa/villages and kitongoji only. Extra regions and districts, and moving a ward to another district, are not supported.
  • parentId is a district code for a ward, a ward postcode for an mtaa, and an mtaa id for a kitongoji (built-in or extra). A ward needs a five-digit postcode that starts with its district code; it can also carry latitude and longitude.
  • A bad list fails creation with an ExtraPlacesInvalidException that lists every problem.
  • A place the dataset already has (same postcode, or same parent and name) is SHADOWED: the built-in one is returned, and you can drop your entry.
  • The library stores nothing: keep the list in your app and pass it each time. It is meant for tens or hundreds of places.

The Compose picker

AddressSearchField(
    repository = addresses,
    onSelected = { path -> println(path.ward?.postcode) },
)

var value by remember { mutableStateOf<AddressPath?>(null) }
AddressPicker(
    repository = addresses,
    value = value,
    onValueChange = { value = it },
    requiredLevel = Level.WARD, // null is reported until this level is chosen
)

Both use your MaterialTheme, ship English and Swahili strings, and survive rotation. A level with nothing listed in the source (some wards have no mtaa, most mtaa have no kitongoji) shows "None listed" and counts as complete. They are built on forminput, so a FormInputTheme around them changes their style and shape. The :app module is a working sample.

JVM hosts: the JVM target uses sqlite-jdbc, so it runs on macOS (Intel and Apple silicon), Windows (x64, arm64) and Linux (including Alpine). It adds about 12 MB to a JVM app; Android and iOS use the AndroidX bundled SQLite.

Updating the dataset

The dataset is regenerated from the published postcode list (see DATA_SOURCE.md), never edited by hand:

./gradlew :importer:importPostcodes -Ppdf=/path/to/2012-postcode-list.pdf -PpdfDir=/path/to/tcra-regional-pdfs

-PpdfDir is a folder of TCRA's regional files (Arusha_23000.pdf, ...); -Ppdf is the 2012 list, used for Zanzibar. Without -PpdfDir the single 2012 list is imported alone (add -PsourceEdition="<label>").

This rewrites dataset/tz-address.json, dataset/import-report.md, dataset/import-anomalies.csv and, when the edition changed, dataset/edition-changes.md. It fails on bad postcodes, duplicates or too many parse anomalies. The bundled database is built from that file at build time (./gradlew :data:generateAddressDb, which also checks its table counts against the report). Then bump VERSION_NAME in gradle.properties and add a CHANGELOG.md entry.

Ward positions are added to the committed dataset/tz-address.json by a second step (the boundary files are public and, like the postcode list, are not stored here; the pinned download links are in DATA_SOURCE.md):

./gradlew :importer:importWardPoints -PwardBoundaries=/path/geoBoundaries-TZA-ADM3_simplified.geojson \
    -PdistrictBoundaries=/path/geoBoundaries-TZA-ADM2_simplified.geojson

It fails when fewer than 75% of wards get a position (-PminMatchRatio to change) or a point is outside Tanzania, and writes dataset/ward-points-report.md and dataset/ward-points-anomalies.csv. Re-importing the postcode list keeps the positions already in the dataset.

Versioning

  • MAJOR: breaking API change, or ids (Mtaa.id, Kitongoji.id) changing for existing places.
  • MINOR: new API, or a new dataset edition.
  • PATCH: data corrections that do not change ids.

Each release records the artifact version and the dataset in CHANGELOG.md; addresses.info() returns the same at runtime.

Building and releasing

  • ./gradlew :core:jvmTest :data:jvmTest :ui:jvmTest runs the unit and desktop UI tests; ./gradlew :app:connectedDebugAndroidTest runs both widgets over the real database on a device.
  • ./gradlew publishToMavenLocal publishes core, data and ui to ~/.m2 for trying them in another project (add mavenLocal() first there).
  • Releasing: set VERSION_NAME, update CHANGELOG.md, commit and push. Then run ./gradlew publishAndReleaseToMavenCentral (needs the Central token and signing key in ~/.gradle/gradle.properties): it signs, uploads and releases once Central validates. ./gradlew publishToMavenCentral only stages the deployment for a manual Publish in the Central Portal. A release cannot be undone. Afterwards push the tag (git tag v<version> && git push origin v<version>): .github/workflows/release.yml checks the repository and attaches the database files to the GitHub release. :app and :importer are never published.

Licence

Code: Apache License 2.0. The address data comes from a public postcode list (see DATA_SOURCE.md).

Ward positions (Ward.latitude, Ward.longitude) are derived from OpenStreetMap data and are licensed under the Open Database License (ODbL) 1.0. If your app uses them, credit © OpenStreetMap contributors where your app shows credits (an About or credits screen is enough). The full text is in the dataset itself: addresses.info().attribution.

For AI coding assistants

.claude/skills/use-tz-address-kit/SKILL.md teaches an assistant such as Claude Code how to use this library. Copy the folder into another project's .claude/skills/ to use it there.

About

Offline Tanzanian addresses for Kotlin Multiplatform: region to kitongoji browsing, postcode lookup, type-ahead search, ward map positions (latitude/longitude), and Compose widgets.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages