Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Morphe NOTICE
Copyright 2026 Morphe
https://github.com/MorpheApp/PotHelper
Morphe NOTICE
Copyright 2026 Morphe
https://github.com/MorpheApp/PotHelper

Portions of this software are provided by the Morphe software project.
Portions of this software are provided by the Morphe software project.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,19 @@

A simple PoToken minter written in Java.

The helper app runs offline and has no internet access permissions.

## ⚖️ Disclaimer

This project is provided **as is** without any warranty or guarantee of functionality, stability, or safety. **Use at your own risk**.

## 📜 License

Distributed under the [Apache License 2.0](https://choosealicense.com/licenses/apache-2.0/).
Distributed under the [Apache License 2.0](https://choosealicense.com/licenses/apache-2.0/).

### License Compatibility

This project contains native code distributed exclusively in compiled binary form.

Because the "Corresponding Source" for these binaries is not available, the native code is incompatible with copyleft licenses.
Specifically, the native code portions may not be linked with or distributed with GPLv3 code or with code under any other strong copyleft license.
31 changes: 25 additions & 6 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,31 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_launcher_name">

<activity
android:allowBackup="true"
android:name=".MainActivity"
android:theme="@style/AppTheme"
android:exported="true" />

<!--
Carries the launcher icon, disabled on purpose: since Android 10 the system
substitutes a synthetic icon for one that the manifest enables, so only an
icon that starts off hidden can be hidden at all.
-->
<activity-alias
android:name=".Launcher"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</activity-alias>

<!--
Client apps bind to the provider and the service, so both must stay exported
without a permission those clients do not hold. The provider exposes no data:
it returns an empty cursor, its writes are no-ops, and call only resolves
services of this package.
-->
<provider
android:name=".chimera.ServiceProvider"
android:authorities="app.morphe.pot.helper.chimera"
android:exported="true" />
android:exported="true"
tools:ignore="ExportedContentProvider" />

<service
android:name=".potokens.PoTokenService"
android:exported="true">
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="app.morphe.pot.helper.potokens.service.START" />
</intent-filter>
Expand Down
51 changes: 0 additions & 51 deletions app/src/main/java/app/morphe/pot/helper/LicensePreference.java

This file was deleted.

208 changes: 190 additions & 18 deletions app/src/main/java/app/morphe/pot/helper/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,38 @@
package app.morphe.pot.helper;

import android.app.Activity;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.Insets;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.Html;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.BulletSpan;
import android.util.Log;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.widget.Switch;
import android.widget.TextView;

import java.io.InputStream;
import java.util.Scanner;

@SuppressWarnings("deprecation")
public class MainActivity extends Activity {
private static final String TAG = "morphe: MainActivity";

@Override
protected void onCreate(Bundle savedInstanceState) {
Expand All @@ -40,34 +61,185 @@ protected void onCreate(Bundle savedInstanceState) {

Window window = getWindow();
window.setStatusBarColor(Color.TRANSPARENT);
window.setNavigationBarColor(Color.TRANSPARENT);

View decorView = window.getDecorView();
if (isDarkModeEnabled) {
decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
} else {
decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE);
// Otherwise the system paints its own opaque scrim over the bars.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.setStatusBarContrastEnforced(false);
window.setNavigationBarContrastEnforced(false);
}
if (Build.VERSION.SDK_INT > 30) {

// Creates the decor view, which the insets controller needs.
View decorView = window.getDecorView();

// A light flag means the bar background is light, so it belongs to the light theme.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
WindowInsetsController insetsController = window.getInsetsController();
if (insetsController != null) {
insetsController.setSystemBarsAppearance(
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
);
int lightBars = WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
| WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS;
insetsController.setSystemBarsAppearance(isDarkModeEnabled ? 0 : lightBars, lightBars);
}
} else {
int lightBars = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
int visibility = decorView.getSystemUiVisibility();
decorView.setSystemUiVisibility(isDarkModeEnabled
? visibility & ~lightBars
: visibility | lightBars);
}

TypedValue typedValue = new TypedValue();
getTheme().resolveAttribute(android.R.attr.colorBackground, typedValue, true);
findViewById(android.R.id.content).setBackgroundColor(typedValue.data);

setContentView(R.layout.root_view);
View contentView = findViewById(android.R.id.content);
contentView.setBackgroundColor(typedValue.data);

// The window is edge to edge.
contentView.setOnApplyWindowInsetsListener((view, insets) -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Insets bars = insets.getInsets(WindowInsets.Type.systemBars());
view.setPadding(bars.left, bars.top, bars.right, bars.bottom);
} else {
view.setPadding(
insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(),
insets.getSystemWindowInsetBottom()
);
}
return insets;
});

setContentView(R.layout.about);

if (savedInstanceState == null) {
getFragmentManager()
.beginTransaction()
.replace(R.id.fragment_container, new MorpheFragment())
.commit();
TextView versionView = findViewById(R.id.app_version);
versionView.setText(getString(R.string.app_version, BuildConfig.VERSION_NAME));

bind(findViewById(R.id.about_content));

Switch showIconSwitch = findViewById(R.id.show_icon_switch);
showIconSwitch.setChecked(isLauncherIconShown());
showIconSwitch.setOnCheckedChangeListener((view, shown) -> setLauncherIconShown(shown));
findViewById(R.id.show_icon).setOnClickListener(view -> showIconSwitch.toggle());
}

/**
* Makes every tagged row tappable. A tag is either a link, or the name
* of a license asset followed by the URL of its source.
*/
private void bind(ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
View child = group.getChildAt(i);
Object tag = child.getTag();
if (tag instanceof String) {
child.setOnClickListener(view -> open((String) tag));
} else if (child instanceof ViewGroup && !child.isClickable()) {
// Only a card is clipped, so that the ripple of its rows follows the rounded
// corners. Clipping a row would hide it, because its own ripple has no outline.
child.setClipToOutline(true);
bind((ViewGroup) child);
}
}
}
}

private ComponentName launcherAlias() {
return new ComponentName(this, getPackageName() + ".Launcher");
}

private boolean isLauncherIconShown() {
return getPackageManager().getComponentEnabledSetting(launcherAlias())
== PackageManager.COMPONENT_ENABLED_STATE_ENABLED;
}

private void setLauncherIconShown(boolean shown) {
getPackageManager().setComponentEnabledSetting(
launcherAlias(),
shown
? PackageManager.COMPONENT_ENABLED_STATE_ENABLED
: PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP);
}

private void open(String tag) {
if (tag.indexOf(' ') < 0) {
browse(tag);
} else {
showLicenseDialog(tag);
}
}

private void browse(String url) {
try {
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
} catch (ActivityNotFoundException ex) {
Log.e(TAG, "No app can open: " + url, ex);
}
}

/**
* Shows the license of a row tagged with an asset name, the URL of its source,
* and optionally the markers 'numbered' and 'tall'.
*/
@SuppressWarnings("CharsetObjectCanBeUsed")
private void showLicenseDialog(String tag) {
String[] parts = tag.split(" ");
String asset = parts[0];
String sourceUrl = parts[1];
boolean useNumberFormat = tag.contains(" numbered");
boolean tallDialog = tag.contains(" tall");

Spanned content;
try (InputStream is = getAssets().open(asset + ".html")) {
String text = new Scanner(is, "UTF-8").useDelimiter("\\A").next();
content = Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY);
} catch (Exception ex) {
Log.e(TAG, "openAssets failed", ex);
return;
}

View contentView = getLayoutInflater().inflate(R.layout.license_dialog, null);
TextView licenseContentView = contentView.findViewById(R.id.license_content);
licenseContentView.setText(handleBulletSpans(content, useNumberFormat));

AlertDialog alertDialog = new AlertDialog.Builder(this, R.style.DialogTheme)
.setView(contentView)
.setPositiveButton(R.string.license_dialog_ok_button_text, null)
.setNeutralButton(R.string.license_dialog_source_button_text,
(dialog, id) -> browse(sourceUrl))
.show();

Window window = alertDialog.getWindow();
if (window != null) {
window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);

// A long notice would otherwise stretch the dialog over the whole screen.
if (tallDialog) {
WindowManager.LayoutParams params = window.getAttributes();
params.height = (int) (getResources().getDisplayMetrics().heightPixels * 0.85);
window.setAttributes(params);
}
}
}

/**
* Html.fromHtml turns every 'li' tag into a bullet point and drops the list type,
* so the bullets are replaced with the numbering of the original license:
* 1, 2, 3 when useNumberFormat is set, a, b, c otherwise.
*/
private Spanned handleBulletSpans(Spanned spanned, boolean useNumberFormat) {
SpannableStringBuilder builder = new SpannableStringBuilder(spanned);
BulletSpan[] bulletSpans = builder.getSpans(0, builder.length(), BulletSpan.class);

for (int i = 0; i < bulletSpans.length; i++) {
BulletSpan bulletSpan = bulletSpans[i];
int start = builder.getSpanStart(bulletSpan);
builder.removeSpan(bulletSpan);
String prefix = useNumberFormat
? (i + 1) + ". "
: (char) ('a' + (i % 26)) + ". ";
builder.insert(start, prefix);
}

return builder;
}
}
Loading