diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..70fecc52d --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,71 @@ +name: Android CI + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set app version + run: | + echo "APP_VERSION=$(sed -n 's/.*android:versionName="\([^"]*\)".*/\1/p' app/src/main/AndroidManifest.xml)" >> "$GITHUB_ENV" + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Find and Replace RC Key + uses: richardrigutins/replace-in-files@v2 + with: + files: '**/*.java' + search-text: '@@___revenuecat_android_api_key__@@' + replacement-text: ${{ secrets.ANDROID_REVENUECAT_API_KEY }} + encoding: 'utf8' + max-parallelism: 10 + + - name: Create Google Services JSON File + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: rm -f app/google-services.json && (echo $GOOGLE_SERVICES_JSON | base64 -di > app/google-services.json) + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build + + - name: Sign APK with keystore + uses: r0adkll/sign-android-release@v1 + id: sign_app + with: + releaseDirectory: app/build/outputs/apk/release + signingKeyBase64: ${{ secrets.KEY_STORE }} + alias: ${{ secrets.KEY_STORE_ALIAS }} + keyStorePassword: ${{ secrets.KEY_STORE_PASS }} + keyPassword: ${{ secrets.KEY_STORE_PASS }} + env: + BUILD_TOOLS_VERSION: "34.0.0" + + - name: Prepare upload artifact + run: | + mkdir -p build/artifacts + cp "${{ steps.sign_app.outputs.signedReleaseFile }}" "build/artifacts/avare_${APP_VERSION}.apk" + + - name: Upload release APK + uses: appleboy/scp-action@v0.1.7 + with: + host: apps4av.org + username: apps4av + password: ${{ secrets.MAMBA_PASSWORD }} + port: 22 + strip_components: 2 + source: build/artifacts/* + target: /home/apps4av/builds diff --git a/.github/workflows/weather.yml b/.github/workflows/weather.yml new file mode 100644 index 000000000..d8918e4b7 --- /dev/null +++ b/.github/workflows/weather.yml @@ -0,0 +1,38 @@ +name: Weather CI + + +on: + schedule: + - cron: '*/10 * * * *' + +jobs: + build: + + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v3 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + sudo apt-get update + sudo apt-get install --fix-missing gdal-bin python3-gdal imagemagick python3-bs4 libwww-perl libxml-parser-perl + pip install regex urllib3 + - name: Build weather + run: | + cd extra/mamba && ./put_tenmin.sh + + - name: SSH to mamba + uses: appleboy/scp-action@v0.1.7 + with: + host: apps4av.org + username: apps4av + password: ${{ secrets.MAMBA_PASSWORD }} + port: 22 + strip_components: 2 + source: "extra/mamba/TFRs.zip,extra/mamba/weather.zip,extra/mamba/conus.zip" + target: /home/apps4av/mamba.dreamhosters.com/new diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index bd9bd5a85..000000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: android -jdk: oraclejdk8 -sudo: false - -android: - components: - # use the latest revision of Android SDK Tools - - tools - - platform-tools - # The BuildTools version used by the project - - build-tools-25.0.0 - # The SDK version used to compile the project - - android-25 - # Additional components, otherwise bild fails to find license signature - - extra-google-google_play_services - - extra-google-m2repository - - extra-android-m2repository - -licenses: - - 'android-sdk-preview-license-.+' - - 'android-sdk-license-.+' - - 'google-gdk-license-.+' - -script: - bash gradlew test --continue --stacktrace \ No newline at end of file diff --git a/README.md b/README.md index bfc5c6c6f..1794a3720 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ +** This project is deprecated/discontinued. See the new project under https://github.com/apps4av/avarex. + + + avare ===== +Note: A more modern multi platofrm version of Avare is available as AvareX on Android Play Store, Apple App Store, Windows App Store, and Linux Snapcraft Store. + Avare Aviation GPS for Android. Avare is pronounced "Ah-vAir" - like "aware" with a "v" and can be manually installed from our servers (see our website). Download from the Google Play Store: https://play.google.com/store/apps/details?id=com.ds.avare&hl=en diff --git a/app/build.gradle b/app/build.gradle index d41dcb194..f72280949 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,13 +2,14 @@ apply plugin: 'com.android.application' apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.firebase.crashlytics' android { - compileSdkVersion 33 - buildToolsVersion '33.0.2' + compileSdk 35 + buildToolsVersion '34.0.0' defaultConfig { applicationId "com.ds.avare" - minSdkVersion 20 // android 5 is minimum - targetSdkVersion 33 + // RevenueCat Paywalls (purchases-ui) requires API 24 (Android 7.0) + minSdkVersion 24 + targetSdk 35 } compileOptions { @@ -41,6 +42,22 @@ android { useLibrary 'android.test.mock' namespace 'com.ds.avare' + // 16 KB page-size workaround. The prebuilt + // libdatastore_shared_counter.so in androidx.datastore-core 1.1.x / + // 1.2.x ships with a 4 KB-aligned RELRO segment that fails to load + // on Android devices using 16 KB memory pages, which crashed the + // Play Console pre-launch lab with + // NoClassDefFoundError: Landroidx/datastore/DataStoreFile; + // The native lib is only required for cross-process DataStore, which + // neither Avare nor RevenueCat use, so excluding it lets DataStore + // fall back to its JVM file-lock path. + // Tracking: https://issuetracker.google.com/issues/476745201 + packaging { + jniLibs { + excludes += ['**/libdatastore_shared_counter.so'] + } + } + applicationVariants.all { variant -> def productFlavor = variant.productFlavors[0] != null ? "${variant.productFlavors[0].name.capitalize()}" : "" def buildType = "${variant.buildType.name.capitalize()}" @@ -53,15 +70,32 @@ dependencies { implementation 'oro:oro:2.0.8' implementation 'org.xmlunit:xmlunit-matchers:2.3.0' implementation 'androidx.core:core:1.3.2' - implementation 'com.google.firebase:firebase-analytics:17.2.2' - implementation 'com.google.firebase:firebase-crashlytics:18.2.6' implementation 'com.github.mik3y:usb-serial-for-android:3.4.6' + implementation 'androidx.exifinterface:exifinterface:1.3.7' + + // Firebase (BoM-managed). Mirrors avarex: Firebase Auth backs the + // RevenueCat user identity. google-services.json must be present. + implementation platform('com.google.firebase:firebase-bom:34.13.0') + implementation 'com.google.firebase:firebase-analytics' + implementation 'com.google.firebase:firebase-crashlytics' + implementation 'com.google.firebase:firebase-auth' + + // FirebaseUI Auth provides the email sign-in / register screen used by + // ProActivity (same UX as avarex's LoginScreen). + implementation 'com.firebaseui:firebase-ui-auth:9.1.1' + + // AppCompat for the Pro/Login screen + activity result APIs + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.activity:activity:1.9.3' + + // RevenueCat core SDK + Paywalls UI (optional subscriptions, mirrors avarex setup) + implementation 'com.revenuecat.purchases:purchases:9.29.0' + implementation 'com.revenuecat.purchases:purchases-ui:9.29.0' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:4.8.1' testImplementation 'org.powermock:powermock-module-junit4:1.7.0RC2' testImplementation 'org.powermock:powermock-api-mockito2:1.7.0RC2' testImplementation 'org.powermock:powermock-classloading-xstream:1.7.0RC2' testImplementation 'org.powermock:powermock-module-junit4-rule:1.7.0RC2' - testImplementation 'org.robolectric:robolectric:4.3' testImplementation 'org.json:json:20220924' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a83b9bbd9..44c7d5e77 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,8 +11,8 @@ Redistribution and use in source and binary forms, with or without modification, * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --> @@ -38,6 +38,15 @@ Redistribution and use in source and binary forms, with or without modification, + + + @@ -111,6 +120,10 @@ Redistribution and use in source and binary forms, with or without modification, + -

Avare Help

+

Avare Help

Donations
Apps4Av Inc. is a registered non-profit organization in the state of MA.
Avare is an open source project on GitHub. @@ -48,7 +48,6 @@
Thank you! -

Notice

ALL GPS applications on any handheld non-certified devices like smartphones and tablets are not approved by the FAA for IFR flights. None of the operating systems on such devices are tested according to rigorous FAA standards, hence any software running on them is unfit for use as a primary flight navigation tool. It is unwise to solely rely on any such device or any app running on one, @@ -60,95 +59,70 @@

Notice

CAUTION - Before flight the user must always ensure that Avare is updated to and thoroughly tested on the latest version, and ensure that all Avare databases and charts are kept current. If Avare and its databases and charts are not of the exact same version, the GPS position displayed may be inaccurate, because the FAA sometimes changes the format of their materials. Avare does not automatically fetch any databases and charts when they are expired, so it is the user's sole responsibility to update any expired charts and databases. To do so, ensure that your device has an internet connection and then just press the Map, Menu, Preferences, Download, and Update buttons in Avare.

Avare Releases

-

10.3.1

+

11.1.4

    -
  • Fixed FAA weather location changes

  • -
  • Ability to store ADSB recorded files

  • -
  • Increased map area size to reduce black borders in track up mode

  • -
  • Can start camera by pressing the volume button

  • -
  • SUAs show when pressed on map away from airports

  • -

    +
  • Bring back register screen for 1800wxbrief under preferences.
-

10.3.0

+

11.1.3

    -
  • Bug fixes in plan filing

  • -

    +
  • Added Subscription.
-

10.2.9

+

11.0.7

    -
  • Added multi aircraft settings, moved W&B and Lists under the Acft tab

  • -

    +
  • Bug fixes.
-

10.2.8

+

11.0.5

    -
  • Added auto pilot control over USB serial

  • -

    +
  • Fixed black area at the edges.
-

10.2.7

+

11.0.4

    -
  • Fix plate tagging

  • -

    +
  • Stop bug in plates fixed.
-

10.2.6

+

11.0.3

    -
  • Improved help file

  • -

    +
  • Fix for Android - 15 screen.
-

10.2.5

+

11.0.2

    -
  • Improved audible traffic alerts for traffic

  • -

    +
  • New LMFS website.
-

10.2.4

+

11.0.1

    -
  • Stop bug fix on ->D -

    +
  • Registration with the server is now optional.
-

10.2.3

+

11.0.0

    -
  • Fix to IO GPS data loss -

    +
  • Charts are divided into regions now. *** Users must re-download all charts for correct operation ***
-

10.2.2

+

10.3.2

    -
  • Stop bug fixes -

    +
  • Added Phoenix Flyway

  • +
  • Volume button for camera is now a setting under UI Settings.

-

10.2.1

+

10.3.1

    -
  • Improved ADS-B traffic display -

    -
  • Added audible traffic alerts for - traffic -

    -
  • Improved weather display color -

    +
  • Fixed FAA weather location changes

  • +
  • Ability to store ADSB recorded files

  • +
  • Increased map area size to reduce black borders in track up mode

  • +
  • Can start camera by pressing the volume button

  • +
  • SUAs show when pressed on map away from airports

-

10.2.0

+

10.3.0

    -
  • Stop bug fixes -

    +
  • Bug fixes in plan filing

-

10.1.9

+

10.2.9

    -
  • Stop bug fix -

    +
  • Added multi aircraft settings, moved W&B and Lists under the Acft tab

-

10.1.8

+

10.2.8

    -
  • Bluetooth and USB connection - improvements -

    -
  • Changed UI for long-press in Find - and Search tabs -

    -
  • Improvements in drawing -

    +
  • Added auto pilot control over USB serial

-

10.1.7

+

10.2.7

    -
  • Stop bug fix -

    +
  • Fix plate tagging

Help Categories

Below is a list of your choices for Help with Avare (pronounced "Ah-vAir" - like "aware" with a "v"). Nearly all are available Offline (without internet access on your device), such as when you are flying or your device is in Airplane Mode.
@@ -172,14 +146,14 @@

Help Categories

Online* - Website & Forum

Online* - FAA Chart Maps
-

Online* - FAA TFR List +

Online* - FAA TFR List

Note: Under the Downloads list on the FAA website, select the chart type for which you'd like to see a map legend. For example, to see a map showing the area covered by each of the FAA Sectional charts, click on the "Sectional Raster Charts" link.


-

Avare Intro Videos

+< class="western" align="center">Avare Intro Videos> • Intro videos on YouTube at
      John Wiley's Avare Channel.
@@ -269,7 +243,7 @@

Quick Start - Intro     For convenience in selecting VORs, etc., airports are sorted toward the bottom since they can be quickly selected with a long-press in Map view. - +

Menu, the button on the lower left side of the Map screen, displays a list of additional options and configuration items for Avare. Use your Android Back key or gesture to return to the Map screen.
   ≡ The Preferences button for Avare is accessed via the Menu button, atop the following list of buttons for other features and options.
@@ -660,7 +634,7 @@

TPC and ONC charts

TPC and ONC charts are added for World coverage. These charts are expired and should not be used for navigation. To find the proper TPC chart for your area, see the TPC -Charts Grid while you are online. +Charts Grid while you are online.


  --- End of Avare offline Help file --- diff --git a/app/src/main/assets/privacy.html b/app/src/main/assets/privacy.html index 087b5895b..e38b716b6 100644 --- a/app/src/main/assets/privacy.html +++ b/app/src/main/assets/privacy.html @@ -81,7 +81,7 @@

Register/Sign This Document


Do you agree to ALL the above Terms, Conditions, and Privacy Policy?
- By clicking "Register" below, you agree to, and sign for ALL the above "Terms, Conditions, and Privacy Policy". + By clicking "Sign This Disclaimer" below, you agree to, and sign for ALL the above "Terms, Conditions, and Privacy Policy".
diff --git a/app/src/main/java/com/ds/avare/AvareApplication.java b/app/src/main/java/com/ds/avare/AvareApplication.java index 217d44c7c..a6b90593e 100644 --- a/app/src/main/java/com/ds/avare/AvareApplication.java +++ b/app/src/main/java/com/ds/avare/AvareApplication.java @@ -14,6 +14,11 @@ import android.app.Application; +import com.ds.avare.utils.RevenueCatService; +import com.google.firebase.FirebaseApp; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseUser; + /** * Created by zkhan on 1/25/16. */ @@ -25,5 +30,29 @@ public void onCreate() { // init storage class StorageService s = StorageService.getInstance(); s.setContext(getApplicationContext()); + + // Firebase (optional — google-services.json may not be present yet + // in checked-in code). The google-services plugin auto-initializes + // Firebase via a ContentProvider, but call here defensively. + try { + FirebaseApp.initializeApp(getApplicationContext()); + } catch (Throwable ignored) { + // ignore — Firebase is optional + } + + // Initialize RevenueCat (optional service — never throws). + RevenueCatService.init(getApplicationContext()); + + // If a Firebase user is cached from a previous session, sync their + // identity into RevenueCat immediately so the entitlement check at + // startup reflects their actual subscription (mirrors avarex). + try { + FirebaseUser user = FirebaseAuth.getInstance().getCurrentUser(); + if (user != null) { + RevenueCatService.logIn(user.getUid(), user.getEmail(), user.getDisplayName()); + } + } catch (Throwable ignored) { + // ignore — Firebase auth not configured / no user + } } } diff --git a/app/src/main/java/com/ds/avare/BaseActivity.java b/app/src/main/java/com/ds/avare/BaseActivity.java index d024338e8..f0efce8f6 100644 --- a/app/src/main/java/com/ds/avare/BaseActivity.java +++ b/app/src/main/java/com/ds/avare/BaseActivity.java @@ -3,6 +3,7 @@ import android.app.Activity; import android.location.GpsStatus; import android.location.Location; +import android.os.Build; import android.os.Bundle; import android.view.Window; @@ -21,6 +22,12 @@ public class BaseActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { Helper.setTheme(this); + //apply theme style + + // apply this for android v35 or above, opt out of edge to edge enforcement + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + this.getTheme().applyStyle(R.style.OptOutEdgeToEdgeEnforcement, /* force */ false); + } super.onCreate(savedInstanceState); diff --git a/app/src/main/java/com/ds/avare/ChartsDownloadActivity.java b/app/src/main/java/com/ds/avare/ChartsDownloadActivity.java index 0cbe6c22f..dbef8f606 100644 --- a/app/src/main/java/com/ds/avare/ChartsDownloadActivity.java +++ b/app/src/main/java/com/ds/avare/ChartsDownloadActivity.java @@ -46,6 +46,7 @@ import com.ds.avare.utils.DecoratedAlertDialogBuilder; import com.ds.avare.utils.Helper; import com.ds.avare.utils.RateApp; +import com.ds.avare.utils.RevenueCatService; import com.ds.avare.utils.Telemetry; import com.ds.avare.utils.TelemetryParams; @@ -77,6 +78,22 @@ public class ChartsDownloadActivity extends BaseActivity { */ private AlertDialog mAlertDialog; + /** + * Cached Paid-entitlement result for the current download batch. Null + * until the first chart in the batch trips the per-category gate; + * after that, reused for the rest of the batch so we don't ask + * RevenueCat once per skipped item. Reset to null when the batch + * finishes (no more checked items) or when the activity pauses. + */ + private Boolean mBatchProEntitled = null; + + /** + * True once we've shown the "Paid Subscription Required" dialog at + * least once during the current batch, so that skipping several + * gated charts back-to-back doesn't spawn a stack of dialogs. + */ + private boolean mBatchProWarned = false; + /* * (non-Javadoc) * @see android.app.Activity#onBackPressed() @@ -234,34 +251,146 @@ public void onResume() { /** + * Entry point used by the Download / Update buttons and by the + * recursive continuation in {@link #mHandler}. Peeks at the next + * checked chart and runs the per-chart Paid-subscription gate, then + * either starts the download or skips/blocks the chart. * + * The gate is re-evaluated on every call so that the on-disk state + * updated by the previous successful download is respected live: if + * the user checked two new sectionals, the first downloads, the + * second is then blocked because the category now has one + * downloaded chart. */ private boolean download() { - - /* - * Download first chart in list that is checked - */ - mName = mChartAdapter.getChecked(); - if(null == mName) { - /* - * Nothing to download - */ - mToast.setText(getString(R.string.Done)); - mToast.show(); + final String name = mChartAdapter.getChecked(); + if (name == null) { + endBatch(); return false; } - + + if (!mChartAdapter.requiresProForChart(name)) { + return downloadOne(name); + } + + // This chart needs Paid. Use cached entitlement if we already + // know the answer for this batch. + if (mBatchProEntitled != null) { + if (mBatchProEntitled) { + return downloadOne(name); + } + return skipGatedChart(name); + } + + // First gated chart of the batch — ask RevenueCat. + RevenueCatService.isProEntitled(new RevenueCatService.EntitlementCallback() { + @Override + public void onResult(final boolean entitled) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (isFinishing()) { + return; + } + mBatchProEntitled = entitled; + // Re-enter the gate with the cached entitlement; + // the new state will route the same chart to + // downloadOne() or skipGatedChart() as needed. + download(); + } + }); + } + }); + return true; + } + + /** + * Free user tried to download a chart that would exceed the + * one-per-category limit. Uncheck it so the batch can keep moving + * through any remaining (exempt or update) items, and surface the + * Paid-subscription dialog the first time it happens this batch. + */ + private boolean skipGatedChart(String name) { + mChartAdapter.unsetChecked(name); + mChartAdapter.notifyDataSetChanged(); + if (!mBatchProWarned) { + mBatchProWarned = true; + showProRequiredDialog(); + } + return download(); + } + + /** + * Reset per-batch state when the chain runs dry. + */ + private void endBatch() { + mBatchProEntitled = null; + mBatchProWarned = false; + mToast.setText(getString(R.string.Done)); + mToast.show(); + } + + /** + * Show the "Paid subscription required" prompt offering a Subscribe + * shortcut into {@link ProActivity}. + */ + private void showProRequiredDialog() { + if (isFinishing()) { + return; + } + DecoratedAlertDialogBuilder builder = + new DecoratedAlertDialogBuilder(ChartsDownloadActivity.this); + builder.setTitle(getString(R.string.ProDownloadLimitTitle)); + builder.setMessage(getString(R.string.ProDownloadLimitMessage)); + builder.setCancelable(false); + builder.setNegativeButton(getString(R.string.Cancel), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + builder.setPositiveButton(getString(R.string.ProServicesSubscribe), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + try { + startActivity(new Intent( + ChartsDownloadActivity.this, + ProActivity.class)); + } catch (Throwable ignored) { + // Paid screen is optional + } + } + }); + mAlertDialog = builder.create(); + try { + mAlertDialog.show(); + } catch (Throwable ignored) { + // ignore - window may be gone + } + } + + /** + * Actually kicks off the download for the given chart. Caller is + * responsible for having peeked the next checked item via {@link + * ChartAdapter#getChecked()} and passed the per-chart Paid gate. + */ + private boolean downloadOne(String name) { + mName = name; + mDownload = new Download(mPref.getRoot(), mHandler, mPref.getCycleAdjust()); mDownload.start(StorageService.getInstance().getPreferences().getServerDataFolder(), mName, mChartAdapter.isStatic(mName)); - + mProgressDialog = new ProgressDialog(ChartsDownloadActivity.this); mProgressDialog.setIndeterminate(false); mProgressDialog.setMax(100); mProgressDialog.setCancelable(false); mProgressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); - mProgressDialog.setMessage(getString(R.string.Downloading) + "/" + + mProgressDialog.setMessage(getString(R.string.Downloading) + "/" + getString(R.string.Extracting) + " : " + mName + ".zip"); - + mProgressDialog.setButton(ProgressDialog.BUTTON_NEGATIVE, getString(R.string.Cancel), new DialogInterface.OnClickListener() { /* (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) @@ -356,6 +485,10 @@ protected void onPause() { */ mService.setDownloading(false); + // Force re-checking the Paid gate on the next batch. + mBatchProEntitled = null; + mBatchProWarned = false; + /* * */ diff --git a/app/src/main/java/com/ds/avare/LocationActivity.java b/app/src/main/java/com/ds/avare/LocationActivity.java index a7ae4029e..417309728 100644 --- a/app/src/main/java/com/ds/avare/LocationActivity.java +++ b/app/src/main/java/com/ds/avare/LocationActivity.java @@ -268,6 +268,9 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { // start camera on volume down/up PackageManager packman = getPackageManager(); Intent intent; + if(!StorageService.getInstance().getPreferences().cameraButton()) { + return false; + } if (KeyEvent.KEYCODE_VOLUME_DOWN == event.getKeyCode()) { intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); } diff --git a/app/src/main/java/com/ds/avare/MainActivity.java b/app/src/main/java/com/ds/avare/MainActivity.java index db1e3702c..e8af73e89 100644 --- a/app/src/main/java/com/ds/avare/MainActivity.java +++ b/app/src/main/java/com/ds/avare/MainActivity.java @@ -200,7 +200,6 @@ public void onGlobalLayout() { //granted setup(); } - } /** diff --git a/app/src/main/java/com/ds/avare/PlatesActivity.java b/app/src/main/java/com/ds/avare/PlatesActivity.java index aac9e24ac..11553bbc1 100644 --- a/app/src/main/java/com/ds/avare/PlatesActivity.java +++ b/app/src/main/java/com/ds/avare/PlatesActivity.java @@ -98,7 +98,6 @@ public class PlatesActivity extends BaseActivity implements Observer { private TankObserver mTankObserver; private TimerObserver mTimerObserver; - public static final String AD = "AIRPORT-DIAGRAM"; public static final String AREA = "AREA"; /* @@ -142,9 +141,6 @@ public static String getNameFromPath(String name) { * @return */ public float[] getMatrix(String name) { - if(name.equals(AD)) { - return(mMatrix); - } if(mService.getPlateDiagram() != null && mService.getPlateDiagram().getName() != null) { @@ -518,7 +514,7 @@ private void setPlateFromPos(int pos) { mPlatesView.setParams(null, true); float m[] = getMatrix(name); mService.setMatrix(null); // to small to show on map - if(name.startsWith(AD)) { + if(name.startsWith("APD")) { mPlatesView.setParams(m, true); } else if(name.startsWith(AREA)) { @@ -658,9 +654,8 @@ public boolean accept(File directory, String fileName) { String dplates[] = new File(mapFolder + "/plates/" + airport).list(filter); String aplates[] = new File(mapFolder + "/area/" + airport).list(filter); - String mins[] = mService.getDBResource().findMinimums(airport); - TreeMap plates = new TreeMap(new PlatesComparable()); + TreeMap plates = new TreeMap(); if (dplates != null) { for (String plate : dplates) { String tokens[] = plate.split(Preferences.IMAGE_EXTENSION); @@ -673,12 +668,6 @@ public boolean accept(File directory, String fileName) { plates.put(tokens[0], mapFolder + "/area/" + airport + "/" + tokens[0]); } } - if (mins != null) { - for (String plate : mins) { - String folder = plate.substring(0, 1) + "/"; - plates.put("Min. " + plate, mapFolder + "/minimums/" + folder + plate); - } - } if (plates.size() > 0) { mPlateFound = Arrays.asList(plates.values().toArray()).toArray(new String[plates.values().toArray().length]); mListPlates = new ArrayList(plates.keySet()); @@ -884,48 +873,6 @@ public void onResume() { } } - /** - * - * @author zkhan - * - */ - private class PlatesComparable implements Comparator{ - - @Override - public int compare(String o1, String o2) { - /* - * Airport diagram must be first - */ - String[] type = {AD, "AREA", "ILS-", "HI-ILS-", "LOC-", "HI-LOC-", "LDA-", "SDA-", "GPS-", "RNAV-GPS-", "RNAV-RNP-", "VOR-", "HI-VOR-", "TACAN-", "HI-TACAN-", "NDB-", "COPTER-", "CUSTOM-", "LAHSO", "HOT-SPOT", "Min."}; - - for(int i = 0; i < type.length; i++) { - if(o1.startsWith(type[i]) && (!o2.startsWith(type[i]))) { - return -1; - } - if(o2.startsWith(type[i]) && (!o1.startsWith(type[i]))) { - return 1; - } - } - - /* - * Continued must follow main - */ - String comp1 = o2.replace(Preferences.IMAGE_EXTENSION, ""); - if(o1.contains("-CONT.") && (!o2.contains("-CONT."))) { - if(o1.startsWith(comp1)) { - return 1; - } - } - String comp2 = o1.replace(Preferences.IMAGE_EXTENSION, ""); - if(o2.contains("-CONT.") && (!o1.contains("-CONT."))) { - if(o2.startsWith(comp2)) { - return -1; - } - } - - return o1.compareTo(o2); - } - } /** * @@ -944,7 +891,15 @@ public static boolean doesAirportHavePlates(String mapFolder, String id) { * @return */ public static boolean doesAirportHaveAirportDiagram(String mapFolder, String id) { - return new File(mapFolder + "/plates/" + id + "/" + AD + Preferences.IMAGE_EXTENSION).exists(); + String[] files = new File(mapFolder + "/plates/" + id + "/").list(); + if(null != files) { + for (String f : files) { + if (f.startsWith("APD")) { + return true; + } + } + } + return false; } diff --git a/app/src/main/java/com/ds/avare/PrefActivity.java b/app/src/main/java/com/ds/avare/PrefActivity.java index 2d5b1bda9..cf39e8595 100644 --- a/app/src/main/java/com/ds/avare/PrefActivity.java +++ b/app/src/main/java/com/ds/avare/PrefActivity.java @@ -20,6 +20,7 @@ import android.content.ServiceConnection; import android.location.GpsStatus; import android.location.Location; +import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.preference.PreferenceActivity; @@ -59,6 +60,12 @@ public void enabledCallback(boolean enabled) { public void onCreate(Bundle savedInstanceState) { requestWindowFeature(Window.FEATURE_NO_TITLE); Helper.setTheme(this); + + // apply this for android v35 or above, opt out of edge to edge enforcement + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + this.getTheme().applyStyle(R.style.OptOutEdgeToEdgeEnforcement, /* force */ false); + } + super.onCreate(savedInstanceState); addPreferencesFromResource(R.xml.preferences); diff --git a/app/src/main/java/com/ds/avare/ProActivity.java b/app/src/main/java/com/ds/avare/ProActivity.java new file mode 100644 index 000000000..160f5bccb --- /dev/null +++ b/app/src/main/java/com/ds/avare/ProActivity.java @@ -0,0 +1,301 @@ +/* +Copyright (c) 2026, Apps4Av Inc. (apps4av.com) +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +package com.ds.avare; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.appcompat.app.AppCompatActivity; + +import com.ds.avare.utils.RevenueCatService; +import com.firebase.ui.auth.AuthUI; +import com.firebase.ui.auth.FirebaseAuthUIActivityResultContract; +import com.firebase.ui.auth.data.model.FirebaseAuthUIAuthenticationResult; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseUser; +import com.revenuecat.purchases.Offering; +import com.revenuecat.purchases.Offerings; +import com.revenuecat.purchases.Purchases; +import com.revenuecat.purchases.PurchasesError; +import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback; +import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallActivityLauncher; +import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallDisplayCallback; +import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallResult; +import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallResultHandler; + +import java.util.Collections; +import java.util.List; + +/** + * Pro Services screen — Avare's analog of the avarex LoginScreen. + * + * Flow: + * 1. If the user is not signed in to Firebase, the "Sign in / Register" + * button launches the FirebaseUI Auth email flow. + * 2. After successful sign-in, the same Firebase UID is forwarded to + * RevenueCat so the entitlement is restored across devices. + * 3. The "Subscribe" button then opens the RevenueCat paywall via + * {@link PaywallActivityLauncher}. If the user already owns the + * {@link RevenueCatService#ENTITLEMENT_ID} entitlement the paywall + * is suppressed and a "thank you" toast is shown instead. + * + * RevenueCat and Firebase are both optional — any missing setup degrades + * gracefully with a toast rather than crashing. + */ +public class ProActivity extends AppCompatActivity { + + private static final List PROVIDERS = + Collections.singletonList(new AuthUI.IdpConfig.EmailBuilder().build()); + + private PaywallActivityLauncher mPaywallLauncher; + + private final ActivityResultLauncher mSignInLauncher = + registerForActivityResult( + new FirebaseAuthUIActivityResultContract(), + new androidx.activity.result.ActivityResultCallback() { + @Override + public void onActivityResult(FirebaseAuthUIAuthenticationResult result) { + handleSignInResult(); + } + }); + + private TextView mStatus; + private Button mSignInButton; + private Button mSignOutButton; + private Button mSubscribeButton; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_pro); + + mStatus = findViewById(R.id.pro_status_text); + mSignInButton = findViewById(R.id.pro_signin_btn); + mSignOutButton = findViewById(R.id.pro_signout_btn); + mSubscribeButton = findViewById(R.id.pro_subscribe_btn); + Button closeButton = findViewById(R.id.pro_close_btn); + + try { + mPaywallLauncher = new PaywallActivityLauncher(this, new PaywallResultHandler() { + @Override + public void onActivityResult(PaywallResult paywallResult) { + refreshUI(); + } + }); + } catch (Throwable ignored) { + mPaywallLauncher = null; + } + + mSignInButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + startSignIn(); + } + }); + + mSignOutButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + signOut(); + } + }); + + mSubscribeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + launchPaywall(); + } + }); + + closeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + finish(); + } + }); + + refreshUI(); + } + + @Override + protected void onResume() { + super.onResume(); + refreshUI(); + } + + private FirebaseUser currentUser() { + try { + return FirebaseAuth.getInstance().getCurrentUser(); + } catch (Throwable ignored) { + return null; + } + } + + private void refreshUI() { + FirebaseUser user = currentUser(); + if (user == null) { + mStatus.setText(getString(R.string.ProStatusSignedOut)); + mSignInButton.setVisibility(View.VISIBLE); + mSignOutButton.setVisibility(View.GONE); + mSubscribeButton.setEnabled(false); + return; + } + + String label = user.getEmail(); + if (label == null || label.isEmpty()) { + label = user.getDisplayName(); + } + if (label == null || label.isEmpty()) { + label = user.getUid(); + } + mStatus.setText(getString(R.string.ProStatusSignedIn, label)); + mSignInButton.setVisibility(View.GONE); + mSignOutButton.setVisibility(View.VISIBLE); + mSubscribeButton.setEnabled(true); + + // Show "already subscribed" state if RC says so. + RevenueCatService.isProEntitled(new RevenueCatService.EntitlementCallback() { + @Override + public void onResult(boolean entitled) { + if (entitled && !isFinishing()) { + mStatus.setText(getString(R.string.ProStatusEntitled)); + } + } + }); + } + + private void startSignIn() { + try { + Intent signInIntent = AuthUI.getInstance() + .createSignInIntentBuilder() + .setAvailableProviders(PROVIDERS) + .build(); + mSignInLauncher.launch(signInIntent); + } catch (Throwable t) { + Toast.makeText(this, + getString(R.string.ProServiceUnavailable), + Toast.LENGTH_SHORT).show(); + } + } + + private void handleSignInResult() { + FirebaseUser user = currentUser(); + if (user != null) { + RevenueCatService.logIn(user.getUid(), user.getEmail(), user.getDisplayName()); + } + refreshUI(); + } + + private void signOut() { + try { + AuthUI.getInstance().signOut(this); + } catch (Throwable ignored) { + // ignore + } + RevenueCatService.logOut(); + refreshUI(); + } + + private void launchPaywall() { + FirebaseUser user = currentUser(); + if (user == null) { + Toast.makeText(this, + getString(R.string.ProMustSignInFirst), + Toast.LENGTH_SHORT).show(); + return; + } + // make sure RC knows about this user before showing the paywall + RevenueCatService.logIn(user.getUid(), user.getEmail(), user.getDisplayName()); + + if (mPaywallLauncher == null || !RevenueCatService.isConfigured()) { + Toast.makeText(this, + getString(R.string.ProServiceUnavailable), + Toast.LENGTH_SHORT).show(); + return; + } + + // Look up the "default_paid" offering before launching the paywall + // so the user always sees the right products and not whatever the + // dashboard's "current" offering happens to be. + try { + Purchases.getSharedInstance().getOfferings(new ReceiveOfferingsCallback() { + @Override + public void onReceived(Offerings offerings) { + Offering offering = offerings.getAll().get(RevenueCatService.OFFERING_ID); + if (offering == null) { + // Fall back to whatever the dashboard marks as current + offering = offerings.getCurrent(); + } + presentPaywall(offering); + } + + @Override + public void onError(PurchasesError error) { + if (!isFinishing()) { + Toast.makeText(ProActivity.this, + getString(R.string.ProServiceUnavailable), + Toast.LENGTH_SHORT).show(); + } + } + }); + } catch (Throwable t) { + Toast.makeText(this, + getString(R.string.ProServiceUnavailable), + Toast.LENGTH_SHORT).show(); + } + } + + private void launchServerRegistration() { + try { + startActivity(new Intent(this, RegisterActivity.class)); + } catch (Throwable t) { + Toast.makeText(this, + getString(R.string.ProServiceUnavailable), + Toast.LENGTH_SHORT).show(); + } + } + + private void presentPaywall(Offering offering) { + if (isFinishing() || mPaywallLauncher == null) { + return; + } + try { + mPaywallLauncher.launchIfNeeded( + RevenueCatService.ENTITLEMENT_ID, + offering, + null, + true, + false, + new PaywallDisplayCallback() { + @Override + public void onPaywallDisplayResult(boolean wasDisplayed) { + if (!wasDisplayed && !isFinishing()) { + Toast.makeText(ProActivity.this, + getString(R.string.ProStatusEntitled), + Toast.LENGTH_SHORT).show(); + refreshUI(); + } + } + }); + } catch (Throwable t) { + Toast.makeText(this, + getString(R.string.ProServiceUnavailable), + Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/java/com/ds/avare/RegisterActivity.java b/app/src/main/java/com/ds/avare/RegisterActivity.java index 9c20d86c2..70c23df17 100644 --- a/app/src/main/java/com/ds/avare/RegisterActivity.java +++ b/app/src/main/java/com/ds/avare/RegisterActivity.java @@ -18,7 +18,6 @@ import android.os.Bundle; import android.text.TextUtils; import android.view.View; -import android.view.Window; import android.view.WindowManager; import android.webkit.WebView; import android.widget.Button; @@ -41,7 +40,7 @@ */ public class RegisterActivity extends BaseActivity { - private static final int MAX_ATTEMPTS = 5; + private static final int MAX_ATTEMPTS = 3; private static final int BACKOFF_MILLI_SECONDS = 2000; static AsyncTask mRegisterTask = null; @@ -64,6 +63,7 @@ private void setButtonStates() { } } + /* * (non-Javadoc) * @see android.app.Activity#onBackPressed() @@ -102,7 +102,6 @@ public void onCreate(Bundle savedInstanceState) { */ mPrivacy = (WebView)findViewById(R.id.privacy_webview); mPrivacy.loadUrl(com.ds.avare.utils.Helper.getWebViewFile(getApplicationContext(), "privacy")); - // Check if Internet present if (!Helper.isNetworkAvailable(this)) { @@ -183,7 +182,7 @@ protected Boolean doInBackground(Void... vals) { } backoff *= 2; } - return false; + return true; // pass anyways as people keep using old devices } @Override @@ -258,7 +257,7 @@ protected Boolean doInBackground(Void... vals) { backoff *= 2; } } - return false; + return true; // pass anyways as people keep using old devices } @Override @@ -290,7 +289,6 @@ public void onClick(DialogInterface dialog, int id) { mRegisterTask.execute(null, null, null); } - } }); @@ -300,4 +298,5 @@ public void onClick(DialogInterface dialog, int id) { private static boolean isValidEmail(String email) { return !TextUtils.isEmpty(email) && android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches(); } + } \ No newline at end of file diff --git a/app/src/main/java/com/ds/avare/adapters/ChartAdapter.java b/app/src/main/java/com/ds/avare/adapters/ChartAdapter.java index 0958543a5..650bbf08d 100644 --- a/app/src/main/java/com/ds/avare/adapters/ChartAdapter.java +++ b/app/src/main/java/com/ds/avare/adapters/ChartAdapter.java @@ -87,13 +87,9 @@ public class ChartAdapter extends BaseExpandableListAdapter { private static final int GROUP_VFRA = 9; private static final int GROUP_AFD = 10; private static final int GROUP_TERRAIN = 11; - private static final int GROUP_TOPO = 12; - private static final int GROUP_HELI = 13; - private static final int GROUP_ONC = 14; - private static final int GROUP_TPC = 15; - private static final int GROUP_MISC = 16; - private static final int GROUP_FLY = 17; - private static final int GROUP_NUM = 18; + private static final int GROUP_HELI = 12; + private static final int GROUP_FLY = 13; + private static final int GROUP_NUM = 14; /** * @param context @@ -120,13 +116,9 @@ public ChartAdapter(Context context) { mChildren[GROUP_AFD] = context.getResources().getStringArray(R.array.resNameAFD); mChildren[GROUP_TERRAIN] = context.getResources().getStringArray(R.array.resNameTerrain); mChildren[GROUP_IFRHE] = context.getResources().getStringArray(R.array.resNameIFRHE); - mChildren[GROUP_TOPO] = context.getResources().getStringArray(R.array.resNameTopo); mChildren[GROUP_HELI] = context.getResources().getStringArray(R.array.resNameHeli); - mChildren[GROUP_ONC] = context.getResources().getStringArray(R.array.resNameONC); - mChildren[GROUP_TPC] = context.getResources().getStringArray(R.array.resNameTPC); mChildren[GROUP_IFRA] = context.getResources().getStringArray(R.array.resNameIFRArea); mChildren[GROUP_VFRA] = context.getResources().getStringArray(R.array.resNameVFRAreaPlate); - mChildren[GROUP_MISC] = context.getResources().getStringArray(R.array.resNameMisc); mChildren[GROUP_FLY] = context.getResources().getStringArray(R.array.resNameFLY); /* @@ -143,13 +135,9 @@ public ChartAdapter(Context context) { mChildrenFiles[GROUP_AFD] = context.getResources().getStringArray(R.array.resFilesAFD); mChildrenFiles[GROUP_TERRAIN] = context.getResources().getStringArray(R.array.resFilesTerrain); mChildrenFiles[GROUP_IFRHE] = context.getResources().getStringArray(R.array.resFilesIFRHE); - mChildrenFiles[GROUP_TOPO] = context.getResources().getStringArray(R.array.resFilesTopo); mChildrenFiles[GROUP_HELI] = context.getResources().getStringArray(R.array.resFilesHeli); - mChildrenFiles[GROUP_ONC] = context.getResources().getStringArray(R.array.resFilesONC); - mChildrenFiles[GROUP_TPC] = context.getResources().getStringArray(R.array.resFilesTPC); mChildrenFiles[GROUP_IFRA] = context.getResources().getStringArray(R.array.resFilesIFRArea); mChildrenFiles[GROUP_VFRA] = context.getResources().getStringArray(R.array.resFilesVFRAreaPlate); - mChildrenFiles[GROUP_MISC] = context.getResources().getStringArray(R.array.resFilesMisc); mChildrenFiles[GROUP_FLY] = context.getResources().getStringArray(R.array.resFilesFLY); /* @@ -167,13 +155,9 @@ public ChartAdapter(Context context) { mVers[GROUP_AFD] = context.getResources().getStringArray(R.array.resFilesAFD); mVers[GROUP_TERRAIN] = context.getResources().getStringArray(R.array.resFilesTerrain); mVers[GROUP_IFRHE] = context.getResources().getStringArray(R.array.resFilesIFRHE); - mVers[GROUP_TOPO] = context.getResources().getStringArray(R.array.resFilesTopo); mVers[GROUP_HELI] = context.getResources().getStringArray(R.array.resFilesHeli); - mVers[GROUP_ONC] = context.getResources().getStringArray(R.array.resFilesONC); - mVers[GROUP_TPC] = context.getResources().getStringArray(R.array.resFilesTPC); mVers[GROUP_IFRA] = context.getResources().getStringArray(R.array.resFilesIFRArea); mVers[GROUP_VFRA] = context.getResources().getStringArray(R.array.resFilesVFRAreaPlate); - mVers[GROUP_MISC] = context.getResources().getStringArray(R.array.resFilesMisc); mVers[GROUP_FLY] = context.getResources().getStringArray(R.array.resFilesFLY); /* @@ -190,13 +174,9 @@ public ChartAdapter(Context context) { mChecked[GROUP_AFD] = new int[mVers[GROUP_AFD].length]; mChecked[GROUP_TERRAIN] = new int[mVers[GROUP_TERRAIN].length]; mChecked[GROUP_IFRHE] = new int[mVers[GROUP_IFRHE].length]; - mChecked[GROUP_TOPO] = new int[mVers[GROUP_TOPO].length]; mChecked[GROUP_HELI] = new int[mVers[GROUP_HELI].length]; - mChecked[GROUP_ONC] = new int[mVers[GROUP_ONC].length]; - mChecked[GROUP_TPC] = new int[mVers[GROUP_TPC].length]; mChecked[GROUP_IFRA] = new int[mVers[GROUP_IFRA].length]; mChecked[GROUP_VFRA] = new int[mVers[GROUP_VFRA].length]; - mChecked[GROUP_MISC] = new int[mVers[GROUP_MISC].length]; mChecked[GROUP_FLY] = new int[mVers[GROUP_FLY].length]; /* @@ -350,7 +330,7 @@ public boolean isStatic(String name) { for(int group = GROUP_DATABASE; group < GROUP_NUM; group++) { for(int child = 0; child < mVers[group].length; child++) { if(mChildrenFiles[group][child].equals(name)) { - return (group == GROUP_ONC || group == GROUP_WAC || group == GROUP_VFRA || group == GROUP_TPC || group == GROUP_TERRAIN || group == GROUP_TOPO || group == GROUP_MISC); + return (group == GROUP_WAC || group == GROUP_VFRA || group == GROUP_TERRAIN); } } } @@ -464,7 +444,53 @@ public void checkDone() { * @return */ private boolean doesChartExpire(int group) { - return (group != GROUP_ONC) && (group != GROUP_TOPO) && (group != GROUP_WAC) && (group != GROUP_VFRA) && (group != GROUP_TERRAIN) && (group != GROUP_TPC) && (group != GROUP_MISC); + return (group != GROUP_WAC) && (group != GROUP_VFRA) && (group != GROUP_TERRAIN); + } + + /** + * Per-chart Paid-subscription gate. Free users are limited to a single + * downloaded chart per category, with the exception of {@link + * #GROUP_DATABASE} and {@link #GROUP_WEATHER} (which contains weather, + * NEXRAD and TFRs — i.e. the "database", "weather" and "tfr" + * categories called out in the spec). Updating a chart that is already + * on disk is always allowed (the file count in the category doesn't + * change). Downloading a NEW chart into a non-exempt category is + * blocked when that category already has another downloaded chart. + * + * Re-evaluated on every call so that the batched download chain in + * {@link com.ds.avare.ChartsDownloadActivity} reacts live to the + * updated on-disk state after each successful download. + * + * @param name chart file name (e.g. {@code "Boston"}) + * @return {@code true} when only a Paid subscriber is allowed to + * download this chart right now + */ + public boolean requiresProForChart(String name) { + if (name == null) { + return false; + } + for (int group = 0; group < GROUP_NUM; group++) { + if (group == GROUP_DATABASE || group == GROUP_WEATHER) { + continue; + } + for (int child = 0; child < mChildrenFiles[group].length; child++) { + if (!name.equals(mChildrenFiles[group][child])) { + continue; + } + if (mVers[group][child] != null) { + // Update of an existing chart — never gated. + return false; + } + for (int sib = 0; sib < mVers[group].length; sib++) { + if (sib != child && mVers[group][sib] != null) { + return true; + } + } + return false; + } + } + // Not found, or in an exempt group. + return false; } /** diff --git a/app/src/main/java/com/ds/avare/adsb/gdl90/HeartbeatMessage.java b/app/src/main/java/com/ds/avare/adsb/gdl90/HeartbeatMessage.java index 8040a63ff..f01db02fb 100644 --- a/app/src/main/java/com/ds/avare/adsb/gdl90/HeartbeatMessage.java +++ b/app/src/main/java/com/ds/avare/adsb/gdl90/HeartbeatMessage.java @@ -22,9 +22,9 @@ public class HeartbeatMessage extends Message { int mMinute; int mSecond; - Boolean mGpsPositionValid; - Boolean mBatteryLow; - Boolean mDeviceRunning; + public Boolean mGpsPositionValid; + public Boolean mBatteryLow; + public Boolean mDeviceRunning; public HeartbeatMessage() { super(MessageType.HEARTBEAT); diff --git a/app/src/main/java/com/ds/avare/adsb/gdl90/OwnshipMessage.java b/app/src/main/java/com/ds/avare/adsb/gdl90/OwnshipMessage.java index ec0ce019b..96fdda7dd 100644 --- a/app/src/main/java/com/ds/avare/adsb/gdl90/OwnshipMessage.java +++ b/app/src/main/java/com/ds/avare/adsb/gdl90/OwnshipMessage.java @@ -27,8 +27,8 @@ public class OwnshipMessage extends Message { public boolean mIsAirborne; boolean mIsExtrapolated; int mTrackType; - int mNIC; - int mNACP; + public int mNIC; + public int mNACP; boolean mIsTrackHeadingValid; boolean mIsTrackHeadingTrueTrackAngle; boolean mIsTrackHeadingHeading; diff --git a/app/src/main/java/com/ds/avare/connections/BufferProcessor.java b/app/src/main/java/com/ds/avare/connections/BufferProcessor.java index 691514f2f..582e587dd 100644 --- a/app/src/main/java/com/ds/avare/connections/BufferProcessor.java +++ b/app/src/main/java/com/ds/avare/connections/BufferProcessor.java @@ -35,6 +35,7 @@ import com.ds.avare.adsb.gdl90.Constants; import com.ds.avare.adsb.gdl90.FisBuffer; import com.ds.avare.adsb.gdl90.FisGraphics; +import com.ds.avare.adsb.gdl90.HeartbeatMessage; import com.ds.avare.adsb.gdl90.Id11Product; import com.ds.avare.adsb.gdl90.Id12Product; import com.ds.avare.adsb.gdl90.Id13Product; @@ -156,8 +157,30 @@ else if(nmeaOwnship.addMessage(m)) { /* * Post on UI thread. */ - - if(m instanceof TrafficReportMessage) { + + if(m instanceof HeartbeatMessage) { + + /* + * Make a GPS heartbeat message from ADSB heartbeat message. + */ + JSONObject object = new JSONObject(); + HeartbeatMessage tm = (HeartbeatMessage)m; + try { + object.put("type", "heartbeat"); + object.put("timestamp", (long)tm.getTime()); + object.put("gpsvalid", (boolean)tm.mGpsPositionValid); + object.put("lowbattery", (boolean)tm.mBatteryLow); + object.put("running", (boolean)tm.mDeviceRunning); + } catch (JSONException e1) { + continue; + } + + objs.add(object.toString()); + + } + + + else if(m instanceof TrafficReportMessage) { /* * Make a GPS locaiton message from ADSB ownship message. @@ -523,6 +546,8 @@ else if(m instanceof OwnshipMessage) { object.put("time", (long)om.getTime()); object.put("altitude", (double) om.mAltitude); object.put("address", (int)om.mIcaoAddress); + object.put("nic", (int)om.mNIC); + object.put("nacp", (int)om.mNACP); } catch (JSONException e1) { continue; } diff --git a/app/src/main/java/com/ds/avare/connections/MsfsConnection.java b/app/src/main/java/com/ds/avare/connections/MsfsConnection.java index ba9205e26..d7d3b4807 100644 --- a/app/src/main/java/com/ds/avare/connections/MsfsConnection.java +++ b/app/src/main/java/com/ds/avare/connections/MsfsConnection.java @@ -160,6 +160,7 @@ public boolean connect(String to, boolean secure) { try { mSocket = new DatagramSocket(mPort); + mSocket.setReuseAddress(true); } catch(Exception e) { Logger.Logit("Failed! Connecting socket " + e.getMessage()); diff --git a/app/src/main/java/com/ds/avare/connections/WifiConnection.java b/app/src/main/java/com/ds/avare/connections/WifiConnection.java index f65453bb7..5604ec0dd 100644 --- a/app/src/main/java/com/ds/avare/connections/WifiConnection.java +++ b/app/src/main/java/com/ds/avare/connections/WifiConnection.java @@ -148,6 +148,7 @@ public boolean connect(String to, boolean secure) { try { mSocket = new DatagramSocket(mPort); + mSocket.setReuseAddress(true); } catch(Exception e) { Logger.Logit("Failed! Connecting socket " + e.getMessage()); diff --git a/app/src/main/java/com/ds/avare/connections/XplaneConnection.java b/app/src/main/java/com/ds/avare/connections/XplaneConnection.java index 698ab9e1d..a9f783667 100644 --- a/app/src/main/java/com/ds/avare/connections/XplaneConnection.java +++ b/app/src/main/java/com/ds/avare/connections/XplaneConnection.java @@ -147,6 +147,7 @@ public boolean connect(String to, boolean secure) { try { mSocket = new DatagramSocket(mPort); + mSocket.setReuseAddress(true); } catch(Exception e) { Logger.Logit("Failed! Connecting socket " + e.getMessage()); diff --git a/app/src/main/java/com/ds/avare/content/DataSource.java b/app/src/main/java/com/ds/avare/content/DataSource.java index e6e3a0ff5..80af3a0df 100644 --- a/app/src/main/java/com/ds/avare/content/DataSource.java +++ b/app/src/main/java/com/ds/avare/content/DataSource.java @@ -137,13 +137,6 @@ public StringPreference searchOneNoCache(String name) { return LocationContentProviderHelper.searchOne(mContext, name, true); } - public String[] findMinimums(String airportId) { - return LocationContentProviderHelper.findMinimums(mContext, airportId); - } - - public LinkedList findAFD(String airportId) { - return LocationContentProviderHelper.findAFD(mContext, airportId); - } public String findLonLat(String name, String type) { return LocationContentProviderHelper.findLonLat(mContext, name, type); diff --git a/app/src/main/java/com/ds/avare/message/NetworkHelper.java b/app/src/main/java/com/ds/avare/message/NetworkHelper.java index 33404e6f7..0c553d4b5 100644 --- a/app/src/main/java/com/ds/avare/message/NetworkHelper.java +++ b/app/src/main/java/com/ds/avare/message/NetworkHelper.java @@ -47,7 +47,7 @@ public class NetworkHelper { * This has to be provided by apps4av */ public static String getServer() { - return "https://apps4av.net/new/"; + return "https://www.apps4av.org/site/"; } /** diff --git a/app/src/main/java/com/ds/avare/place/Boundaries.java b/app/src/main/java/com/ds/avare/place/Boundaries.java index 5d6ac4fb4..6a5d0b174 100644 --- a/app/src/main/java/com/ds/avare/place/Boundaries.java +++ b/app/src/main/java/com/ds/avare/place/Boundaries.java @@ -1252,6 +1252,10 @@ public static ArrayList getChartTypes() { "13","OrlandoFLY","-82.0368","27.8287", "13","OrlandoFLY","-80.1406","27.8287", "13","OrlandoFLY","-80.1406","29.2311", + "13","PhoenixFLY","-112.8704026","34.0841324", + "13","PhoenixFLY","-112.855721","32.782496", + "13","PhoenixFLY","-111.1819443","32.7827780", + "13","PhoenixFLY","-111.1675846","34.0844160", "13","SaltLakeCityFLY","-112.909","41.4207", "13","SaltLakeCityFLY","-112.909","40.1204", "13","SaltLakeCityFLY","-111.016","40.1204", diff --git a/app/src/main/java/com/ds/avare/place/DatabaseDestination.java b/app/src/main/java/com/ds/avare/place/DatabaseDestination.java index 3be63a841..9a90b1226 100644 --- a/app/src/main/java/com/ds/avare/place/DatabaseDestination.java +++ b/app/src/main/java/com/ds/avare/place/DatabaseDestination.java @@ -122,35 +122,30 @@ protected Boolean doInBackground(Object... vals) { * Find Chart Supplement */ mAfdFound = null; - final LinkedList afdName = mDataSource.findAFD(mName); - if(afdName.size() > 0) { - FilenameFilter filter = new FilenameFilter() { - public boolean accept(File directory, String fileName) { - boolean match = false; - for(final String name : afdName) { - match |= fileName.matches(name + Preferences.IMAGE_EXTENSION) || - fileName.matches(name + "-[0-9]+" + Preferences.IMAGE_EXTENSION); - } - return match; - } - }; - String afd[] = null; - afd = new File(StorageService.getInstance().getPreferences().getServerDataFolder() + File.separator + "afd" + File.separator).list(filter); - if(null != afd) { - java.util.Arrays.sort(afd); - int len1 = afd.length; - String tmp1[] = new String[len1]; - for(int count = 0; count < len1; count++) { - /* - * Add Chart Supplement - */ - String tokens[] = afd[count].split(Preferences.IMAGE_EXTENSION); - tmp1[count] = StorageService.getInstance().getPreferences().getServerDataFolder() + File.separator + "afd" + File.separator + - tokens[0]; - } - if(len1 > 0) { - mAfdFound = tmp1; + String afd[] = null; + afd = new File(StorageService.getInstance().getPreferences().getServerDataFolder() + File.separator + "afd" + File.separator+ mName + File.separator).list(new FilenameFilter() { + @Override + public boolean accept(File file, String s) { + return !s.equals(".nomedia"); + } + }); + if(null != afd) { + java.util.Arrays.sort(afd); + int len1 = afd.length; + String tmp1[] = new String[len1]; + for(int count = 0; count < len1; count++) { + if(afd[count].equals(".nomedia")) { + continue; } + /* + * Add Chart Supplement + */ + String tokens[] = afd[count].split(Preferences.IMAGE_EXTENSION); + tmp1[count] = StorageService.getInstance().getPreferences().getServerDataFolder() + File.separator + "afd" + File.separator + mName + File.separator + + tokens[0]; + } + if(len1 > 0) { + mAfdFound = tmp1; } } } diff --git a/app/src/main/java/com/ds/avare/plan/LmfsInterface.java b/app/src/main/java/com/ds/avare/plan/LmfsInterface.java index 1fcb7a308..02b77a107 100644 --- a/app/src/main/java/com/ds/avare/plan/LmfsInterface.java +++ b/app/src/main/java/com/ds/avare/plan/LmfsInterface.java @@ -48,7 +48,7 @@ */ public class LmfsInterface { - private static final String AVARE_LMFS_URL = "https://apps4av.net/new/lmfs.php"; + private static final String AVARE_LMFS_URL = "https://www.apps4av.org/site/lmfs.php"; private Context mContext; private Preferences mPref; diff --git a/app/src/main/java/com/ds/avare/shapes/Tile.java b/app/src/main/java/com/ds/avare/shapes/Tile.java index 6dcd3ef09..4a4ba5708 100644 --- a/app/src/main/java/com/ds/avare/shapes/Tile.java +++ b/app/src/main/java/com/ds/avare/shapes/Tile.java @@ -332,10 +332,16 @@ else if(null == tile.getBitmap()) { */ tile.getTransform().setScale(scaleFactor, scaleFactor); + + int tileXIndex = tilen % tiles.getXTilesNum(); + int tileYIndex = tilen / tiles.getXTilesNum(); + int centerTileX = (int)(tiles.getXTilesNum() / 2); + int centerTileY = (int)(tiles.getYTilesNum() / 2); + tile.getTransform().postTranslate( ctx.view.getWidth() / 2.f + ( - BitmapHolder.WIDTH / 2.f - + ((tilen % tiles.getXTilesNum()) * BitmapHolder.WIDTH - BitmapHolder.WIDTH * (int)(tiles.getXTilesNum() / 2)) + + ((tileXIndex - centerTileX) * BitmapHolder.WIDTH) + ctx.pan.getMoveX() + ctx.pan.getTileMoveX() * BitmapHolder.WIDTH - (float)ctx.movement.getOffsetLongitude()) * scaleFactor, @@ -343,7 +349,7 @@ else if(null == tile.getBitmap()) { ctx.view.getHeight() / 2.f + ( - BitmapHolder.HEIGHT / 2.f + ctx.pan.getMoveY() - + ((tilen / tiles.getXTilesNum()) * BitmapHolder.HEIGHT - BitmapHolder.HEIGHT * (int)(tiles.getYTilesNum() / 2)) + + ((tileYIndex - centerTileY) * BitmapHolder.HEIGHT) + ctx.pan.getTileMoveY() * BitmapHolder.HEIGHT - (float)ctx.movement.getOffsetLatitude() ) * scaleFactor); diff --git a/app/src/main/java/com/ds/avare/storage/Preferences.java b/app/src/main/java/com/ds/avare/storage/Preferences.java index 94271030c..fa7bdcdd4 100644 --- a/app/src/main/java/com/ds/avare/storage/Preferences.java +++ b/app/src/main/java/com/ds/avare/storage/Preferences.java @@ -83,13 +83,16 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi /* * Max memory and max screen size it will support */ + public static final long MEM_512 = 512 * 1024 * 1024; public static final long MEM_256 = 256 * 1024 * 1024; public static final long MEM_192 = 192 * 1024 * 1024; public static final long MEM_128 = 128 * 1024 * 1024; public static final long MEM_64 = 64 * 1024 * 1024; public static final long MEM_32 = 32 * 1024 * 1024; - + public static final int MEM_512_X = 13; // For modern devices (512MB+ heap) + public static final int MEM_512_Y = 11; + public static final int MEM_512_OH = 13; public static final int MEM_192_X = 9; public static final int MEM_192_Y = 7; public static final int MEM_192_OH = 13; @@ -193,7 +196,7 @@ public String getRoot() { val = "0"; } if (val.equals("0")) { - return "http://www.apps4av.org/new/"; + return "http://www.apps4av.org/regions/"; } else if (val.equals("1")) { return "https://avare.bubble.org/"; } else if (val.equals("2")) { @@ -218,7 +221,11 @@ public static int[] getTilesNumber(Context ctx, boolean useScreenSize) { */ long mem = Runtime.getRuntime().maxMemory(); - if (mem >= MEM_192) { + if (mem >= MEM_512) { + ret[0] = MEM_512_X; + ret[1] = MEM_512_Y; + ret[2] = MEM_512_OH; + } else if (mem >= MEM_192) { ret[0] = MEM_192_X; ret[1] = MEM_192_Y; ret[2] = MEM_192_OH; @@ -248,8 +255,11 @@ public static int[] getTilesNumber(Context ctx, boolean useScreenSize) { defaultDisplay.getMetrics(displayMetrics); int width = displayMetrics.widthPixels; int height = displayMetrics.heightPixels; - int tilesx = (width / BitmapHolder.WIDTH) + 2; // add 1 for round up, and 1 for zoom - int tilesy = (height / BitmapHolder.HEIGHT) + 2; + // Account for minimum effective zoom (scaleFactor * macroFactor minimum is 0.5) + // At 0.5x zoom, tiles appear half size, so we need 2x as many to cover the screen + float minEffectiveZoom = 0.5f; + int tilesx = (int)Math.ceil((width / BitmapHolder.WIDTH) / minEffectiveZoom) + 2; // +2 for rounding and buffer + int tilesy = (int)Math.ceil((height / BitmapHolder.HEIGHT) / minEffectiveZoom) + 2; // odd tiles only if (tilesx % 2 == 0) { @@ -1073,6 +1083,10 @@ public boolean removeB1Map() { return mPref.getBoolean(mContext.getString(R.string.b1map), false); } + public boolean cameraButton() { + return mPref.getBoolean(mContext.getString(R.string.cameraButton), false); + } + public boolean removeB3Plate() { return mPref.getBoolean(mContext.getString(R.string.b3plate), false); } diff --git a/app/src/main/java/com/ds/avare/utils/MetarFlightCategory.java b/app/src/main/java/com/ds/avare/utils/MetarFlightCategory.java deleted file mode 100644 index 8aab4389c..000000000 --- a/app/src/main/java/com/ds/avare/utils/MetarFlightCategory.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.ds.avare.utils; - -import net.sf.jweather.metar.Metar; -import net.sf.jweather.metar.MetarParser; -import net.sf.jweather.metar.SkyCondition; - -import java.util.ArrayList; - -/** - * Created by zkhan on 12/12/15. - */ -public class MetarFlightCategory { - - - public static String getFlightCategory(String stationId, String rawText) { - - String flightCategory = "Unknown"; - - // parse it to find flight category - try { - Metar metar = MetarParser.parse(stationId + " " + rawText); - float vis = metar.getVisibility().floatValue(); - int ovc = Integer.MAX_VALUE; - boolean isCeiling = false; - boolean visLessThan = metar.getVisibilityLessThan(); - ArrayList sky = metar.getSkyConditions(); - for (SkyCondition cond : sky) { - if(cond.isBrokenClouds() || cond.isOvercast() || cond.isVerticalVisibility()) { - ovc = cond.getHeight(); - isCeiling = true; - break; - } - } - flightCategory = getFlightCategory(isCeiling, ovc, vis, visLessThan); - } - catch (Exception e) { - } - return flightCategory; - } - - - /** - * Find flight category - * https://www.aviationweather.gov/adds/metars/description/page_no/4 - * @param isCeiling - * @param ceilingFt - * @param visibility - * @return - */ - private static String getFlightCategory(boolean isCeiling, int ceilingFt, float visibility, boolean visLessThan) { - if(visLessThan) { - visibility -= 0.01; - } - if((isCeiling && ceilingFt < 500) || visibility < 1) { - return "LIFR"; - } - if((isCeiling && ceilingFt < 1000 && ceilingFt >= 500) || (visibility >= 1 && visibility < 3)) { - return "IFR"; - } - if((isCeiling && ceilingFt < 3000 && ceilingFt >= 1000) || (visibility >= 3 && visibility <= 5)) { - return "MVFR"; - } - if((ceilingFt > 3000 || (!isCeiling)) && (visibility > 5)) { - return "VFR"; - } - return "Unknown"; - } -} diff --git a/app/src/main/java/com/ds/avare/utils/PngCommentReader.java b/app/src/main/java/com/ds/avare/utils/PngCommentReader.java index 73670defe..57bc7a115 100644 --- a/app/src/main/java/com/ds/avare/utils/PngCommentReader.java +++ b/app/src/main/java/com/ds/avare/utils/PngCommentReader.java @@ -1,9 +1,9 @@ package com.ds.avare.utils; + +import androidx.exifinterface.media.ExifInterface; + import java.io.FileInputStream; -import java.nio.ByteOrder; -import java.nio.MappedByteBuffer; -import java.nio.channels.FileChannel; /** * Created by zkhan on 3/14/17. @@ -13,65 +13,37 @@ public class PngCommentReader { public static float[] readPlate(String fileName) { - - FileChannel channel = null; - - try { // parsing a file causes unknown issues, so surround entire with exception catch - channel = new FileInputStream(fileName).getChannel(); - MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size()); - buffer.order(ByteOrder.BIG_ENDIAN); // BE for PNGs - - byte[] sign = new byte[8]; - buffer.get(sign); - if(sign[0] == (byte)0x89 && sign[1] == (byte)0x50 && sign[2] == (byte)0x4E && sign[3] == (byte)0x47 && sign[4] == (byte)0x0D && sign[5] == (byte)0x0A && sign[6] == (byte)0x1A && sign[7] == (byte)0x0A) { - // valid sign - int len = 0; - byte[] type = new byte[4]; - do { - len = buffer.getInt(); - buffer.get(type); - if('t' == type[0] && 'E' == type[1] && 'X' == type[2] && 't' == type[3]) { - byte[] data = new byte[len]; - buffer.get(data); - for(int count = 0; count < data.length; count++) { - if(0 == data[count]) { // remove nulls - data[count] = ' '; - } - } - String txt = new String(data); - if(txt.startsWith("Comment")) { - txt = txt.replace("Comment", ""); - String toks[] = txt.split("[|]"); - if(4 == toks.length) { - float matrix[] = new float[4]; - matrix[0] = (float)Double.parseDouble(toks[0]); - matrix[1] = (float)Double.parseDouble(toks[1]); - matrix[2] = (float)Double.parseDouble(toks[2]); - matrix[3] = (float)Double.parseDouble(toks[3]); - return matrix; - } - } - buffer.position(buffer.position() + 4); // 4 for CRC - } - else { - buffer.position(buffer.position() + len + 4); // 4 for CRC - } - } - while(len > 0); - - } - + ExifInterface exif; + try { + exif = new ExifInterface(new FileInputStream(fileName)); } catch (Exception e) { - + return null; } - try { - channel.close(); + String comment = exif.getAttribute("UserComment"); + if(comment == null) { + return null; } - catch (Exception e) { + String[] toks = comment.split("\\|"); + if(toks.length == 4) { + float[] matrix = new float[4]; + matrix[0] = (float)Double.parseDouble(toks[0]); + matrix[1] = (float)Double.parseDouble(toks[1]); + matrix[2] = (float)Double.parseDouble(toks[2]); + matrix[3] = (float)Double.parseDouble(toks[3]); + return matrix; + } + if(toks.length == 6) { + float[] matrix = new float[12]; + matrix[6] = (float)Double.parseDouble(toks[0]); + matrix[7] = (float)Double.parseDouble(toks[1]); + matrix[8] = (float)Double.parseDouble(toks[2]); + matrix[9] = (float)Double.parseDouble(toks[3]); + matrix[10] = (float)Double.parseDouble(toks[4]); + matrix[11] = (float)Double.parseDouble(toks[5]); + return matrix; } - return null; } } diff --git a/app/src/main/java/com/ds/avare/utils/ProServicesPreference.java b/app/src/main/java/com/ds/avare/utils/ProServicesPreference.java new file mode 100644 index 000000000..0e41c04cf --- /dev/null +++ b/app/src/main/java/com/ds/avare/utils/ProServicesPreference.java @@ -0,0 +1,62 @@ +/* +Copyright (c) 2026, Apps4Av Inc. (apps4av.com) +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +package com.ds.avare.utils; + +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.preference.DialogPreference; +import android.util.AttributeSet; +import android.widget.Toast; + +import com.ds.avare.ProActivity; +import com.ds.avare.R; + +/** + * Preference entry that opens the RevenueCat paywall via {@link ProActivity}. + * Mirrors {@link RegisterActivityPreference} so users can subscribe (or + * restore an existing subscription) from the Settings screen at any time. + */ +public class ProServicesPreference extends DialogPreference { + + private final Context mContext; + + public ProServicesPreference(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + super.onDialogClosed(positiveResult); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (which != DialogInterface.BUTTON_POSITIVE) { + return; + } + if (!RevenueCatService.isConfigured()) { + Toast.makeText(mContext, + mContext.getString(R.string.ProServiceUnavailable), + Toast.LENGTH_SHORT).show(); + return; + } + try { + Intent intent = new Intent(mContext, ProActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mContext.startActivity(intent); + } catch (Throwable ignored) { + // optional — silently fail + } + } +} diff --git a/app/src/main/java/com/ds/avare/utils/RevenueCatService.java b/app/src/main/java/com/ds/avare/utils/RevenueCatService.java new file mode 100644 index 000000000..3a79220d4 --- /dev/null +++ b/app/src/main/java/com/ds/avare/utils/RevenueCatService.java @@ -0,0 +1,193 @@ +/* +Copyright (c) 2026, Apps4Av Inc. (apps4av.com) +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +package com.ds.avare.utils; + +import android.content.Context; + +import com.revenuecat.purchases.CustomerInfo; +import com.revenuecat.purchases.EntitlementInfo; +import com.revenuecat.purchases.LogLevel; +import com.revenuecat.purchases.Purchases; +import com.revenuecat.purchases.PurchasesConfiguration; +import com.revenuecat.purchases.PurchasesError; +import com.revenuecat.purchases.interfaces.LogInCallback; +import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback; + +/** + * Thin wrapper around the RevenueCat Android SDK. Mirrors the avarex + * {@code RevenueCatService} so the two apps share the same entitlement model. + * + * RevenueCat is treated as an OPTIONAL service in Avare. Initialization + * failures (no Google Play, no network, missing api key, etc.) are swallowed + * and the app keeps working — non-subscribers simply see the start-up + * "use count" nag dialog while subscribers see no extra dialogs at all. + */ +public final class RevenueCatService { + + /** + * Public key, replaced at build time (matches avarex placeholder). + * If left as the placeholder string the SDK will simply fail to fetch + * customer info and {@link #isProEntitled(EntitlementCallback)} returns + * false — which is the desired "not subscribed" fallback. + */ + public static final String ANDROID_API_KEY = "@@___revenuecat_android_api_key__@@"; + + /** Entitlement identifier configured in the RevenueCat dashboard. */ + public static final String ENTITLEMENT_ID = "Pro"; + + /** + * Offering identifier configured in the RevenueCat dashboard. + * The dashboard display name is "Paid" — the identifier below is what + * the SDK uses to look the offering up. + */ + public static final String OFFERING_ID = "default_paid"; + + private static boolean sConfigured = false; + + private RevenueCatService() { } + + /** + * Initialize the SDK. Safe to call multiple times; no-op after the first + * successful call. Never throws. + */ + public static synchronized void init(Context context) { + if (sConfigured) { + return; + } + if (context == null) { + return; + } + // If the api-key placeholder was not substituted at build time, do + // not attempt to configure. RevenueCat would reject an invalid key + // and log noisily on every launch. + if (ANDROID_API_KEY == null + || ANDROID_API_KEY.isEmpty() + || ANDROID_API_KEY.startsWith("@@")) { + return; + } + try { + Purchases.setLogLevel(LogLevel.INFO); + PurchasesConfiguration cfg = new PurchasesConfiguration.Builder( + context.getApplicationContext(), ANDROID_API_KEY).build(); + Purchases.configure(cfg); + sConfigured = true; + } catch (Throwable ignored) { + // RevenueCat is optional — swallow any init failure + } + } + + /** @return true once {@link #init(Context)} has succeeded. */ + public static synchronized boolean isConfigured() { + return sConfigured; + } + + /** Callback for {@link #isProEntitled(EntitlementCallback)}. */ + public interface EntitlementCallback { + /** Always invoked on the main thread. */ + void onResult(boolean entitled); + } + + /** + * Log the current Firebase user into RevenueCat so their entitlement is + * synced across devices. Safe to call repeatedly. Mirrors avarex + * {@code RevenueCatService.logIn}. All errors swallowed. + */ + public static void logIn(String userId, String email, String displayName) { + if (!isConfigured() || userId == null || userId.isEmpty()) { + return; + } + try { + Purchases.getSharedInstance().logIn(userId, new LogInCallback() { + @Override + public void onReceived(CustomerInfo customerInfo, boolean created) { + // ignore + } + + @Override + public void onError(PurchasesError error) { + // ignore + } + }); + if (email != null && !email.isEmpty()) { + try { + Purchases.getSharedInstance().setEmail(email); + } catch (Throwable ignored) { /* ignore */ } + } + if (displayName != null && !displayName.isEmpty()) { + try { + Purchases.getSharedInstance().setDisplayName(displayName); + } catch (Throwable ignored) { /* ignore */ } + } + } catch (Throwable ignored) { + // optional service + } + } + + /** Log out of RevenueCat (anonymous identity going forward). */ + public static void logOut() { + if (!isConfigured()) { + return; + } + try { + Purchases.getSharedInstance().logOut(new ReceiveCustomerInfoCallback() { + @Override + public void onReceived(CustomerInfo customerInfo) { /* ignore */ } + + @Override + public void onError(PurchasesError error) { /* ignore */ } + }); + } catch (Throwable ignored) { + // optional service + } + } + + /** + * Asynchronously check whether the current user owns the {@link + * #ENTITLEMENT_ID} entitlement. If RevenueCat is not configured, or any + * error occurs (network down, etc.), the callback receives {@code false}. + */ + public static void isProEntitled(final EntitlementCallback cb) { + if (cb == null) { + return; + } + if (!isConfigured()) { + cb.onResult(false); + return; + } + try { + Purchases.getSharedInstance().getCustomerInfo(new ReceiveCustomerInfoCallback() { + @Override + public void onReceived(CustomerInfo customerInfo) { + boolean entitled = false; + try { + EntitlementInfo ent = customerInfo + .getEntitlements() + .getAll() + .get(ENTITLEMENT_ID); + entitled = ent != null && ent.isActive(); + } catch (Throwable ignored) { + entitled = false; + } + cb.onResult(entitled); + } + + @Override + public void onError(PurchasesError error) { + cb.onResult(false); + } + }); + } catch (Throwable t) { + cb.onResult(false); + } + } +} diff --git a/app/src/main/java/com/ds/avare/views/PlatesView.java b/app/src/main/java/com/ds/avare/views/PlatesView.java index 9b5bc75fa..f9130c651 100644 --- a/app/src/main/java/com/ds/avare/views/PlatesView.java +++ b/app/src/main/java/com/ds/avare/views/PlatesView.java @@ -244,7 +244,7 @@ public void onDraw(Canvas canvas) { */ - if (mShowingAD) { + if (mShowingAD && mMatrix.length == 12) { /* * Mike's matrix */ diff --git a/app/src/main/java/com/ds/avare/weather/MetarFlightCategory.java b/app/src/main/java/com/ds/avare/weather/MetarFlightCategory.java index 9d624873c..b591da3ca 100644 --- a/app/src/main/java/com/ds/avare/weather/MetarFlightCategory.java +++ b/app/src/main/java/com/ds/avare/weather/MetarFlightCategory.java @@ -11,14 +11,42 @@ */ public class MetarFlightCategory { + /** Real METAR/SPECI bodies are far smaller; cap avoids parser OOM on garbage uplinks. */ + private static final int MAX_RAW_METAR_CHARS = 4096; public static String getFlightCategory(String stationId, String rawText) { String flightCategory = "Unknown"; + if (stationId == null || rawText == null) { + return flightCategory; + } + + String s = rawText.trim(); + if (s.isEmpty()) { + return flightCategory; + } + + int nl = s.indexOf('\n'); + if (nl >= 0) { + s = s.substring(0, nl).trim(); + if (s.isEmpty()) { + return flightCategory; + } + } + + if (s.length() > MAX_RAW_METAR_CHARS) { + s = s.substring(0, MAX_RAW_METAR_CHARS); + } + + String id = stationId.trim(); + if (id.isEmpty()) { + return flightCategory; + } + // parse it to find flight category try { - Metar metar = MetarParser.parse(stationId + " " + rawText); + Metar metar = MetarParser.parse(id + " " + s); float vis = metar.getVisibility().floatValue(); int ovc = Integer.MAX_VALUE; boolean isCeiling = false; diff --git a/app/src/main/java/net/sf/jweather/metar/MetarParser.java b/app/src/main/java/net/sf/jweather/metar/MetarParser.java index 8658dd629..5373e63f3 100644 --- a/app/src/main/java/net/sf/jweather/metar/MetarParser.java +++ b/app/src/main/java/net/sf/jweather/metar/MetarParser.java @@ -130,7 +130,10 @@ private Metar parseData(String metarData) throws MetarParseException { throw new MetarParseException("empty metar data"); } - + // sanitize abbreviated METARs that may end in '=' so they can't interfere with unit parsing on tokens + if(metarData.endsWith("=")) { + metarData = metarData.substring(0, metarData.length()-1); + } Metar metar = new Metar(); @@ -146,7 +149,7 @@ private Metar parseData(String metarData) throws MetarParseException { utility.split(tokens, ((String)metarData)); } catch(MalformedPerl5PatternException e) { - throw new MetarParseException("error spliting metar data on whitespace: "+e); + throw new MetarParseException("error splitting metar data on whitespace: "+e); } // the number of tokens we have @@ -159,8 +162,10 @@ private Metar parseData(String metarData) throws MetarParseException { // format: CCCC // CCCC - alphabetic characters only [a-zA-Z] metar.setStationID((String)tokens.get(index++)); - - + // station id or time may be replaced with "NIL" signifying end of METAR/missing METAR + if(index == numTokens || numTokens == 2) { + return metar; + } // date and time of the report @@ -211,9 +216,7 @@ private Metar parseData(String metarData) throws MetarParseException { metar.setDate(calendar.getTime()); // on to the next token - if (index < numTokens - 1) { - index++; - } + index++; } else { @@ -231,16 +234,14 @@ private Metar parseData(String metarData) throws MetarParseException { { metar.setReportModifier((String)tokens.get(index)); // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } - } else { } - - // wind group (speed and direction) // format: dddff(f)Gf f (f )KT_d d d Vd d d // m m m n n n x x x @@ -251,18 +252,10 @@ private Metar parseData(String metarData) throws MetarParseException { // KT (or) MPS - knots (or) meters per second // d d d Vd d d - variable wind direction > 6 knots, degree=>degree // n n n x x x e.g. 180V210 => variable from 180deg to 210deg - temp = (String)tokens.get(index); if (temp.endsWith("KT") || temp.endsWith("MPS")) { int pos = 0; - boolean windInKnots = false; - - if (temp.endsWith("KT")) { - - windInKnots = true; - } else { - - } + boolean windInKnots = temp.endsWith("KT"); if (!((String)tokens.get(index)).substring(0,3).equals("VRB")) { // we have gusts @@ -331,20 +324,12 @@ private Metar parseData(String metarData) throws MetarParseException { } - - if (windInKnots) { - - } else { - - } - - // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } - // if we have variable wind direction temp = ((String)tokens.get(index)); try { @@ -355,12 +340,10 @@ private Metar parseData(String metarData) throws MetarParseException { metar.setWindDirectionMin(new Integer(((String)tokens.get(index)).substring(0,3))); metar.setWindDirectionMax(new Integer(((String)tokens.get(index)).substring(4,7))); - - - // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } } } catch(MalformedPatternException e) { @@ -383,8 +366,9 @@ private Metar parseData(String metarData) throws MetarParseException { metar.setIsCavok(true); // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } // Horizontal visibility in meters } else if (matcher.matches(((String)tokens.get(index)), new Perl5Compiler().compile("/^(\\d+)$/"))) { @@ -392,16 +376,18 @@ private Metar parseData(String metarData) throws MetarParseException { metar.setVisibilityInMeters(new Float(tmp)); // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } // Horizontal visibility of 10Km and above } else if (((String)tokens.get(index)).equals("9999")) { metar.setVisibilityInKilometers(new Float(10)); // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } // get visibility @@ -472,10 +458,10 @@ private Metar parseData(String metarData) throws MetarParseException { metar.setVisibilityLessThan(isLessThan); // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } - } else { String token = (String)tokens.get(index); boolean isLessThan = false; @@ -493,8 +479,9 @@ private Metar parseData(String metarData) throws MetarParseException { metar.setVisibilityLessThan(isLessThan); // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } } else { // unexpected token...should have been visibility @@ -568,8 +555,9 @@ private Metar parseData(String metarData) throws MetarParseException { } // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } metar.addRunwayVisualRange(runwayVisualRange); @@ -700,8 +688,9 @@ private Metar parseData(String metarData) throws MetarParseException { } // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } } @@ -763,8 +752,9 @@ private Metar parseData(String metarData) throws MetarParseException { // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } } @@ -819,8 +809,9 @@ private Metar parseData(String metarData) throws MetarParseException { //dewPoint = new Float(((String)tokens.get(index)).substring(5,9)).floatValue(); // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } } else { @@ -847,8 +838,9 @@ private Metar parseData(String metarData) throws MetarParseException { // on to the next token - if (index < numTokens - 1) { - index++; + index++; + if(index == numTokens) { + return metar; } } else { diff --git a/app/src/main/res/layout/activity_pro.xml b/app/src/main/res/layout/activity_pro.xml new file mode 100644 index 000000000..dcfa70256 --- /dev/null +++ b/app/src/main/res/layout/activity_pro.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + +