diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2f9961a..83b6cc5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -130,6 +130,43 @@ jobs:
- name: Build iOS (release, no codesign)
run: flutter build ios --release --no-codesign
+ build-android:
+ name: Build Android
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ cache: true
+
+ - name: Cache Gradle
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: gradle-${{ runner.os }}-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }}
+ restore-keys: gradle-${{ runner.os }}-
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ - name: Run code generation
+ run: dart run build_runner build --delete-conflicting-outputs
+
+ # No signing secrets on PRs: the release build falls back to the debug
+ # keystore (see android/app/build.gradle.kts), so this validates the
+ # whole release pipeline short of the Play upload.
+ - name: Build Android app bundle
+ run: flutter build appbundle --release
+
build-web:
name: Build Web
runs-on: ubuntu-latest
diff --git a/.github/workflows/playstore.yml b/.github/workflows/playstore.yml
new file mode 100644
index 0000000..1e69ec8
--- /dev/null
+++ b/.github/workflows/playstore.yml
@@ -0,0 +1,141 @@
+name: Deploy to Play Store
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "lib/**"
+ - "android/**"
+ - "pubspec.yaml"
+ - "pubspec.lock"
+ - ".github/workflows/playstore.yml"
+ workflow_dispatch:
+ inputs:
+ track:
+ description: "Play track to upload to"
+ type: choice
+ options: [internal, alpha]
+ default: internal
+ release_status:
+ description: "Release status (a brand-new app must use draft until its first release is rolled out in the Play Console)"
+ type: choice
+ options: [draft, completed]
+ default: draft
+
+# Serialize Play runs. The version code is max(track version codes) + 1, so two
+# concurrent runs compute the SAME code and the second upload is rejected as a
+# duplicate. Queue instead of racing, and never cancel an in-flight upload.
+concurrency:
+ group: playstore-deploy
+ cancel-in-progress: false
+
+env:
+ FLUTTER_VERSION: "3.41.4"
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ # Without Play credentials the workflow still builds and attaches the signed
+ # AAB (the very first upload of a new app must happen manually in the Play
+ # Console anyway - the publisher API can't create it).
+ - name: Check Play credentials
+ id: play
+ env:
+ PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
+ run: |
+ if [ -n "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
+ echo "available=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "available=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::PLAY_SERVICE_ACCOUNT_JSON is not set - building the signed AAB as an artifact and skipping the Play upload."
+ fi
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.3"
+
+ - name: Calculate build number
+ id: build_number
+ env:
+ PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
+ run: |
+ VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//')
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ if [ "${{ steps.play.outputs.available }}" = "true" ]; then
+ printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/play-service-account.json"
+ export PLAY_JSON_KEY_PATH="$RUNNER_TEMP/play-service-account.json"
+ cd android
+ gem install fastlane
+ fastlane next_build_number
+ else
+ echo "build_number=${{ github.run_number }}" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - name: Setup Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ cache: true
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ - name: Run code generation
+ run: dart run build_runner build --delete-conflicting-outputs
+
+ - name: Configure signing
+ env:
+ ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ run: |
+ printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/upload-keystore.jks
+ cat > android/key.properties << EOF
+ storePassword=$ANDROID_KEYSTORE_PASSWORD
+ keyPassword=$ANDROID_KEY_PASSWORD
+ keyAlias=$ANDROID_KEY_ALIAS
+ storeFile=../upload-keystore.jks
+ EOF
+
+ - name: Build AAB
+ run: |
+ flutter build appbundle \
+ --build-number=${{ steps.build_number.outputs.build_number }} \
+ --build-name=${{ steps.build_number.outputs.version }}
+
+ - name: Upload AAB artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: nullfeed-${{ steps.build_number.outputs.version }}-${{ steps.build_number.outputs.build_number }}-aab
+ path: build/app/outputs/bundle/release/app-release.aab
+ if-no-files-found: error
+
+ - name: Upload to Play Store
+ if: steps.play.outputs.available == 'true'
+ env:
+ PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
+ PLAY_TRACK: ${{ inputs.track || 'internal' }}
+ PLAY_RELEASE_STATUS: ${{ inputs.release_status || 'draft' }}
+ run: |
+ printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/play-service-account.json"
+ export PLAY_JSON_KEY_PATH="$RUNNER_TEMP/play-service-account.json"
+ cd android
+ fastlane beta
+
+ - name: Cleanup signing material
+ if: always()
+ run: |
+ rm -f android/key.properties android/upload-keystore.jks "$RUNNER_TEMP/play-service-account.json"
diff --git a/.github/workflows/storelisting.yml b/.github/workflows/storelisting.yml
new file mode 100644
index 0000000..d33bd49
--- /dev/null
+++ b/.github/workflows/storelisting.yml
@@ -0,0 +1,57 @@
+name: Sync Store Listing
+
+# Play listing copy/graphics live in-repo (android/fastlane/metadata); merging a
+# change to them pushes the listing to the Play Console. No binaries here.
+on:
+ push:
+ branches: [main]
+ paths:
+ - "android/fastlane/metadata/**"
+ - ".github/workflows/storelisting.yml"
+ workflow_dispatch:
+
+concurrency:
+ group: storelisting
+ cancel-in-progress: false
+
+jobs:
+ play-listing:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: android
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Check Play credentials
+ id: play
+ env:
+ PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
+ run: |
+ if [ -n "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
+ echo "available=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "available=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::PLAY_SERVICE_ACCOUNT_JSON is not set - skipping the Play listing sync."
+ fi
+
+ - name: Set up Ruby
+ if: steps.play.outputs.available == 'true'
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.3"
+
+ - name: Push listing
+ if: steps.play.outputs.available == 'true'
+ env:
+ PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
+ run: |
+ printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/play-service-account.json"
+ export PLAY_JSON_KEY_PATH="$RUNNER_TEMP/play-service-account.json"
+ gem install fastlane
+ fastlane listing
+
+ - name: Cleanup credentials
+ if: always()
+ run: rm -f "$RUNNER_TEMP/play-service-account.json"
diff --git a/.metadata b/.metadata
index ab7697c..05a325e 100644
--- a/.metadata
+++ b/.metadata
@@ -15,7 +15,7 @@ migration:
- platform: root
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- - platform: web
+ - platform: android
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..d7d3b8a
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,65 @@
+import java.io.FileInputStream
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+// CI decodes the upload keystore and writes android/key.properties; local builds
+// without it fall back to debug signing so `flutter run --release` still works.
+val keystorePropertiesFile = rootProject.file("key.properties")
+val keystoreProperties = Properties()
+if (keystorePropertiesFile.exists()) {
+ FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) }
+}
+
+android {
+ namespace = "codes.julian.nullfeed"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ applicationId = "codes.julian.nullfeed"
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ signingConfigs {
+ if (keystorePropertiesFile.exists()) {
+ create("release") {
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ storeFile = file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ signingConfig = if (keystorePropertiesFile.exists()) {
+ signingConfigs.getByName("release")
+ } else {
+ signingConfigs.getByName("debug")
+ }
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..80ba1eb
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/codes/julian/nullfeed/MainActivity.kt b/android/app/src/main/kotlin/codes/julian/nullfeed/MainActivity.kt
new file mode 100644
index 0000000..29c7fef
--- /dev/null
+++ b/android/app/src/main/kotlin/codes/julian/nullfeed/MainActivity.kt
@@ -0,0 +1,5 @@
+package codes.julian.nullfeed
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..57992db
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..57992db
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..80b730f
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..7e6a67c
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6775af2
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..46436d2
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..0cfc056
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..8f89fae
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..0a5a9a9
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..f5b74fe
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..ea35289
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..a56e50e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..400579e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..ae0870f
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+
+ #07090D
+
diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..c041278
--- /dev/null
+++ b/android/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #7C4DFF
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile
new file mode 100644
index 0000000..fd9c289
--- /dev/null
+++ b/android/fastlane/Fastfile
@@ -0,0 +1,65 @@
+default_platform(:android)
+
+PACKAGE_NAME = "codes.julian.nullfeed"
+
+platform :android do
+ desc "Get next version code from Play (max across all tracks + 1)"
+ lane :next_build_number do
+ codes = []
+ %w[internal alpha beta production].each do |track|
+ begin
+ codes += google_play_track_version_codes(
+ package_name: PACKAGE_NAME,
+ track: track,
+ json_key: ENV["PLAY_JSON_KEY_PATH"],
+ )
+ rescue => e
+ UI.message("Track #{track}: #{e.message.to_s.lines.first&.strip} (skipping)")
+ end
+ end
+ next_build = (codes.max || 0) + 1
+
+ UI.message("Play version codes: #{codes.inspect}, next: #{next_build}")
+ File.write(ENV['GITHUB_OUTPUT'], "build_number=#{next_build}\n", mode: "a") if ENV['GITHUB_OUTPUT']
+ end
+
+ desc "Upload the release AAB to Play"
+ lane :beta do
+ begin
+ upload_to_play_store(
+ package_name: PACKAGE_NAME,
+ track: ENV["PLAY_TRACK"] || "internal",
+ release_status: ENV["PLAY_RELEASE_STATUS"] || "draft",
+ aab: "../build/app/outputs/bundle/release/app-release.aab",
+ json_key: ENV["PLAY_JSON_KEY_PATH"],
+ skip_upload_metadata: true,
+ skip_upload_changelogs: true,
+ skip_upload_images: true,
+ skip_upload_screenshots: true,
+ )
+ rescue => e
+ # Bootstrap: the publisher API cannot create apps. Until NullFeed is
+ # created once in the Play Console UI, uploads are a graceful no-op
+ # (the signed AAB is still attached as a workflow artifact).
+ if e.message.to_s =~ /[Pp]ackage not found|applicationNotFound/
+ UI.important("codes.julian.nullfeed doesn't exist in the Play Console yet - create it once in the Console UI, then re-run. Skipping upload.")
+ else
+ raise
+ end
+ end
+ end
+
+ desc "Push the Play listing (copy + graphics), no binary"
+ lane :listing do
+ upload_to_play_store(
+ package_name: PACKAGE_NAME,
+ json_key: ENV["PLAY_JSON_KEY_PATH"],
+ skip_upload_aab: true,
+ skip_upload_apk: true,
+ skip_upload_changelogs: true,
+ skip_upload_metadata: false,
+ skip_upload_images: false,
+ skip_upload_screenshots: false,
+ )
+ end
+end
diff --git a/android/fastlane/metadata/android/en-US/changelogs/default.txt b/android/fastlane/metadata/android/en-US/changelogs/default.txt
new file mode 100644
index 0000000..49d01db
--- /dev/null
+++ b/android/fastlane/metadata/android/en-US/changelogs/default.txt
@@ -0,0 +1 @@
+First internal build of NullFeed for Android.
\ No newline at end of file
diff --git a/android/fastlane/metadata/android/en-US/full_description.txt b/android/fastlane/metadata/android/en-US/full_description.txt
new file mode 100644
index 0000000..c80be47
--- /dev/null
+++ b/android/fastlane/metadata/android/en-US/full_description.txt
@@ -0,0 +1,13 @@
+NullFeed is a personal media center app for your self-hosted video library. Browse your collection with a beautiful, cinematic interface. Enjoy features like resume-aware playback, multi-user profiles, and AI-powered content discovery.
+
+Features:
+• Stream your personal video library from your own server
+• Continue watching from where you left off on any device
+• Multi-user profiles with independent watch history
+• Channel-based organization for your content
+• AI-powered recommendations to discover new content
+• Dark theme optimized for OLED displays
+
+NullFeed connects to your self-hosted NullFeed server. Set up your server, point the app to it, and enjoy your personal media library from anywhere.
+
+No ads. No tracking. No promos. No monthly fee.
diff --git a/android/fastlane/metadata/android/en-US/images/featureGraphic.png b/android/fastlane/metadata/android/en-US/images/featureGraphic.png
new file mode 100644
index 0000000..e6ef6ee
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/featureGraphic.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/icon.png b/android/fastlane/metadata/android/en-US/images/icon.png
new file mode 100644
index 0000000..b7b89da
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/phoneScreenshots/01_home.png b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/01_home.png
new file mode 100644
index 0000000..3c4f66f
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/01_home.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/phoneScreenshots/02_channel_detail.png b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/02_channel_detail.png
new file mode 100644
index 0000000..d12bfe7
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/02_channel_detail.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/phoneScreenshots/03_library.png b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/03_library.png
new file mode 100644
index 0000000..7967164
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/03_library.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/phoneScreenshots/04_discover.png b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/04_discover.png
new file mode 100644
index 0000000..ca29dbc
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/04_discover.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/phoneScreenshots/05_profile_picker.png b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/05_profile_picker.png
new file mode 100644
index 0000000..36d0bac
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/phoneScreenshots/05_profile_picker.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/01_home.png b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/01_home.png
new file mode 100644
index 0000000..0793102
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/01_home.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/02_channel_detail.png b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/02_channel_detail.png
new file mode 100644
index 0000000..17cad8d
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/02_channel_detail.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_library.png b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_library.png
new file mode 100644
index 0000000..3473221
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_library.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/04_discover.png b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/04_discover.png
new file mode 100644
index 0000000..4a172e1
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/04_discover.png differ
diff --git a/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/05_profile_picker.png b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/05_profile_picker.png
new file mode 100644
index 0000000..b58b7cd
Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/05_profile_picker.png differ
diff --git a/android/fastlane/metadata/android/en-US/short_description.txt b/android/fastlane/metadata/android/en-US/short_description.txt
new file mode 100644
index 0000000..ac95efa
--- /dev/null
+++ b/android/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+Your self-hosted video library — no ads, no tracking, no algorithm.
\ No newline at end of file
diff --git a/android/fastlane/metadata/android/en-US/title.txt b/android/fastlane/metadata/android/en-US/title.txt
new file mode 100644
index 0000000..85dd383
--- /dev/null
+++ b/android/fastlane/metadata/android/en-US/title.txt
@@ -0,0 +1 @@
+NullFeed
\ No newline at end of file
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..fbee1d8
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e4ef43f
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..ca7fe06
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")