Skip to content

Commit

Permalink
Merge pull request #84 from nicolasbrailo/reshow-config-toast
Browse files Browse the repository at this point in the history
Holiday hacking 2: Reshow config toast
  • Loading branch information
pserwylo authored Jan 2, 2024
2 parents 88d4697 + 62fefe4 commit ce5b21b
Show file tree
Hide file tree
Showing 7 changed files with 541 additions and 109 deletions.
216 changes: 150 additions & 66 deletions app/src/main/java/com/nicobrailo/pianoli/AppConfigTrigger.java
Original file line number Diff line number Diff line change
@@ -1,116 +1,200 @@
package com.nicobrailo.pianoli;

import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.media.MediaPlayer;
import android.util.Log;

import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;


/**
* Listens for, and defines, the "magic" key combination that unlocks the app, and opens the settings menu.
*
* <p>
* The next key to touch is indicated by a "settings" gear-icon, with already-pressed keys showing a smaller version.
* </p>
* <p>
* The "magic" combination is a random combination of multiple black keys, to be held simultaneously.
* The amount of which is defined by {@link #CONFIG_TRIGGER_COUNT} (currently {@value #CONFIG_TRIGGER_COUNT}).
* We've chosen a simultaneous-hold koy combo, rather than a serial sequence, because children in our target audience
* lack the fine motor skills to achieve it (at least without accidentally triggering a reset by accidentally
* brushing another key), but they <em>would</em> be able to trigger a serial sequence by playing "follow the gear".
* </p>
*/
class AppConfigTrigger implements PianoListener {
private static final float CONFIG_ICON_SIZE_TO_FLAT_KEY_RATIO = 0.5f;
private static final float CONFIG_ICON_SIZE_TO_FLAT_KEY_RATIO_PRESSED = 0.4f;
private static final int CONFIG_TRIGGER_COUNT = 2;
/** How many of the geared keys must be held before config opens */
public static final int CONFIG_TRIGGER_COUNT = 2;

/**
* Candidate keys to receive a gear icon.
*
* <p>Currently a hardcoded set of the first </p>
*/
private static final Set<Integer> BLACK_KEYS = new HashSet<>(Arrays.asList(1, 3, 7, 9, 11, 15));
private final AppCompatActivity activity;

/**
* Current progress in the unlock sequence: all already-held config-keys.
*
* <p>
* We need to track which keys are held, not just their amount, to<ol>
* <li>avoid re-selecting them as next candidate key</li>
* <li>draw icons on them.</li>
* </ol>
* </p>
*/
private final Set<Integer> pressedConfigKeys = new HashSet<>();
private Integer nextKeyPress;
private TooltipReminder tooltipReminder;

/**
* @see #calculateNextExpectedKey()
*/
private int nextExpectedKey;

/**
* Our "upstream", who knows enougjh about global app context to actually <em>do</em> stuff.
*/
private AppConfigCallback cb = null;
private boolean tooltip_shown = false;
private final Drawable icon;

AppConfigTrigger(AppCompatActivity activity) {
nextKeyPress = getNextExpectedKey();
this.activity = activity;
this.icon = ContextCompat.getDrawable(activity, R.drawable.ic_settings);
if (this.icon == null) {
Log.wtf("PianOliError", "Config icon doesn't exist");
}

AppConfigTrigger() {
nextExpectedKey = calculateNextExpectedKey();
}

void setConfigRequestCallback(AppConfigCallback cb) {
this.cb = cb;
tooltipReminder = new TooltipReminder(cb);
}

/**
* @return set of currently-held config keys (defensively copied).
*/
public Set<Integer> getPressedConfigKeys() {
return new HashSet<>(pressedConfigKeys);
}

private Integer getNextExpectedKey() {
Set<Integer> nextKeyOptions = new HashSet<>(BLACK_KEYS);
nextKeyOptions.removeAll(pressedConfigKeys);
int next_key_i = (new Random()).nextInt(nextKeyOptions.size());
/**
* @return currently expected next key in the sequence (without changing it)
* @see #calculateNextExpectedKey();
*/
public int getNextExpectedKey() {
return nextExpectedKey;
}

/**
* Chooses the next key that must be held to make progress in the sequence.
*
* <p>
* Ensures already-held keys are not chosen again.
* </p>
*/
private int calculateNextExpectedKey() {
Set<Integer> candidates = new HashSet<>(BLACK_KEYS);
candidates.removeAll(pressedConfigKeys);

if (candidates.isEmpty()) {
Log.e("PianOliError", "No next config key possible");
return -1;
}

for (Integer nextKey : nextKeyOptions) {
next_key_i = next_key_i - 1;
if (next_key_i <= 0) return nextKey;
// Since we cannot easily pick a random selection from a set directly,
// (at least not at the low API-level we want to support)
// iterate the set to a random depth and select that one.
int i = (new Random()).nextInt(candidates.size());
for (Integer nextKey : candidates) {
i--;
if (i <= 0) { return nextKey; }
}

Log.e("PianOliError", "No next config key possible");
// Unreachable due to way candidates.size is upper bound for loop count,
// but that's too complicated for the compiler to figure out.
// (it can't see through the Random.nextInt() ).
return -1;
}

private void reset() {
// Only do an actual reset if there was some state to reset, otherwise this will select a
// new NextExpectedKey and move the icon around whenever the user presses a key
/**
* Resets all progress towards opening the config, back to zero.
*
* <p>
* If any gear-keys were already held, a new expected key is randomly chosen from <em>non-held</em> keys.
* This ensures any current touches lose their status as "progress".
* </p>
*
* @see #pressedConfigKeys
* @see #calculateNextExpectedKey()
*/
void reset() {
// Only change expected keys if there was some progress to reset, otherwise this would select a
// new NextExpectedKey and move the icon around whenever the user presses a key.
if (!pressedConfigKeys.isEmpty()) {
nextKeyPress = getNextExpectedKey();
// Calculate next expectation *before* clearing pressedConfigKeys, to keep current touches
// out of the candidate list. Otherwise, we could accidentally make an already-held key into a 'magic'
// key, thereby granting the user unlock-progress without them doing anything to deserve it.
nextExpectedKey = calculateNextExpectedKey();
}

pressedConfigKeys.clear();
}

private void showConfigDialogue() {
final MediaPlayer snd = MediaPlayer.create(activity, R.raw.alert);
snd.seekTo(0);
snd.setVolume(100, 100);
snd.start();
snd.setOnCompletionListener(mediaPlayer -> snd.release());

if (cb != null) {
cb.onConfigOpenRequested();
}
}

@Override
public void onKeyDown(int keyIdx) {
if (keyIdx == nextKeyPress) {
if (!tooltip_shown) {
tooltip_shown = true;
cb.onShowConfigTooltip();
}

if (keyIdx == nextExpectedKey) {
// track user's progress in the unlock-sequence
pressedConfigKeys.add(keyIdx);
if (pressedConfigKeys.size() == CONFIG_TRIGGER_COUNT) {
reset();
showConfigDialogue();
// Sequence complete!
reset(); // clear it so it's no longer counted as in-progress.
// Open Sesame!
if (cb != null) {
cb.requestConfig();
}
} else {
nextKeyPress = getNextExpectedKey();
nextExpectedKey = calculateNextExpectedKey();
}
} else {
// wrong key: force user/child to start from the beginning.
reset();
}
}

/**
* Reset all unlock-sequence progress.
*
* <p>
* Releasing *any* key means we are either<ul>
* <li>aborting our in-progress sequence (released key was a geared one), or</li>
* <li>another 'wrong' key used to be pressed and is now released</li>
* </ul>
* Either way, we want to force the user to start over, for touching a non-config key.
* (A mistake an adult would have been able to avoid, but a child likely wouldn't).
* </p>
*
* @param keyIdx unused for this purpose, all releases are equally 'mistaken'.
*/
@Override
public void onKeyUp(int keyIdx) {
reset();
}

void onPianoRedrawFinish(PianoCanvas piano, Canvas canvas) {
int pressedSize = (int) (piano.piano.get_keys_flat_width() * CONFIG_ICON_SIZE_TO_FLAT_KEY_RATIO_PRESSED);
for (Integer cfgKey : pressedConfigKeys) {
piano.draw_icon_on_black_key(canvas, icon, cfgKey, pressedSize, pressedSize);
if (pressedConfigKeys.contains(keyIdx)) {
// The released key was part of an in-progress unlock-sequence
// (completed sequence would have invoked reset, thus clearing this set, before we get here)
tooltipReminder.registerFailedAttempt();
}

int normalSize = (int) (piano.piano.get_keys_flat_width() * CONFIG_ICON_SIZE_TO_FLAT_KEY_RATIO);
piano.draw_icon_on_black_key(canvas, icon, nextKeyPress, normalSize, normalSize);
reset();
}

/**
* Decoupling interface, to keep Android-environment awareness out of this Trigger-class.
*
* <p>
* Switching activities, and showing user UI feedback, require a level of global application awareness
* that is out of place for this trigger-tracker.
* Via this interface, we delegate our required actions to a higher-up that is allowed to have such
* awareness.
* </p>
*/
public interface AppConfigCallback {
void onConfigOpenRequested();
/** Switch to the Config/Settings Activity */
void requestConfig();

void onShowConfigTooltip();
/** Show a hint to the user on how to use the gear icons */
void showConfigTooltip();
}
}
18 changes: 12 additions & 6 deletions app/src/main/java/com/nicobrailo/pianoli/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.media.MediaPlayer;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.Toast;

import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatActivity;

Expand Down Expand Up @@ -70,13 +70,19 @@ protected void onResume() {
lock_app();
}

private static final int REQUEST_CONFIG = 1;

@Override
public void onConfigOpenRequested() {
public void requestConfig() {
// play a loud, distinct noise to alert any adults nearby that *someone*
// (potentially a half-supervised child) has managed to break out of the app.
final MediaPlayer snd = MediaPlayer.create(this, R.raw.alert);
snd.seekTo(0);
snd.setVolume(100, 100);
snd.start();
snd.setOnCompletionListener(mediaPlayer -> snd.release());

// If you've done the dance to press multiple specific buttons at once, no need to keep the screen locked.
// It will be a minor inconvenience when returning from settings, because it will prompt the user again
// to lock the app. However the expectation is that the options are not used very often, and the benefit
// to lock the app. However, the expectation is that the options are not used very often, and the benefit
// of having a settings screen work like a more typical Android app probably outweigh the negatives from a
// child accidentally getting to the settings screen.
unlock_app();
Expand All @@ -85,7 +91,7 @@ public void onConfigOpenRequested() {
}

@Override
public void onShowConfigTooltip() {
public void showConfigTooltip() {
Toast toast = Toast.makeText(getApplicationContext(), R.string.config_tooltip, Toast.LENGTH_LONG);
toast.show();
}
Expand Down
Loading

0 comments on commit ce5b21b

Please sign in to comment.