Compare commits

..

No commits in common. "main" and "master" have entirely different histories.
main ... master

131 changed files with 2667 additions and 3745 deletions

View file

@ -1,74 +0,0 @@
name: Build & Test
on:
pull_request:
push:
branches: [ main ]
jobs:
validate:
runs-on: ubuntu-latest
name: Validate
steps:
- uses: actions/checkout@v4
- name: set up JDK
uses: https://git.wbrawner.com/actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '21'
- name: Validate Gradle Wrapper
uses: https://git.wbrawner.com/gradle/actions/wrapper-validation@v4
unit_tests:
name: Run Unit Tests
runs-on: ubuntu-latest
needs:
- validate
steps:
- uses: actions/checkout@v4
- name: set up JDK
uses: https://git.wbrawner.com/actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '21'
- name: Setup Android SDK
uses: https://git.wbrawner.com/android-actions/setup-android@v3
- name: Setup Gradle
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v4
- name: Run checks
run: ./gradlew --no-daemon --console=plain check
# TODO: Uncomment the following once I get unit tests written
# - name: Publish JUnit Results
# uses: actions/upload-artifact@v3
# if: always()
# with:
# name: Unit Test Results
# path: "*/build/reports/*"
# if-no-files-found: error
# TODO: Uncomment the following once I get UI tests written
# ui_tests:
# runs-on: ubuntu-latest
# name: Run UI Tests
# needs:
# - validate
# steps:
# - uses: actions/checkout@v2
# - name: set up JDK
# uses: https://git.wbrawner.com/actions/setup-java@v3
# with:
# distribution: 'zulu'
# java-version: '21'
# - name: Build with Gradle
# uses: https://git.wbrawner.com/gradle/gradle-build-action@v2
# with:
# arguments: assembleDebug assembleDebugAndroidTest
# - name: Grant execute permission for flank_auth.sh
# run: chmod +x flank_auth.sh
# - name: Add auth for flank
# env:
# GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }}
# run: |
# ./flank_auth.sh
# - name: Run UI tests
# uses: https://git.wbrawner.com/gradle/gradle-build-action@v2
# with:
# arguments: runFlank

10
.gitignore vendored
View file

@ -1,11 +1,15 @@
*.iml
.gradle
/local.properties
.idea
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
keystore.properties
.kotlin
keystore.properties

View file

@ -1 +1 @@
Pi-helper
Pi-Helper

View file

@ -1,251 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="-1681706849">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1446219758">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1423466859">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1235639767">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1082288520">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1041444055">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-964027671">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-797988877">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-725122147">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-255309275">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="20293776">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="28629151">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="302221053">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="496416765">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="730894960">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="2A221FDH200B53" value="120" />
<entry key="Duration" value="90" />
<entry key="Google&#10; Pixel 7" value="120" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1517452226">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="2038388625">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="2093109046">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_35" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>

View file

@ -1,8 +0,0 @@
<component name="ArtifactManager">
<artifact type="jar" name="shared-desktop">
<output-path>$PROJECT_DIR$/shared/build/libs</output-path>
<root id="archive" name="shared-desktop.jar">
<element id="module-output" name="Pi-helper.shared.desktopMain" />
</root>
</artifact>
</component>

View file

@ -1,6 +1,22 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="java.util" alias="false" withSubpackages="false" />
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
<package name="java" alias="false" withSubpackages="true" />
<package name="javax" alias="false" withSubpackages="true" />
<package name="kotlin" alias="false" withSubpackages="true" />
<package name="" alias="true" withSubpackages="true" />
</value>
</option>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
<bytecodeTargetLevel target="1.8" />
</component>
</project>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State />
</entry>
</value>
</component>
</project>

View file

@ -4,18 +4,18 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="testRunner" value="PLATFORM" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/desktop" />
<option value="$PROJECT_DIR$/shared" />
<option value="$PROJECT_DIR$/piholeclient" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
<option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings>
</option>
</component>

View file

@ -1,41 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

View file

@ -21,20 +21,5 @@
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://s01.oss.sonatype.org/content/repositories/snapshots/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://maven.pkg.jetbrains.space/public/p/compose/dev" />
</remote-repository>
</component>
</project>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinScriptingSettings">
<scriptDefinition className="org.jetbrains.kotlin.scripting.resolve.KotlinScriptDefinitionFromAnnotatedTemplate" definitionName="KotlinBuildScript">
<order>2147483647</order>
<autoReloadConfigurations>true</autoReloadConfigurations>
</scriptDefinition>
</component>
</project>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.21" />
</component>
</project>

View file

@ -5,67 +5,7 @@
<configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" />
</configurations>
</component>
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="../../../../layout/compose-model-1622823449309.xml" value="0.1523829431438127" />
<entry key="../../../../layout/compose-model-1622824814438.xml" value="0.33" />
<entry key="../../../../layout/compose-model-1622917622395.xml" value="0.2170608108108108" />
<entry key="../../../../layout/compose-model-1623014109717.xml" value="0.337037037037037" />
<entry key="../../../../layout/compose-model-1623014799493.xml" value="0.10015558148580318" />
<entry key="../../../../layout/compose-model-1623015496605.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623015889966.xml" value="0.1072324414715719" />
<entry key="../../../../layout/compose-model-1623016793844.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623332506723.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623332556375.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623353007224.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623353013891.xml" value="2.0" />
<entry key="../../../../layout/compose-model-1623358629334.xml" value="0.10618436406067679" />
<entry key="../../../../layout/compose-model-1623358815178.xml" value="1.6830769230769231" />
<entry key="../../../../layout/compose-model-1623359670724.xml" value="0.11413043478260869" />
<entry key="../../../../layout/compose-model-1623360495184.xml" value="0.20880752102919348" />
<entry key="../../../../layout/compose-model-1646330539642.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1646331413004.xml" value="0.4506079027355623" />
<entry key="../../../../layout/compose-model-1646332602415.xml" value="0.13775083612040134" />
<entry key="../../../../layout/compose-model-1646358533232.xml" value="0.30153061224489797" />
<entry key="../../../../layout/compose-model-1647060386627.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1647060537103.xml" value="0.12374581939799331" />
<entry key="../../../../layout/compose-model-1647101512079.xml" value="0.12332775919732442" />
<entry key="../../../../layout/compose-model-1647102165810.xml" value="0.1" />
<entry key="app/src/main/res/drawable-v26/ic_shortcut_enable.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable-v26/ic_shortcut_pause.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/background_splash.xml" value="0.409375" />
<entry key="app/src/main/res/drawable/horizontal_rule.xml" value="0.1" />
<entry key="app/src/main/res/drawable/ic_app_logo.xml" value="0.1065" />
<entry key="app/src/main/res/drawable/ic_launcher_background.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/ic_pause.xml" value="0.409375" />
<entry key="app/src/main/res/drawable/ic_play.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/ic_play_arrow.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/ic_settings.xml" value="0.409375" />
<entry key="app/src/main/res/drawable/ic_shortcut_background.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/ic_shortcut_enable.xml" value="0.409375" />
<entry key="app/src/main/res/drawable/ic_shortcut_pause.xml" value="0.409375" />
<entry key="app/src/main/res/layout/activity_main.xml" value="0.16111111111111112" />
<entry key="app/src/main/res/layout/dialog_disable_custom_time.xml" value="0.22708333333333333" />
<entry key="app/src/main/res/layout/fragment_add_pi_hole.xml" value="0.3493975903614458" />
<entry key="app/src/main/res/layout/fragment_info.xml" value="0.24010416666666667" />
<entry key="app/src/main/res/layout/fragment_main.xml" value="0.14175724637681159" />
<entry key="app/src/main/res/layout/fragment_retrieve_api_key.xml" value="0.24010416666666667" />
<entry key="app/src/main/res/layout/or_divider.xml" value="0.17147922998986828" />
<entry key="app/src/main/res/menu/main.xml" value="0.3932291666666667" />
</map>
</option>
</component>
<component name="EntryPointsManager">
<list size="1">
<item index="0" class="java.lang.String" itemvalue="dagger.hilt.android.testing.BindValue" />
</list>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>

View file

@ -1,124 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Palette2">
<group name="Swing">
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
</item>
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
</item>
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
</item>
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
<initial-values>
<property name="text" value="Button" />
</initial-values>
</item>
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="RadioButton" />
</initial-values>
</item>
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="CheckBox" />
</initial-values>
</item>
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
<initial-values>
<property name="text" value="Label" />
</initial-values>
</item>
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
</item>
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
</item>
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
<preferred-size width="-1" height="20" />
</default-constraints>
</item>
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
</item>
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
</item>
</group>
</component>
</project>

View file

@ -2,6 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/desktop" vcs="Git" />
</component>
</project>

75
app/build.gradle Normal file
View file

@ -0,0 +1,75 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
def keystoreProperties = new Properties()
try {
def keystorePropertiesFile = rootProject.file("keystore.properties")
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
} catch (FileNotFoundException ignored) {
logger.warn("Unable to load keystore properties. Using debug signing configuration instead")
keystoreProperties['keyAlias'] = "androiddebugkey"
keystoreProperties['keyPassword'] = "android"
keystoreProperties['storeFile'] = new File(System.getProperty("user.home"), ".android/debug.keystore").absolutePath
keystoreProperties['storePassword'] = "android"
}
android {
compileSdkVersion 29
defaultConfig {
applicationId "com.wbrawner.pihelper"
minSdkVersion 23
targetSdkVersion 29
versionCode 2
versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
signingConfig signingConfigs.debug
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation project(':piholeclient')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
implementation "org.koin:koin-androidx-viewmodel:$koin_version"
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'com.google.android.material:material:1.3.0-alpha04'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.security:security-crypto:1.0.0-rc01'
implementation 'androidx.preference:preference-ktx:1.1.1'
testImplementation 'junit:junit:4.13.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
def navigation_version = '2.3.2'
implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version"
implementation "androidx.navigation:navigation-ui-ktx:$navigation_version"
def lifecycle_version = '2.2.0'
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
}

View file

@ -1,100 +0,0 @@
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.util.*
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.hilt.android)
alias(libs.plugins.compose)
}
val keystoreProperties = Properties()
try {
val keystorePropertiesFile = rootProject.file("keystore.properties")
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
} catch (ignored: FileNotFoundException) {
logger.warn("Unable to load keystore properties. Using debug signing configuration instead")
keystoreProperties["keyAlias"] = "androiddebugkey"
keystoreProperties["keyPassword"] = "android"
keystoreProperties["storeFile"] =
File(System.getProperty("user.home"), ".android/debug.keystore").absolutePath
keystoreProperties["storePassword"] = "android"
}
android {
compileSdk = libs.versions.maxSdk.get().toInt()
defaultConfig {
applicationId = "com.wbrawner.pihelper"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.maxSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
testInstrumentationRunner = "com.wbrawner.pihelper.util.HiltTestRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
signingConfig = signingConfigs["debug"]
}
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"].toString()
keyPassword = keystoreProperties["keyPassword"].toString()
storeFile = file(keystoreProperties["storeFile"].toString())
storePassword = keystoreProperties["storePassword"].toString()
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs["release"]
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
namespace = "com.wbrawner.pihelper"
}
dependencies {
implementation(project(":shared"))
implementation(libs.bundles.coroutines)
implementation(libs.bundles.compose)
implementation(libs.hilt.android.core)
implementation(libs.hilt.navigation.compose)
ksp(libs.hilt.android.ksp)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.splash)
implementation(libs.material)
implementation("androidx.legacy:legacy-support-v4:1.0.0")
implementation("androidx.security:security-crypto:1.0.0")
implementation(libs.preference)
testImplementation(libs.junit)
androidTestImplementation(libs.mockwebserver)
androidTestImplementation(libs.androidx.test.runner)
androidTestUtil(libs.androidx.test.orchestrator)
androidTestImplementation(libs.test.ext)
androidTestImplementation(libs.espresso)
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.android.ksp)
androidTestImplementation(libs.compose.test.junit)
debugImplementation(libs.compose.test.manifest)
}

View file

@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

View file

@ -1,3 +0,0 @@
{
"status": "disabled"
}

View file

@ -1,3 +0,0 @@
{
"status": "enabled"
}

View file

@ -1,38 +0,0 @@
{
"domains_being_blocked": 166010,
"dns_queries_today": 71567,
"ads_blocked_today": 37254,
"ads_percentage_today": 52.054718,
"unique_domains": 17559,
"queries_forwarded": 29352,
"queries_cached": 4864,
"clients_ever_seen": 6,
"unique_clients": 3,
"dns_queries_all_types": 71567,
"reply_UNKNOWN": 174,
"reply_NODATA": 7023,
"reply_NXDOMAIN": 2006,
"reply_CNAME": 13863,
"reply_IP": 47973,
"reply_DOMAIN": 48,
"reply_RRNAME": 0,
"reply_SERVFAIL": 0,
"reply_REFUSED": 0,
"reply_NOTIMP": 0,
"reply_OTHER": 0,
"reply_DNSSEC": 0,
"reply_NONE": 0,
"reply_BLOB": 480,
"dns_queries_all_replies": 71567,
"privacy_level": 0,
"status": "disabled",
"gravity_last_updated": {
"file_exists": true,
"absolute": 1671361515,
"relative": {
"days": 3,
"hours": 5,
"minutes": 23
}
}
}

View file

@ -1,38 +0,0 @@
{
"domains_being_blocked": 166010,
"dns_queries_today": 71567,
"ads_blocked_today": 37254,
"ads_percentage_today": 52.054718,
"unique_domains": 17559,
"queries_forwarded": 29352,
"queries_cached": 4864,
"clients_ever_seen": 6,
"unique_clients": 3,
"dns_queries_all_types": 71567,
"reply_UNKNOWN": 174,
"reply_NODATA": 7023,
"reply_NXDOMAIN": 2006,
"reply_CNAME": 13863,
"reply_IP": 47973,
"reply_DOMAIN": 48,
"reply_RRNAME": 0,
"reply_SERVFAIL": 0,
"reply_REFUSED": 0,
"reply_NOTIMP": 0,
"reply_OTHER": 0,
"reply_DNSSEC": 0,
"reply_NONE": 0,
"reply_BLOB": 480,
"dns_queries_all_replies": 71567,
"privacy_level": 0,
"status": "enabled",
"gravity_last_updated": {
"file_exists": true,
"absolute": 1671361515,
"relative": {
"days": 3,
"hours": 5,
"minutes": 23
}
}
}

View file

@ -1,56 +0,0 @@
{
"top_queries": {
"gateway.fe.apple-dns.net": 647,
"lb._dns-sd._udp.0.1.168.192.in-addr.arpa": 390,
"github.com": 385,
"www.belkin.com": 305,
"ip.wbrawner.com": 294,
"mask.icloud.com": 292,
"23.1.168.192.in-addr.arpa": 245,
"weather-data.apple.com": 232,
"www.google.com": 224,
"ocsp2-lb.apple.com.akadns.net": 202,
"www.googleapis.com": 198,
"e673.dsce9.akamaiedge.net": 196,
"domains.google.com": 194,
"c1187123150.ip4-d1b183ab.saasprotection.com": 183,
"googlehosted.l.googleusercontent.com": 177,
"_dns.resolver.arpa": 177,
"www.netgear.com": 175,
"dns.google": 174,
"play.googleapis.com": 170,
"ocsp2.g.aaplimg.com": 167,
"time.g.aaplimg.com": 161,
"userproxypac.aexp.com": 161,
"www.gstatic.com": 160,
"nrdp.prod.cloud.netflix.com": 155,
"mdw-efz.ms-acdc.office.com": 152
},
"top_ads": {
"api2.branch.io": 4173,
"cws.conviva.com": 3019,
"scribe.logs.roku.com": 1119,
"mobile.pipe.aria.microsoft.com": 1083,
"ssl.google-analytics.com": 526,
"data.emb-api.com": 325,
"api.bugfender.com": 200,
"googleads.g.doubleclick.net": 174,
"app-measurement.com": 157,
"config.emb-api.com": 154,
"fls-na.amazon.com": 125,
"s.youtube.com": 109,
"device-api.urbanairship.com": 106,
"mobile-collector.newrelic.com": 95,
"incoming.telemetry.mozilla.org": 93,
"metrics.icloud.com": 74,
"www.googleadservices.com": 63,
"nova.collect.igodigital.com": 57,
"www.googletagmanager.com": 51,
"app.adjust.com": 43,
"app.adjust.net.in": 43,
"app.adjust.world": 43,
"iadsdk.apple.com": 41,
"api.apptentive.com": 41,
"adservice.google.com": 39
}
}

View file

@ -1,3 +0,0 @@
{
"version": 3
}

View file

@ -1,91 +0,0 @@
package com.wbrawner.pihelper
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.State
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.util.FakeAPIService
import com.wbrawner.pihelper.util.onMainScreen
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import dagger.hilt.components.SingletonComponent
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@UninstallModules(PiHelperModule::class)
@HiltAndroidTest
@OptIn(ExperimentalAnimationApi::class)
class MainTests {
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this)
@BindValue
@JvmField
val apiService: FakeAPIService = FakeAPIService()
@Test
fun testDisable() {
onMainScreen(composeTestRule) {
apiService.statusEnabled(context)
apiService.disableSuccess(context)
apiService.statusDisabled(context)
apiService.disabledPermanently()
verifyStatus("Enabled")
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("auth") == true)
clickDisablePermanentlyButton()
verifyStatus("Disabled")
}
}
@Test
fun testEnable() {
onMainScreen(composeTestRule) {
apiService.statusDisabled(context)
apiService.disabledPermanently()
apiService.enableSuccess(context)
apiService.statusEnabled(context)
verifyStatus("Disabled")
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("auth") == true)
clickEnableButton()
verifyStatus("Enabled")
}
}
@Module
@InstallIn(SingletonComponent::class)
inner class TestModule {
@Provides
@Singleton
fun providesPiholeAPIService(): PiholeAPIService = apiService
@Provides
@Singleton
fun providesInitialState(apiService: FakeAPIService): State = State(
apiKey = "key",
host = "${apiService.hostName}:${apiService.port}"
)
@Provides
@Singleton
fun providesStore(
apiService: PiholeAPIService,
initialState: State
): Store = Store(apiService, initialState = initialState)
}
}

View file

@ -1,153 +0,0 @@
package com.wbrawner.pihelper
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.util.FakeAPIService
import com.wbrawner.pihelper.util.onAddScreen
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import dagger.hilt.components.SingletonComponent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@UninstallModules(PiHelperModule::class)
@HiltAndroidTest
@OptIn(ExperimentalAnimationApi::class)
class SetupTests {
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this)
@BindValue
@JvmField
val apiService: FakeAPIService = FakeAPIService()
@Test
fun testSuccessfulConnectionWithPassword() {
onAddScreen(composeTestRule) {
apiService.testConnectionSuccess()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
apiService.server.takeRequest(1, TimeUnit.SECONDS)
} onAuthScreen {
apiService.authenticationSuccess(context)
verifyConnectionSuccessMessage()
inputPassword("password")
clickAuthenticateWithPassword()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("topItems") ?: false)
assertEquals(
"113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3",
request?.requestUrl?.queryParameter("auth")
)
}
}
@Test
fun testSuccessfulConnectionWithAPIKey() {
onAddScreen(composeTestRule) {
apiService.testConnectionSuccess()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
apiService.server.takeRequest(1, TimeUnit.SECONDS)
} onAuthScreen {
apiService.authenticationSuccess(context)
verifyConnectionSuccessMessage()
inputAPIKey("113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3")
clickAuthenticateWithAPIKey()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("topItems") ?: false)
assertEquals(
"113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3",
request?.requestUrl?.queryParameter("auth")
)
}
}
@Test
fun testFailedConnection() {
onAddScreen(composeTestRule) {
clearHost()
inputHost("localhost")
clickConnect()
verifyErrorMessageIsDisplayed("Failed to connect")
}
}
@Test
fun testInvalidHost() {
onAddScreen(composeTestRule) {
apiService.testConnectionFailure()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
verifyErrorMessageIsDisplayed("Host does not appear to be a valid Pi-hole")
}
}
@Test
fun testFailedAuthenticationWithAPIKey() {
onAddScreen(composeTestRule) {
apiService.testConnectionSuccess()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
} onAuthScreen {
apiService.authenticationFailure(context)
verifyConnectionSuccessMessage()
inputAPIKey("113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3")
clickAuthenticateWithAPIKey()
verifyErrorMessageIsDisplayed("Invalid credentials")
}
}
@Test
fun testFailedAuthenticationWithPassword() {
onAddScreen(composeTestRule) {
apiService.testConnectionSuccess()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
} onAuthScreen {
verifyConnectionSuccessMessage()
apiService.authenticationFailure(context)
inputPassword("password")
clickAuthenticateWithPassword()
verifyErrorMessageIsDisplayed("Invalid credentials")
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class TestModule {
@Binds
@Singleton
abstract fun bindsPiholeAPIService(apiService: FakeAPIService): PiholeAPIService
companion object {
@Provides
@Singleton
fun providesStore(
apiService: PiholeAPIService
): Store = Store(apiService)
}
}
}

View file

@ -1,33 +0,0 @@
package com.wbrawner.pihelper.util
import android.content.Context
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.test.platform.app.InstrumentationRegistry
fun onAddScreen(testRule: ComposeTestRule, actions: AddScreenRobot.() -> Unit) =
AddScreenRobot(testRule).apply { actions() }
class AddScreenRobot(private val testRule: ComposeTestRule) {
val context: Context = InstrumentationRegistry.getInstrumentation().context
infix fun onAuthScreen(actions: AuthScreenRobot.() -> Unit) = AuthScreenRobot(testRule).run {
actions()
}
fun clearHost() =
testRule.onNodeWithContentDescription("Pi-hole host input").performTextClearance()
fun inputHost(host: String) =
testRule.onNodeWithContentDescription("Pi-hole host input").performTextInput(host)
fun clickConnect() = testRule.onNode(hasText("Connect")).performClick()
fun verifyErrorMessageIsDisplayed(message: String) {
testRule.waitUntil(2_000) {
testRule
.onAllNodesWithContentDescription(message, substring = true)
.fetchSemanticsNodes().size == 1
}
}
}

View file

@ -1,44 +0,0 @@
package com.wbrawner.pihelper.util
import android.content.Context
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.test.platform.app.InstrumentationRegistry
import com.wbrawner.pihelper.shared.ui.*
class AuthScreenRobot(private val testRule: ComposeTestRule) {
val context: Context = InstrumentationRegistry.getInstrumentation().context
init {
testRule.waitUntil {
testRule
.onAllNodesWithTag(AUTH_SCREEN_TAG)
.fetchSemanticsNodes().size == 1
}
}
fun verifyConnectionSuccessMessage() =
testRule.onNode(hasTestTag(SUCCESS_TEXT_TAG)).assertExists()
fun inputPassword(password: String) =
testRule.onNode(hasTestTag(PASSWORD_INPUT_TAG)).performTextInput(password)
fun clickAuthenticateWithPassword() = testRule.onNode(hasTestTag(PASSWORD_BUTTON_TAG))
.performScrollTo()
.performClick()
fun inputAPIKey(key: String) =
testRule.onNode(hasTestTag(API_KEY_INPUT_TAG)).performTextInput(key)
fun clickAuthenticateWithAPIKey() = testRule.onNode(hasTestTag(API_KEY_BUTTON_TAG))
.performScrollTo()
.performClick()
fun verifyErrorMessageIsDisplayed(message: String) {
testRule.waitUntil(2_000) {
testRule
.onAllNodesWithText(message, substring = true)
.fetchSemanticsNodes().size == 1
}
}
}

View file

@ -1,100 +0,0 @@
package com.wbrawner.pihelper.util
import android.content.Context
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.create
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
class FakeAPIService : PiholeAPIService by PiholeAPIService.create() {
val server = MockWebServer().apply {
start()
}
val hostName: String = server.hostName
val port = server.port
fun testConnectionSuccess() {
server.enqueue(
MockResponse().setHeader(
"X-Pi-Hole",
"The Pi-hole Web interface is working!"
)
)
}
fun testConnectionFailure() {
server.enqueue(MockResponse().setResponseCode(204))
}
fun authenticationSuccess(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/top_items_success.json")
)
)
}
fun authenticationFailure(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/top_items_failure.json")
)
)
}
fun statusEnabled(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/summary_enabled.json")
)
)
}
fun statusDisabled(context: Context, duration: Long? = null) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/summary_disabled.json")
)
)
}
fun enableSuccess(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/status_enabled.json")
)
)
}
fun disableSuccess(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/status_disabled.json")
)
)
}
fun disabledPermanently() {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "text/html")
.setBody("<p>Definitely not a number</p>")
)
}
}
private fun Context.readAsset(path: String) = assets.open(path)
.bufferedReader()
.use { it.readText() }

View file

@ -1,13 +0,0 @@
package com.wbrawner.pihelper.util
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
@Suppress("unused")
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

View file

@ -1,50 +0,0 @@
package com.wbrawner.pihelper.util
import android.content.Context
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.test.platform.app.InstrumentationRegistry
import com.wbrawner.pihelper.shared.ui.DISABLE_PERMANENT_BUTTON_TAG
import com.wbrawner.pihelper.shared.ui.ENABLE_BUTTON_TAG
import com.wbrawner.pihelper.shared.ui.MAIN_SCREEN_TAG
import com.wbrawner.pihelper.shared.ui.STATUS_TEXT_TAG
fun onMainScreen(testRule: ComposeTestRule, actions: MainScreenRobot.() -> Unit) =
MainScreenRobot(testRule).apply { actions() }
class MainScreenRobot(private val testRule: ComposeTestRule) {
val context: Context = InstrumentationRegistry.getInstrumentation().context
init {
testRule.waitUntil {
testRule
.onAllNodesWithTag(MAIN_SCREEN_TAG)
.fetchSemanticsNodes().size == 1
}
}
infix fun onSettingsScreen(actions: AuthScreenRobot.() -> Unit) = AuthScreenRobot(testRule)
.run {
actions()
}
fun verifyStatus(status: String) {
testRule.waitUntil {
testRule.onAllNodes(hasTestTag(STATUS_TEXT_TAG).and(hasText(status)))
.fetchSemanticsNodes().size == 1
}
}
fun clickEnableButton() = testRule.onNode(hasTestTag(ENABLE_BUTTON_TAG)).performClick()
fun clickDisablePermanentlyButton() = testRule.onNode(hasTestTag(DISABLE_PERMANENT_BUTTON_TAG))
.performClick()
fun verifyErrorMessageIsDisplayed(message: String) {
testRule.waitUntil(2_000) {
testRule
.onAllNodesWithText(message, substring = true)
.fetchSemanticsNodes().size == 1
}
}
}

View file

@ -1,2 +0,0 @@
package com.wbrawner.pihelper.util

View file

@ -1,23 +1,21 @@
<?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"
package="com.wbrawner.pihelper">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".PiHelperApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_descriptor"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting">
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View file

@ -0,0 +1,136 @@
package com.wbrawner.pihelper
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.piholeclient.PiHoleApiService
import com.wbrawner.piholeclient.VersionResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.ConnectException
import java.net.SocketTimeoutException
const val KEY_BASE_URL = "baseUrl"
const val KEY_API_KEY = "apiKey"
const val IP_MIN = 0
const val IP_MAX = 255
class AddPiHelperViewModel(
private val sharedPreferences: SharedPreferences,
private val apiService: PiHoleApiService
) : ViewModel() {
@Volatile
var baseUrl: String? = sharedPreferences.getString(KEY_BASE_URL, null)
set(value) {
sharedPreferences.edit {
putString(KEY_BASE_URL, value)
}
field = value
}
@Volatile
var apiKey: String? = sharedPreferences.getString(KEY_API_KEY, null)
set(value) {
sharedPreferences.edit {
putString(KEY_API_KEY, value)
}
field = value
}
init {
apiService.baseUrl = this.baseUrl
apiService.apiKey = this.apiKey
}
val piHoleIpAddress = MutableLiveData<String?>()
val scanningIp = MutableLiveData<String?>()
val authenticated = MutableLiveData<Boolean>()
suspend fun beginScanning(deviceIpAddress: String) {
val addressParts = deviceIpAddress.split(".").toMutableList()
var chunks = 1
// If the Pi-hole is correctly set up, then there should be a special host for it as
// "pi.hole"
val ipAddresses = mutableListOf("pi.hole")
while (chunks <= IP_MAX) {
val chunkSize = (IP_MAX - IP_MIN + 1) / chunks
if (chunkSize == 1) {
return
}
for (chunk in 0 until chunks) {
val chunkStart = IP_MIN + (chunk * chunkSize)
val chunkEnd = IP_MIN + ((chunk + 1) * chunkSize)
addressParts[3] = (((chunkEnd - chunkStart) / 2) + chunkStart).toString()
ipAddresses.add(addressParts.joinToString("."))
}
chunks *= 2
}
scan(ipAddresses)
}
private suspend fun scan(ipAddresses: MutableList<String>) {
if (ipAddresses.isEmpty()) {
scanningIp.postValue(null)
piHoleIpAddress.postValue(null)
return
}
val ipAddress = ipAddresses.removeAt(0)
scanningIp.postValue(ipAddress)
if (!connectToIpAddress(ipAddress)) {
scan(ipAddresses)
}
}
suspend fun connectToIpAddress(ipAddress: String): Boolean {
val version: VersionResponse? = withContext(Dispatchers.IO) {
try {
apiService.baseUrl = ipAddress
apiService.getVersion()
} catch (ignored: ConnectException) {
null
} catch (ignored: SocketTimeoutException) {
null
} catch (e: Exception) {
Log.e("Pi-helper", "Failed to load Pi-Hole version at $ipAddress", e)
null
}
}
return if (version == null) {
false
} else {
piHoleIpAddress.postValue(ipAddress)
baseUrl = ipAddress
true
}
}
suspend fun authenticateWithPassword(password: String) {
// The Pi-hole API key is just the web password hashed twice with SHA-256
authenticateWithApiKey(password.hash().hash())
}
suspend fun authenticateWithApiKey(apiKey: String) {
// This uses the topItems endpoint to test that the API key is working since it requires
// authentication and is fairly simple to determine whether or not the request was
// successful
apiService.apiKey = apiKey
try {
apiService.getTopItems()
this.apiKey = apiKey
authenticated.postValue(true)
} catch (e: Exception) {
Log.e("Pi-helper", "Unable to authenticate with API key", e)
authenticated.postValue(false)
throw e
}
}
fun forgetPihole() {
baseUrl = null
apiKey = null
}
}

View file

@ -0,0 +1,95 @@
package com.wbrawner.pihelper
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.navigation.fragment.findNavController
import com.wbrawner.pihelper.databinding.FragmentAddPiHoleBinding
import org.koin.android.ext.android.inject
class AddPiHoleFragment : Fragment() {
private val viewModel: AddPiHelperViewModel by inject()
private var _binding: FragmentAddPiHoleBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentAddPiHoleBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val navController = findNavController()
binding.scanNetworkButton.setOnClickListener {
navController.navigate(
R.id.action_addPiHoleFragment_to_scanNetworkFragment,
null,
null,
FragmentNavigatorExtras(binding.piHelperLogo to "piHelperLogo")
)
}
binding.ipAddress.setOnEditorActionListener { _, _, _ ->
binding.connectButton.performClick()
}
binding.connectButton.setSuspendingOnClickListener(lifecycleScope) {
showProgress(true)
if (viewModel.connectToIpAddress(binding.ipAddress.text.toString())) {
navController.navigate(
R.id.action_addPiHoleFragment_to_retrieveApiKeyFragment,
null,
null,
FragmentNavigatorExtras(binding.piHelperLogo to "piHelperLogo")
)
} else {
AlertDialog.Builder(view.context)
.setTitle(R.string.connection_failed_title)
.setMessage(R.string.connection_failed)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.show()
}
showProgress(false)
}
}
private fun showProgress(show: Boolean) {
if (show) {
binding.piHelperLogo.startAnimation(
RotateAnimation(
0f,
360f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
).apply {
duration =
resources.getInteger(android.R.integer.config_longAnimTime).toLong() * 2
repeatMode = Animation.RESTART
repeatCount = Animation.INFINITE
interpolator = LinearInterpolator()
fillAfter = true
}
)
} else {
binding.piHelperLogo.clearAnimation()
}
binding.connectionForm.visibility = if (show) View.GONE else View.VISIBLE
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View file

@ -1,84 +0,0 @@
package com.wbrawner.pihelper
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.shared.ui.AddScreen
import com.wbrawner.pihelper.shared.ui.OrDivider
import com.wbrawner.pihelper.shared.ui.theme.PihelperTheme
import java.net.Inet4Address
val emulatorBuildModels = listOf(
"Android SDK built for x86",
"sdk_gphone64_arm64"
)
@Composable
fun AddScreen(store: Store) {
val state by store.state.collectAsState()
val effect by store.effects.collectAsState(initial = Effect.Empty)
val context = LocalContext.current
AddScreen(
scanNetwork = scan@{
// TODO: This needs to go in the Store
if (BuildConfig.DEBUG && emulatorBuildModels.contains(Build.MODEL)) {
// For emulators, just begin scanning the host machine directly
store.dispatch(Action.Scan("10.0.2.2"))
return@scan
}
(context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)
?.let { connectivityManager ->
connectivityManager.allNetworks
.filter {
connectivityManager.getNetworkCapabilities(it)
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}
.mapNotNull { network ->
connectivityManager.getLinkProperties(network)
?.linkAddresses
?.filter {
!it.address.isLoopbackAddress
&& !it.address.isLinkLocalAddress
&& it.address is Inet4Address
}
?.mapNotNull { it.address.hostAddress }
?.forEach {
store.dispatch(Action.Scan(it))
}
}
}
?: Toast.makeText(context, "Failed to scan network", Toast.LENGTH_SHORT).show()
},
connectToPihole = {
store.dispatch(Action.Connect(it))
},
state.loading,
error = effect as? Effect.Error
)
}
@Composable
@DayNightPreview
fun AddScreen_Preview() {
PihelperTheme {
AddScreen({}, {}, error = Effect.Error("Something bad happened"))
}
}
@Composable
@DayNightPreview
fun OrDivider_Preview() {
PihelperTheme {
OrDivider()
}
}

View file

@ -1,8 +0,0 @@
package com.wbrawner.pihelper
import android.content.res.Configuration
import androidx.compose.ui.tooling.preview.Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
annotation class DayNightPreview

View file

@ -0,0 +1,24 @@
package com.wbrawner.pihelper
import android.view.View
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.math.BigInteger
import java.security.MessageDigest
fun String.hash(): String = BigInteger(
1,
MessageDigest.getInstance("SHA-256").digest(this.toByteArray())
).toString(16).padStart(64, '0')
fun CoroutineScope.cancel() {
coroutineContext[Job]?.cancel()
}
fun View.setSuspendingOnClickListener(
coroutineScope: CoroutineScope,
clickListener: suspend (v: View) -> Unit
) = setOnClickListener { v ->
coroutineScope.launch { clickListener(v) }
}

View file

@ -0,0 +1,85 @@
package com.wbrawner.pihelper
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.wbrawner.pihelper.MainActivity.Companion.ACTION_FORGET_PIHOLE
import com.wbrawner.pihelper.databinding.FragmentInfoBinding
import org.koin.android.ext.android.inject
class InfoFragment : Fragment() {
private val viewModel: AddPiHelperViewModel by inject()
private var _binding: FragmentInfoBinding? = null
private val binding get() = _binding!!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentInfoBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
(activity as? AppCompatActivity)?.supportActionBar?.setDisplayHomeAsUpEnabled(true)
(activity as? AppCompatActivity)?.supportActionBar?.setTitle(R.string.action_settings)
val html = getString(R.string.content_info)
@Suppress("DEPRECATION")
binding.infoContent.text = if (Build.VERSION.SDK_INT < 24)
Html.fromHtml(html)
else
Html.fromHtml(html, 0)
binding.infoContent.movementMethod = LinkMovementMethod.getInstance()
binding.forgetPiHoleButton.setOnClickListener {
AlertDialog.Builder(view.context)
.setTitle(R.string.confirm_forget_pihole)
.setMessage(R.string.warning_cannot_be_undone)
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.action_forget_pihole) { _, _ ->
viewModel.forgetPihole()
val refreshIntent = Intent(
view.context.applicationContext,
MainActivity::class.java
).apply {
action = ACTION_FORGET_PIHOLE
addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TASK
and Intent.FLAG_ACTIVITY_NEW_TASK
)
}
activity?.startActivity(refreshIntent)
}
.show()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
findNavController().navigateUp()
return true
}
return super.onOptionsItemSelected(item)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View file

@ -1,140 +1,89 @@
package com.wbrawner.pihelper
import android.animation.ObjectAnimator
import android.os.Build
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.View
import android.view.animation.AnticipateInterpolator
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.animation.doOnEnd
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Route
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.shared.ui.AuthScreen
import com.wbrawner.pihelper.shared.ui.InfoScreen
import com.wbrawner.pihelper.shared.ui.MainScreen
import com.wbrawner.pihelper.shared.ui.component.LoadingSpinner
import com.wbrawner.pihelper.shared.ui.theme.PihelperTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import androidx.navigation.NavController
import androidx.navigation.findNavController
import com.wbrawner.pihelper.MainFragment.Companion.ACTION_DISABLE
import com.wbrawner.pihelper.MainFragment.Companion.ACTION_ENABLE
import com.wbrawner.pihelper.MainFragment.Companion.EXTRA_DURATION
import com.wbrawner.pihelper.databinding.ActivityMainBinding
import org.koin.android.ext.android.inject
@ExperimentalAnimationApi
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var store: Store
private val addPiHoleViewModel: AddPiHelperViewModel by inject()
private val navController: NavController by lazy {
findNavController(R.id.content_main)
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
BackHandler {
store.dispatch(Action.Back)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
window.setBackgroundDrawable(ColorDrawable(getColor(R.color.colorSurface)))
val args = when (intent.action) {
ACTION_ENABLE -> {
if (addPiHoleViewModel.apiKey == null) {
Toast.makeText(this, R.string.configure_pihelper, Toast.LENGTH_SHORT).show()
null
} else {
Bundle().apply { putBoolean(ACTION_ENABLE, true) }
}
}
val launchIntent = remember { intent }
LaunchedEffect(launchIntent) {
ShortcutActions.fromIntentAction(launchIntent.action)?.let { action ->
if (action == ShortcutActions.DISABLE) {
val duration = launchIntent.getIntExtra(DURATION, 0)
store.dispatch(Action.Disable(duration.toLong()))
} else {
store.dispatch(Action.Enable)
ACTION_DISABLE -> {
if (addPiHoleViewModel.apiKey == null) {
Toast.makeText(this, R.string.configure_pihelper, Toast.LENGTH_SHORT).show()
null
} else {
Bundle().apply {
putBoolean(ACTION_DISABLE, true)
putLong(EXTRA_DURATION, intent.getIntExtra(EXTRA_DURATION, 10).toLong())
}
}
}
val state by store.state.collectAsState()
val navController = rememberNavController()
LaunchedEffect(state.route) {
navController.navigate(state.route.name)
}
val effect by store.effects.collectAsState(initial = Effect.Empty)
LaunchedEffect(effect) {
when (effect) {
is Effect.Exit -> finish()
else -> {
// no-op
ACTION_FORGET_PIHOLE -> {
if (intent.component?.packageName == packageName) {
while (navController.popBackStack()) {
// Do nothing, just pop all the items off the back stack
}
// Just return an empty bundle so that the navigation branch below will load
// the correct screen
Bundle()
} else {
null
}
}
PihelperTheme {
NavHost(navController, startDestination = state.initialRoute.name) {
composable(Route.CONNECT.name) {
AddScreen(store)
}
composable(Route.SCAN.name) {
ScanScreen(store)
}
composable(Route.AUTH.name) {
AuthScreen(store)
}
composable(Route.HOME.name) {
MainScreen(store)
}
composable(Route.ABOUT.name) {
InfoScreen(store)
}
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { splashScreenView ->
listOf(View.SCALE_X, View.SCALE_Y).forEach { axis ->
ObjectAnimator.ofFloat(
splashScreenView,
axis,
1f,
0.45f
).apply {
interpolator = AnticipateInterpolator()
duration = 200L
doOnEnd {
splashScreenView.remove()
}
start()
}
}
}
}
}
}
@Composable
@Preview
fun LoadingSpinner_Preview() {
LoadingSpinner()
}
enum class ShortcutActions(val fullName: String) {
ENABLE("com.wbrawner.pihelper.ShortcutActions.ENABLE"),
DISABLE("com.wbrawner.pihelper.ShortcutActions.DISABLE");
companion object {
fun fromIntentAction(action: String?): ShortcutActions? = when (action) {
ENABLE.fullName -> ENABLE
DISABLE.fullName -> DISABLE
else -> null
}
}
}
const val DURATION: String = "com.wbrawner.pihelper.MainActivityKt.DURATION"
@Composable
@DayNightPreview
fun InfoScreen_Preview() {
PihelperTheme {
InfoScreen({}, {})
when {
navController.currentDestination?.id != R.id.placeholder && args == null -> {
return
}
addPiHoleViewModel.baseUrl.isNullOrBlank() -> {
navController.navigate(R.id.addPiHoleFragment, args)
}
addPiHoleViewModel.apiKey.isNullOrBlank() -> {
navController.navigate(R.id.addPiHoleFragment)
navController.navigate(R.id.retrieveApiKeyFragment, args)
}
else -> {
navController.navigate(R.id.mainFragment, args)
}
}
}
override fun onBackPressed() {
if (!navController.navigateUp()) {
finish()
}
if (navController.currentDestination?.id == R.id.placeholder) {
finish()
}
}
companion object {
const val ACTION_FORGET_PIHOLE = "com.wbrawner.pihelper.ACTION_FORGET_PIHOLE"
}
}

View file

@ -0,0 +1,214 @@
package com.wbrawner.pihelper
import android.graphics.Typeface
import android.os.Bundle
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Log
import android.view.*
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat.getColor
import androidx.core.text.set
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.wbrawner.pihelper.databinding.DialogDisableCustomTimeBinding
import com.wbrawner.pihelper.databinding.FragmentMainBinding
import com.wbrawner.piholeclient.Status
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
class MainFragment : Fragment() {
private val viewModel: PiHelperViewModel by inject()
private var _binding: FragmentMainBinding? = null
private val binding get() = _binding!!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
lifecycleScope.launch {
if (arguments?.getBoolean(ACTION_ENABLE) == true) {
viewModel.enablePiHole()
} else if (arguments?.getBoolean(ACTION_DISABLE) == true) {
viewModel.disablePiHole(arguments?.getLong(EXTRA_DURATION))
}
viewModel.monitorSummary()
}
viewModel.status.observe(this, {
showProgress(false)
val (statusColor, statusText) = when (it) {
Status.DISABLED -> {
binding.enableButton.visibility = View.VISIBLE
binding.disableButtons.visibility = View.GONE
Pair(R.color.colorDisabled, R.string.status_disabled)
}
Status.ENABLED -> {
binding.enableButton.visibility = View.GONE
binding.disableButtons.visibility = View.VISIBLE
Pair(R.color.colorEnabled, R.string.status_enabled)
}
else -> {
binding.enableButton.visibility = View.GONE
binding.disableButtons.visibility = View.GONE
Pair(R.color.colorUnknown, R.string.status_unknown)
}
}
val status = getString(statusText)
val statusLabel = getString(R.string.label_status, status)
val start = statusLabel.indexOf(status)
val end = start + status.length
val statusSpan = SpannableString(statusLabel)
statusSpan[start, end] = StyleSpan(Typeface.BOLD)
statusSpan[start, end] =
ForegroundColorSpan(getColor(binding.status.context, statusColor))
binding.status.text = statusSpan
})
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentMainBinding.inflate(inflater, container, false)
return binding.root
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.main, menu)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
showProgress(true)
binding.enableButton.setSuspendingOnClickListener(lifecycleScope) {
showProgress(true)
try {
viewModel.enablePiHole()
} catch (ignored: Exception) {
Log.e("Pi-helper", "Failed to enable Pi-Hole", ignored)
}
}
binding.disable10SecondsButton.setSuspendingOnClickListener(lifecycleScope) {
showProgress(true)
try {
viewModel.disablePiHole(10)
} catch (ignored: Exception) {
Log.e("Pi-helper", "Failed to disable Pi-Hole", ignored)
}
}
binding.disable30SecondsButton.setSuspendingOnClickListener(lifecycleScope) {
showProgress(true)
try {
viewModel.disablePiHole(30)
} catch (ignored: Exception) {
Log.e("Pi-helper", "Failed to disable Pi-Hole", ignored)
}
}
binding.disable5MinutesButton.setSuspendingOnClickListener(lifecycleScope) {
showProgress(true)
try {
viewModel.disablePiHole(300)
} catch (ignored: Exception) {
Log.e("Pi-helper", "Failed to disable Pi-Hole", ignored)
}
}
binding.disableCustomTimeButton.setOnClickListener {
val dialogView = DialogDisableCustomTimeBinding.inflate(
LayoutInflater.from(it.context),
view as ViewGroup,
false
)
AlertDialog.Builder(it.context)
.setTitle(R.string.action_disable_custom)
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.action_disable, null)
.setView(dialogView.root)
.create()
.apply {
setOnShowListener {
getButton(AlertDialog.BUTTON_POSITIVE)
.setSuspendingOnClickListener(lifecycleScope) {
try {
val rawTime = dialogView.time
.text
.toString()
.toLong()
val computedTime =
when (dialogView.timeUnit.checkedRadioButtonId) {
R.id.seconds -> rawTime
R.id.minutes -> rawTime * 60
else -> rawTime * 3600
}
viewModel.disablePiHole(computedTime)
dismiss()
} catch (e: Exception) {
dialogView.time.error = "Failed to disable Pi-hole"
}
}
}
}
.show()
}
binding.disablePermanentlyButton.setSuspendingOnClickListener(lifecycleScope) {
showProgress(true)
try {
viewModel.disablePiHole()
} catch (ignored: Exception) {
Log.e("Pi-helper", "Failed to disable Pi-Hole", ignored)
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.settings) {
findNavController().navigate(R.id.action_mainFragment_to_settingsFragment)
return true
}
return super.onOptionsItemSelected(item)
}
private fun showProgress(show: Boolean) {
binding.progressBar.visibility = if (show) {
binding.progressBar.startAnimation(RotateAnimation(
0f,
360f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
).apply {
duration = resources.getInteger(android.R.integer.config_longAnimTime).toLong() * 2
repeatMode = Animation.RESTART
repeatCount = Animation.INFINITE
interpolator = LinearInterpolator()
fillAfter = true
})
View.VISIBLE
} else {
binding.progressBar.clearAnimation()
View.GONE
}
binding.statusContent.visibility = if (show) {
View.GONE
} else {
View.VISIBLE
}
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
companion object {
const val ACTION_DISABLE = "com.wbrawner.pihelper.MainFragment.ACTION_DISABLE"
const val ACTION_ENABLE = "com.wbrawner.pihelper.MainFragment.ACTION_ENABLE"
const val EXTRA_DURATION = "com.wbrawner.pihelper.MainFragment.EXTRA_DURATION"
}
}

View file

@ -1,7 +1,23 @@
package com.wbrawner.pihelper
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import android.content.Context
import com.wbrawner.piholeclient.piHoleClientModule
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
@HiltAndroidApp
class PiHelperApplication : Application()
@Suppress("unused")
class PiHelperApplication: Application() {
override fun onCreate() {
super.onCreate()
startKoin{
androidLogger()
androidContext(this@PiHelperApplication)
modules(listOf(
piHoleClientModule,
piHelperModule
))
}
}
}

View file

@ -1,38 +1,33 @@
package com.wbrawner.pihelper
import com.wbrawner.pihelper.shared.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import androidx.security.crypto.EncryptedSharedPreferences
import com.wbrawner.piholeclient.NAME_BASE_URL
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
@Module
@InstallIn(SingletonComponent::class)
object PiHelperModule {
@Provides
@Singleton
fun providesPiholeAPIService(): PiholeAPIService = PiholeAPIService.create()
const val ENCRYPTED_SHARED_PREFS_FILE_NAME = "pihelper.prefs"
@Provides
@Singleton
fun providesAnalyticsHelper(): AnalyticsHelper = object : AnalyticsHelper {
override fun pageView(route: Route) {
// Not implemented
}
override fun event(
event: AnalyticsEvent,
route: Route
) {
// Not implemented
}
val piHelperModule = module {
single {
EncryptedSharedPreferences.create(
ENCRYPTED_SHARED_PREFS_FILE_NAME,
"pihelper",
get(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
@Provides
@Singleton
fun providesStore(
apiService: PiholeAPIService,
analyticsHelper: AnalyticsHelper
): Store = Store(apiService, analyticsHelper)
viewModel {
AddPiHelperViewModel(get(), get())
}
viewModel {
PiHelperViewModel(get())
}
single(named(NAME_BASE_URL)) {
get<EncryptedSharedPreferences>().getString(KEY_BASE_URL, "")
}
}

View file

@ -0,0 +1,45 @@
package com.wbrawner.pihelper
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.piholeclient.PiHoleApiService
import com.wbrawner.piholeclient.Status
import com.wbrawner.piholeclient.StatusProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlin.coroutines.coroutineContext
class PiHelperViewModel(
private val apiService: PiHoleApiService
) : ViewModel() {
val status = MutableLiveData<Status>()
private var action: (suspend () -> StatusProvider)? = null
get() = field ?: defaultAction
private var defaultAction = suspend {
apiService.getSummary()
}
suspend fun monitorSummary() {
while (coroutineContext.isActive) {
try {
status.postValue(action!!.invoke().status)
action = null
} catch (ignored: Exception) {
break
}
delay(1000)
}
}
suspend fun enablePiHole() {
action = {
apiService.enable()
}
}
suspend fun disablePiHole(duration: Long? = null) {
action = {
apiService.disable(duration)
}
}
}

View file

@ -0,0 +1,102 @@
package com.wbrawner.pihelper
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.transition.TransitionInflater
import com.wbrawner.pihelper.databinding.FragmentRetrieveApiKeyBinding
import org.koin.android.ext.android.inject
class RetrieveApiKeyFragment : Fragment() {
private val viewModel: AddPiHelperViewModel by inject()
private var _binding: FragmentRetrieveApiKeyBinding? = null
private val binding get() = _binding!!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedElementEnterTransition = TransitionInflater.from(context)
.inflateTransition(android.R.transition.move)
viewModel.authenticated.observe(this, Observer {
if (!it) return@Observer
findNavController().navigate(R.id.action_retrieveApiKeyFragment_to_mainFragment)
})
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentRetrieveApiKeyBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.password.setOnEditorActionListener { _, _, _ ->
binding.connectWithPasswordButton.performClick()
}
binding.connectWithPasswordButton.setSuspendingOnClickListener(lifecycleScope) {
showProgress(true)
try {
viewModel.authenticateWithPassword(binding.password.text.toString())
} catch (ignored: Exception) {
Log.e("Pi-helper", "Failed to authenticate with password", ignored)
binding.password.error = "Failed to authenticate with given password. Please verify " +
"you've entered it correctly and try again."
showProgress(false)
}
}
binding.apiKey.setOnEditorActionListener { _, _, _ ->
binding.connectWithApiKeyButton.performClick()
}
binding.connectWithApiKeyButton.setSuspendingOnClickListener(lifecycleScope) {
showProgress(true)
try {
viewModel.authenticateWithApiKey(binding.apiKey.text.toString())
} catch (ignored: Exception) {
binding.apiKey.error = "Failed to authenticate with given API key. Please verify " +
"you've entered it correctly and try again."
showProgress(false)
}
}
}
private fun showProgress(show: Boolean) {
if (show) {
binding.piHelperLogo.startAnimation(
RotateAnimation(
0f,
360f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
).apply {
duration =
resources.getInteger(android.R.integer.config_longAnimTime).toLong() * 2
repeatMode = Animation.RESTART
repeatCount = Animation.INFINITE
interpolator = LinearInterpolator()
fillAfter = true
}
)
} else {
binding.piHelperLogo.clearAnimation()
}
binding.authenticationForm.visibility = if (show) View.GONE else View.VISIBLE
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}

View file

@ -0,0 +1,171 @@
package com.wbrawner.pihelper
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.navigation.fragment.findNavController
import androidx.transition.Transition
import androidx.transition.TransitionInflater
import com.wbrawner.pihelper.databinding.FragmentScanNetworkBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import java.net.Inet4Address
class ScanNetworkFragment : Fragment() {
private val viewModel: AddPiHelperViewModel by inject()
private var _binding: FragmentScanNetworkBinding? = null
private val binding get() = _binding!!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedElementEnterTransition = TransitionInflater.from(context)
.inflateTransition(android.R.transition.move)
.addListener(object : Transition.TransitionListener {
override fun onTransitionEnd(transition: Transition) {
animatePiHelperLogo()
}
override fun onTransitionResume(transition: Transition) {
}
override fun onTransitionPause(transition: Transition) {
}
override fun onTransitionCancel(transition: Transition) {
}
override fun onTransitionStart(transition: Transition) {
}
})
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentScanNetworkBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.scanningIp.observe(viewLifecycleOwner, Observer {
binding.ipAddress.text = it
})
viewModel.piHoleIpAddress.observe(viewLifecycleOwner, Observer { ipAddress ->
if (ipAddress == null) {
AlertDialog.Builder(view.context)
.setTitle(R.string.scan_failed_title)
.setMessage(R.string.scan_failed)
.setPositiveButton(android.R.string.ok) { _, _ ->
findNavController().navigateUp()
}
.show()
return@Observer
}
binding.piHelperLogo.animation?.let {
it.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationRepeat(animation: Animation?) {
}
override fun onAnimationEnd(animation: Animation?) {
navigateToApiKeyScreen()
}
override fun onAnimationStart(animation: Animation?) {
}
})
it.repeatCount = 0
} ?: navigateToApiKeyScreen()
})
lifecycleScope.launch(Dispatchers.IO) {
if (BuildConfig.DEBUG && Build.MODEL == "Android SDK built for x86") {
// For emulators, just begin scanning the host machine directly
viewModel.beginScanning("10.0.2.2")
return@launch
}
(view.context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.let { connectivityManager ->
connectivityManager.allNetworks
.filter {
connectivityManager.getNetworkCapabilities(it)
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
?: false
}
.forEach { network ->
connectivityManager.getLinkProperties(network)
?.linkAddresses
?.filter { !it.address.isLoopbackAddress && it.address is Inet4Address }
?.forEach { address ->
Log.d(
"Pi-helper",
"Found link address: ${address.address.hostName}"
)
viewModel.beginScanning(address.address.hostAddress)
}
}
}
}
lifecycleScope.launch {
delay(500)
if (binding.piHelperLogo.animation == null) {
animatePiHelperLogo()
}
}
}
private fun navigateToApiKeyScreen() {
val extras = FragmentNavigatorExtras(
binding.piHelperLogo to "piHelperLogo"
)
findNavController().navigate(
R.id.action_scanNetworkFragment_to_retrieveApiKeyFragment,
null,
null,
extras
)
}
override fun onDestroyView() {
binding.piHelperLogo.clearAnimation()
_binding = null
super.onDestroyView()
}
private fun animatePiHelperLogo() {
binding.piHelperLogo.startAnimation(
RotateAnimation(
0f,
360f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
).apply {
duration = resources.getInteger(android.R.integer.config_longAnimTime).toLong() * 2
repeatMode = Animation.RESTART
repeatCount = Animation.INFINITE
interpolator = LinearInterpolator()
fillAfter = true
}
)
}
}

View file

@ -1,62 +0,0 @@
package com.wbrawner.pihelper
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.shared.ui.component.LoadingSpinner
import com.wbrawner.pihelper.shared.ui.theme.PihelperTheme
@Composable
fun ScanScreen(store: Store) {
val state by store.state.collectAsState()
ScanningStatus(state.scanning?.let { "Scanning $it..." })
}
@Composable
fun ScanningStatus(
loadingMessage: String? = null
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
) {
LoadingSpinner(loadingMessage != null)
loadingMessage?.let {
Text(
text = loadingMessage,
textAlign = TextAlign.Center
)
}
}
}
@Composable
@Preview
fun ScanningStatus_Preview() {
PihelperTheme(false) {
ScanningStatus(loadingMessage = "Scanning 127.0.0.1")
}
}
@Composable
@Preview
fun ScanningStatus_DarkPreview() {
PihelperTheme(true) {
ScanningStatus(loadingMessage = "Scanning 127.0.0.1")
}
}

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground>
<inset
android:drawable="@drawable/ic_play_arrow_green"
android:inset="15%" />
</foreground>
<background android:drawable="@color/colorSurface" />
<monochrome android:drawable="@drawable/ic_play_arrow" />
</adaptive-icon>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground>
<inset
android:drawable="@drawable/ic_pause_red"
android:inset="20%" />
</foreground>
<background android:drawable="@color/colorSurface" />
<monochrome android:drawable="@drawable/ic_pause" />
</adaptive-icon>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:width="500dp"
android:height="1dp"
android:gravity="center">
<color android:color="@color/colorOnSurface" />
<shape android:shape="rectangle" />
</item>
</layer-list>

View file

@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorWhite"
android:fillColor="@color/colorRedDark"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorRedDark"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
</vector>

View file

@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorWhite"
android:fillColor="@color/colorGreenDark"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorGreenDark"
android:pathData="M8,5v14l11,-7z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/colorOnSurface"
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z"/>
</vector>

View file

@ -2,8 +2,8 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape
android:shape="rectangle"
android:tint="@color/colorSurface" />
android:shape="oval"
android:tint="@color/colorWhite" />
</item>
<item
android:bottom="0dp"

View file

@ -2,8 +2,8 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape
android:shape="rectangle"
android:tint="@color/colorSurface" />
android:shape="oval"
android:tint="@color/colorWhite" />
</item>
<item
android:bottom="0dp"

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:width="1dp"
android:height="500dp"
android:gravity="center">
<color android:color="@color/colorOnSurface" />
<shape android:shape="rectangle" />
</item>
</layer-list>

View file

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:fillViewport="true"
tools:background="@color/colorSurface"
tools:context=".AddPiHoleFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/connectionForm"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/piHelperLogo">
<TextView
android:id="@+id/scanNetwork"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/info_scan_network"
android:textAlignment="center"
app:layout_constraintBottom_toTopOf="@+id/scanNetworkButton"
app:layout_constraintEnd_toStartOf="@+id/orDivider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/scanNetworkButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_scan_network"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/orDivider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/scanNetwork" />
<include
android:id="@+id/orDivider"
layout="@layout/or_divider"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/connectDirectly"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/info_connect"
android:textAlignment="center"
app:layout_constraintBottom_toTopOf="@+id/scanNetworkButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/ipAddressContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/prompt_ip_address"
app:layout_constraintBottom_toTopOf="@+id/connectButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toBottomOf="@+id/connectDirectly"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/ipAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="text"
android:maxLines="1"
android:text="pi.hole"
tools:ignore="HardcodedText" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_connect_pihole"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toBottomOf="@+id/ipAddressContainer" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
tools:background="@color/colorSurface">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/statusContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
tools:visibility="visible">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal"
android:padding="16dp"
tools:context=".MainFragment">
<TextView
android:id="@+id/status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="4dp"
android:textAlignment="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/actionButtons"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Enabled"
tools:textColor="@color/colorGreenDark" />
<LinearLayout
android:id="@+id/actionButtons"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/status"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/enableButton"
style="@style/AppTheme.Button.Green"
android:text="@string/action_enable" />
<LinearLayout
android:id="@+id/disableButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.button.MaterialButton
android:id="@+id/disable10SecondsButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_10_seconds" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disable30SecondsButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_30_seconds" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disable5MinutesButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_5_minutes" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disableCustomTimeButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_custom" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disablePermanentlyButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_permanently" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<ImageView
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
tools:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,141 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:clipChildren="false"
android:clipToPadding="false"
android:fillViewport="true"
android:padding="16dp"
tools:background="@color/colorSurface"
tools:context=".RetrieveApiKeyFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="400dp"
android:layout_gravity="center">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/accessibility_description_pi_helper_logo"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo"
app:layout_constraintBottom_toTopOf="@+id/authenticationForm"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/authenticationForm"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintHeight_min="wrap"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/piHelperLogo">
<TextView
android:id="@+id/connectionSuccess"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_connection_success"
android:textAlignment="center"
app:layout_constraintBottom_toTopOf="@+id/authRequired"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/authRequired"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_authentication_required"
android:textAlignment="center"
app:layout_constraintBottom_toTopOf="@+id/orDivider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/connectionSuccess" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/prompt_password"
app:layout_constraintBottom_toTopOf="@+id/connectWithPasswordButton"
app:layout_constraintEnd_toStartOf="@+id/orDivider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/orDivider"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectWithPasswordButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_authenticate_password"
app:layout_constraintBottom_toBottomOf="@+id/orDivider"
app:layout_constraintEnd_toStartOf="@+id/orDivider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/passwordContainer" />
<include
android:id="@+id/orDivider"
layout="@layout/or_divider"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_min="200dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/authRequired" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/apiKeyContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/prompt_api_key"
app:layout_constraintBottom_toTopOf="@+id/connectWithApiKeyButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toTopOf="@+id/orDivider"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/apiKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectWithApiKeyButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_authenticate_api_key"
app:layout_constraintBottom_toBottomOf="@+id/orDivider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toBottomOf="@+id/apiKeyContainer" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/colorSurface">
<ImageView
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_gravity="center"
android:layout_weight="1"
android:scaleType="fitXY"
android:src="@drawable/vertical_rule"
tools:ignore="ContentDescription" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="16dp"
android:text="@string/or"
android:textAlignment="center"
android:textAllCaps="true" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_gravity="center"
android:layout_weight="1"
android:scaleType="fitXY"
android:src="@drawable/vertical_rule"
tools:ignore="ContentDescription" />
</LinearLayout>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/content_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"
tools:background="@color/colorSurface" />

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
tools:background="@color/colorSurface">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_disable_duration">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number|numberSigned" />
</com.google.android.material.textfield.TextInputLayout>
<RadioGroup
android:id="@+id/timeUnit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<RadioButton
android:id="@+id/seconds"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/duration_seconds" />
<RadioButton
android:id="@+id/minutes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/duration_minutes" />
<RadioButton
android:id="@+id/hours"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/duration_hours" />
</RadioGroup>
</LinearLayout>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
tools:background="@color/colorSurface"
tools:context=".AddPiHoleFragment">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo" />
<LinearLayout
android:id="@+id/connectionForm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_scan_network"
android:textAlignment="center" />
<com.google.android.material.button.MaterialButton
android:id="@+id/scanNetworkButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_scan_network" />
<include layout="@layout/or_divider" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_connect"
android:textAlignment="center" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_ip_address">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/ipAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="text"
android:maxLines="1"
android:text="pi.hole"
tools:ignore="HardcodedText" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_connect_pihole" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@color/colorSurface"
tools:context=".InfoFragment">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo" />
<TextView
android:id="@+id/infoContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/content_info"
android:textAlignment="center" />
<com.google.android.material.button.MaterialButton
android:id="@+id/forgetPiHoleButton"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_forget_pihole" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
tools:background="@color/colorSurface">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/statusContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="16dp"
tools:context=".MainFragment">
<TextView
android:id="@+id/status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp"
android:textAlignment="center"
tools:text="Enabled"
tools:textColor="@color/colorGreenDark" />
<com.google.android.material.button.MaterialButton
android:id="@+id/enableButton"
style="@style/AppTheme.Button.Green"
android:text="@string/action_enable" />
<LinearLayout
android:id="@+id/disableButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.button.MaterialButton
android:id="@+id/disable10SecondsButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_10_seconds" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disable30SecondsButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_30_seconds" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disable5MinutesButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_5_minutes" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disableCustomTimeButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_custom" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disablePermanentlyButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_permanently" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<ImageView
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:fillViewport="true"
tools:background="@color/colorSurface"
tools:context=".RetrieveApiKeyFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:contentDescription="@string/accessibility_description_pi_helper_logo"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo"
app:layout_constraintBottom_toTopOf="@+id/authenticationForm"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<LinearLayout
android:id="@+id/authenticationForm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/piHelperLogo">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_connection_success"
android:textAlignment="center" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/info_authentication_required"
android:textAlignment="center" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_password">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectWithPasswordButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_authenticate_password" />
<include layout="@layout/or_divider" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_api_key">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/apiKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectWithApiKeyButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_authenticate_api_key" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="16dp"
tools:background="@color/colorSurface"
tools:context=".ScanNetworkFragment">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo"
android:contentDescription="@string/accessibility_description_pi_helper_logo" />
<TextView
android:id="@+id/ipAddressScanningInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scanning_ip_address"
android:textAlignment="center" />
<TextView
android:id="@+id/ipAddress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center" />
</LinearLayout>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
tools:background="@color/colorSurface">
<ImageView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:scaleType="fitXY"
android:src="@drawable/horizontal_rule"
tools:ignore="ContentDescription" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="16dp"
android:text="@string/or"
android:textAlignment="center"
android:textAllCaps="true" />
<ImageView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:scaleType="fitXY"
android:src="@drawable/horizontal_rule"
tools:ignore="ContentDescription" />
</LinearLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/settings"
android:icon="@drawable/ic_settings"
android:title="@string/action_settings"
app:showAsAction="ifRoom" />
</menu>

View file

@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/placeholder">
<fragment
android:id="@+id/addPiHoleFragment"
android:name="com.wbrawner.pihelper.AddPiHoleFragment"
android:label="fragment_add_pi_hole"
tools:layout="@layout/fragment_add_pi_hole" >
<action
android:id="@+id/action_addPiHoleFragment_to_scanNetworkFragment"
app:destination="@id/scanNetworkFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_addPiHoleFragment_to_retrieveApiKeyFragment"
app:destination="@id/retrieveApiKeyFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:popUpTo="@+id/addPiHoleFragment" />
</fragment>
<fragment
android:id="@+id/placeholder"
android:name="androidx.fragment.app.Fragment" />
<fragment
android:id="@+id/scanNetworkFragment"
android:name="com.wbrawner.pihelper.ScanNetworkFragment"
android:label="fragment_scan_network"
tools:layout="@layout/fragment_scan_network" >
<action
android:id="@+id/action_scanNetworkFragment_to_retrieveApiKeyFragment"
app:destination="@id/retrieveApiKeyFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:popUpTo="@+id/addPiHoleFragment" />
</fragment>
<fragment
android:id="@+id/retrieveApiKeyFragment"
android:name="com.wbrawner.pihelper.RetrieveApiKeyFragment"
android:label="fragment_retrieve_api_key"
tools:layout="@layout/fragment_retrieve_api_key" >
<action
android:id="@+id/action_retrieveApiKeyFragment_to_mainFragment"
app:destination="@id/mainFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
android:id="@+id/mainFragment"
android:name="com.wbrawner.pihelper.MainFragment"
android:label="@string/app_name"
tools:layout="@layout/fragment_main" >
<action
android:id="@+id/action_mainFragment_to_settingsFragment"
app:destination="@id/settingsFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
android:id="@+id/settingsFragment"
android:name="com.wbrawner.pihelper.InfoFragment"
android:label="@string/action_settings"
tools:layout="@layout/fragment_info" />
</navigation>

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorSurface">#000000</color>
<color name="colorSurface">#333333</color>
<color name="colorOnSurface">#f1f1f1</color>
<color name="colorEnabled">@color/colorGreenLight</color>
<color name="colorDisabled">@color/colorRedLight</color>
<color name="colorButtonSecondary">#999999</color>
</resources>

View file

@ -1,7 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="BaseTheme">
<item name="android:statusBarColor">@color/colorTransparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
<style name="AppTheme.Button.Red" parent="Widget.MaterialComponents.Button">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="backgroundTint">@color/colorRedLight</item>
<item name="android:textColor">@color/colorWhite</item>
</style>
</resources>

View file

@ -1,15 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">@color/colorAccent</color>
<color name="colorPrimary">@color/colorRedLight</color>
<color name="colorPrimaryDark">@color/colorRedDark</color>
<color name="colorAccent">@color/colorRedLight</color>
<color name="colorRedLight">#F44336</color>
<color name="colorRedDark">#B71C1C</color>
<color name="colorGreenLight">#4CAF50</color>
<color name="colorGreenDark">#1B5E20</color>
<color name="colorShortcutBackground">#FFFFFFFF</color>
<color name="colorRedLight">#f60d1a</color>
<color name="colorRedDark">#96060c</color>
<color name="colorGreenLight">#30d158</color>
<color name="colorGreenDark">#34c759</color>
<color name="colorOnSurface">#000000</color>
<color name="colorWhite">#FFFBFE</color>
<color name="colorWhite">#ffffff</color>
<color name="colorEnabled">@color/colorGreenDark</color>
<color name="colorUnknown">@color/colorButtonSecondary</color>
<color name="colorDisabled">@color/colorRedDark</color>
<color name="colorSurface">@color/colorWhite</color>
<color name="colorTransparent">#00000000</color>
</resources>
<color name="colorButtonSecondary">#666666</color>
</resources>

View file

@ -1,10 +1,50 @@
<resources>
<string name="app_name">Pi-helper</string>
<string name="scanning_ip_address">Scanning IP Address:</string>
<string name="accessibility_description_pi_helper_logo">Pi-helper Logo</string>
<string name="label_status">Status: %1$s</string>
<string name="action_enable">Enable</string>
<string name="action_disable">Disable</string>
<string name="action_disable_10_seconds">Disable for 10 seconds</string>
<string name="action_disable_10_seconds_short">Disable for 10 seconds</string>
<string name="action_disable_30_seconds">Disable for 30 seconds</string>
<string name="action_disable_30_seconds_short">Disable for 30 seconds</string>
<string name="action_disable_5_minutes">Disable for 5 minutes</string>
<string name="action_disable_5_minutes_short">Disable for 5 minutes</string>
<string name="action_disable_custom">Disable for custom time</string>
<string name="action_disable_permanently">Disable Permanently</string>
<string name="status_disabled">Disabled</string>
<string name="status_enabled">Enabled</string>
<string name="scan_failed">Please ensure you are connected to the same Wi-Fi network that the Pi-Hole is running on and try again, or enter the Pi-Hole\'s IP address manually.</string>
<string name="scan_failed_title">Pi-helper failed to find your Pi-Hole</string>
<string name="connection_failed">Please ensure you are connected to the same Wi-Fi network that the Pi-Hole is running on, and that you\'re using the correct IP address and try again.</string>
<string name="connection_failed_title">Pi-helper failed to connect to your Pi-Hole</string>
<string name="configure_pihelper">Please configure Pi-helper before using shortcuts</string>
<string name="or">or</string>
<string name="action_settings">Settings</string>
<string name="action_forget_pihole">Forget Pi-hole</string>
<string name="content_info"><![CDATA[Pi-helper was made with ❤ by <a href=\"https://wbrawner.com\">William Brawner</a>. You can find the source code or report issues on the <a href=\"https://github.com/wbrawner/PiHelperAndroid\">GitHub page</a> for the project.]]></string>
<string name="confirm_forget_pihole">Are you sure you want to forget your Pi-hole?</string>
<string name="warning_cannot_be_undone">This cannot be undone.</string>
<string name="title_crash_notification">Pi-helper Crashed!</string>
<string name="text_crash_notification">Would you please consider sending the crash report to me?</string>
<string name="channel_crash_notification">Crash Reports</string>
<string name="status_unknown">Unknown</string>
<string name="duration_seconds">Secs</string>
<string name="duration_minutes">Mins</string>
<string name="hint_disable_duration">Time to disable</string>
<string name="info_scan_network">If you\'re not sure what the IP address for your Pi-Hole is, Pi-helper can attempt to find it for you by scanning your network.</string>
<string name="action_scan_network">Scan Network</string>
<string name="info_connect">If you already know the IP address or host of your Pi-Hole, you can also enter it below:</string>
<string name="prompt_ip_address">Pi-Hole IP Address/Host</string>
<string name="action_connect_pihole">Connect to Pi-Hole</string>
<string name="info_connection_success">Pi-helper has successfully connected to your Pi-Hole!</string>
<string name="info_authentication_required">You\'ll need to authenticate in order to enable and disable the Pi-hole.</string>
<string name="prompt_password">Pi-Hole Web Password</string>
<string name="action_authenticate_password">Authenticate with Password</string>
<string name="prompt_api_key">Pi-Hole API Key</string>
<string name="action_authenticate_api_key">Authenticate with API Key</string>
<string name="connecting_to_pihole">Connecting to Pi-hole…</string>
<string name="action_cancel">Cancel</string>
<string name="duration_hours">Hours</string>
</resources>

View file

@ -4,17 +4,25 @@
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@drawable/background_splash</item>
<item name="android:statusBarColor">@color/colorWhite</item>
<item name="android:statusBarColor">@color/colorTransparent</item>
<item name="android:textColor">@color/colorOnSurface</item>
</style>
<style name="AppTheme" parent="BaseTheme">
<item name="android:windowLightStatusBar">true</item>
</style>
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/colorSurface</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_app_logo</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
<style name="AppTheme.Button" parent="Widget.MaterialComponents.Button">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textColor">@color/colorWhite</item>
</style>
<style name="AppTheme.Button.Green" parent="AppTheme.Button">
<item name="backgroundTint">@color/colorGreenDark</item>
</style>
<style name="AppTheme.Button.Red" parent="AppTheme.Button">
<item name="backgroundTint">@color/colorAccent</item>
</style>
</resources>

View file

@ -7,11 +7,11 @@
android:shortcutLongLabel="@string/action_disable_5_minutes"
android:shortcutShortLabel="@string/action_disable_5_minutes_short">
<intent
android:action="com.wbrawner.pihelper.ShortcutActions.DISABLE"
android:action="com.wbrawner.pihelper.MainFragment.ACTION_DISABLE"
android:targetClass="com.wbrawner.pihelper.MainActivity"
android:targetPackage="com.wbrawner.pihelper">
<extra
android:name="com.wbrawner.pihelper.MainActivityKt.DURATION"
android:name="com.wbrawner.pihelper.MainFragment.EXTRA_DURATION"
android:value="300" />
</intent>
</shortcut>
@ -23,11 +23,11 @@
android:shortcutLongLabel="@string/action_disable_30_seconds"
android:shortcutShortLabel="@string/action_disable_30_seconds_short">
<intent
android:action="com.wbrawner.pihelper.ShortcutActions.DISABLE"
android:action="com.wbrawner.pihelper.MainFragment.ACTION_DISABLE"
android:targetClass="com.wbrawner.pihelper.MainActivity"
android:targetPackage="com.wbrawner.pihelper">
<extra
android:name="com.wbrawner.pihelper.MainActivityKt.DURATION"
android:name="com.wbrawner.pihelper.MainFragment.EXTRA_DURATION"
android:value="30" />
</intent>
</shortcut>
@ -39,11 +39,11 @@
android:shortcutLongLabel="@string/action_disable_10_seconds"
android:shortcutShortLabel="@string/action_disable_10_seconds_short">
<intent
android:action="com.wbrawner.pihelper.ShortcutActions.DISABLE"
android:action="com.wbrawner.pihelper.MainFragment.ACTION_DISABLE"
android:targetClass="com.wbrawner.pihelper.MainActivity"
android:targetPackage="com.wbrawner.pihelper">
<extra
android:name="com.wbrawner.pihelper.MainActivityKt.DURATION"
android:name="com.wbrawner.pihelper.MainFragment.EXTRA_DURATION"
android:value="10" />
</intent>
</shortcut>
@ -55,7 +55,7 @@
android:shortcutLongLabel="@string/action_enable"
android:shortcutShortLabel="@string/action_enable">
<intent
android:action="com.wbrawner.pihelper.ShortcutActions.ENABLE"
android:action="com.wbrawner.pihelper.MainFragment.ACTION_ENABLE"
android:targetClass="com.wbrawner.pihelper.MainActivity"
android:targetPackage="com.wbrawner.pihelper" />
</shortcut>

View file

@ -4,11 +4,11 @@
android:enabled="true"
android:icon="@drawable/ic_shortcut_pause">
<intent
android:action="com.wbrawner.pihelper.ShortcutActions.DISABLE"
android:action="com.wbrawner.pihelper.MainFragment.ACTION_DISABLE"
android:targetClass="com.wbrawner.pihelper.MainActivity"
android:targetPackage="com.wbrawner.pihelper">
<extra
android:name="com.wbrawner.pihelper.MainActivityKt.DURATION"
android:name="com.wbrawner.pihelper.MainFragment.EXTRA_DURATION"
android:value="300" />
</intent>
</shortcut>
@ -17,11 +17,11 @@
android:enabled="true"
android:icon="@drawable/ic_shortcut_pause">
<intent
android:action="com.wbrawner.pihelper.ShortcutActions.DISABLE"
android:action="com.wbrawner.pihelper.MainFragment.ACTION_DISABLE"
android:targetClass="com.wbrawner.pihelper.MainActivity"
android:targetPackage="com.wbrawner.pihelper">
<extra
android:name="com.wbrawner.pihelper.MainActivityKt.DURATION"
android:name="com.wbrawner.pihelper.MainFragment.EXTRA_DURATION"
android:value="30" />
</intent>
</shortcut>
@ -30,11 +30,11 @@
android:enabled="true"
android:icon="@drawable/ic_shortcut_pause">
<intent
android:action="com.wbrawner.pihelper.ShortcutActions.DISABLE"
android:action="com.wbrawner.pihelper.MainFragment.ACTION_DISABLE"
android:targetClass="com.wbrawner.pihelper.MainActivity"
android:targetPackage="com.wbrawner.pihelper">
<extra
android:name="com.wbrawner.pihelper.MainActivityKt.DURATION"
android:name="com.wbrawner.pihelper.MainFragment.EXTRA_DURATION"
android:value="10" />
</intent>
</shortcut>
@ -43,7 +43,7 @@
android:enabled="true"
android:icon="@drawable/ic_shortcut_enable">
<intent
android:action="com.wbrawner.pihelper.ShortcutActions.ENABLE"
android:action="com.wbrawner.pihelper.MainFragment.ACTION_ENABLE"
android:targetClass="com.wbrawner.pihelper.MainActivity"
android:targetPackage="com.wbrawner.pihelper" />
</shortcut>

25
build.gradle Normal file
View file

@ -0,0 +1,25 @@
buildscript {
ext.kotlin_version = '1.4.20'
ext.coroutines_version = '1.3.2'
ext.koin_version = '2.0.1'
ext.okhttp_version = '4.2.2'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View file

@ -1,12 +0,0 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.compose) apply false
alias(libs.plugins.jetbrainsCompose) apply false
alias(libs.plugins.hilt.android) apply false
alias(libs.plugins.kotlin.ksp) apply false
}

2
desktop/.gitignore vendored
View file

@ -1,2 +0,0 @@
/build
/release

View file

@ -1,21 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="desktop" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName"/>
<option name="externalProjectPath" value="$PROJECT_DIR$"/>
<option name="externalSystemIdString" value="GRADLE"/>
<option name="scriptParameters" value=""/>
<option name="taskDescriptions">
<list/>
</option>
<option name="taskNames">
<list>
<option value="run"/>
</list>
</option>
<option name="vmOptions" value=""/>
</ExternalSystemSettings>
<GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
<method v="2"/>
</configuration>
</component>

View file

@ -1,45 +0,0 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.compose)
alias(libs.plugins.jetbrainsCompose)
java
}
group = "com.wbrawner.pihelper"
version = "1.0-SNAPSHOT"
dependencies {
// Note, if you develop a library, you should use compose.desktop.common.
// compose.desktop.currentOs should be used in launcher-sourceSet
// (in a separate module for demo project and in testMain).
// With compose.desktop.common you will also lose @Preview functionality
implementation(project(":shared"))
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.jvm)
implementation(libs.logback.classic)
}
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
includeAllModules = true
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Pi-helper"
packageVersion = "1.0.0"
macOS {
iconFile.set(project.file("src/main/resources/icon.icns"))
}
windows {
iconFile.set(project.file("src/main/resources/icon.ico"))
}
linux {
iconFile.set(project.file("src/main/resources/icon.png"))
}
}
}
}

View file

@ -1,138 +0,0 @@
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.isTraySupported
import androidx.compose.ui.window.rememberTrayState
import com.wbrawner.pihelper.shared.*
import com.wbrawner.pihelper.shared.State
import com.wbrawner.pihelper.shared.ui.AddScreen
import com.wbrawner.pihelper.shared.ui.AuthScreen
import com.wbrawner.pihelper.shared.ui.InfoScreen
import com.wbrawner.pihelper.shared.ui.MainScreen
import com.wbrawner.pihelper.shared.ui.theme.PihelperTheme
import java.net.InetAddress
val store = Store(PiholeAPIService.create())
@OptIn(ExperimentalAnimationApi::class)
@Composable
@Preview
fun App(state: State) {
val error by store.effects.collectAsState(Effect.Empty)
PihelperTheme {
when (state.route) {
Route.CONNECT -> AddScreen(
scanNetwork = {
store.dispatch(Action.Scan(InetAddress.getLocalHost().hostAddress))
},
connectToPihole = { store.dispatch(Action.Connect(it)) },
loading = state.loading,
error = error as? Effect.Error
)
Route.AUTH -> AuthScreen(store)
Route.HOME -> MainScreen(store)
Route.ABOUT -> InfoScreen(store)
else -> {
Text("Not yet implemented")
}
}
}
}
fun main() = application {
LaunchedEffect(Unit) {
System.setProperty("apple.awt.enableTemplateImages", "true")
}
val state by store.state.collectAsState()
val trayState = rememberTrayState()
var isOpen by remember { mutableStateOf(state.apiKey.isNullOrBlank()) }
val statusText = when (val status = state.status) {
is Status.Enabled -> "Enabled"
is Status.Disabled -> status.timeRemaining?.let { "Disabled (${it})" } ?: "Disabled"
else -> "Not connected"
}
Tray(
state = trayState,
tooltip = statusText,
icon = painterResource("IconTemplate.png"),
menu = {
Item(
text = statusText,
enabled = false,
onClick = {}
)
if (state.status is Status.Disabled) {
Item(
"Enable",
onClick = {
store.dispatch(Action.Enable)
}
)
} else if (state.status is Status.Enabled) {
Item(
"Disable for 10 seconds",
onClick = {
store.dispatch(Action.Disable(duration = 10))
}
)
Item(
"Disable for 30 seconds",
onClick = {
store.dispatch(Action.Disable(duration = 30))
}
)
Item(
"Disable for 1 minute",
onClick = {
store.dispatch(Action.Disable(duration = 30))
}
)
Item(
"Disable for 5 minutes",
onClick = {
store.dispatch(Action.Disable(duration = 30))
}
)
Item(
"Disable permanently",
onClick = {
store.dispatch(Action.Disable())
}
)
}
Item(
text = if (isOpen) "Hide window" else "Show window",
onClick = {
isOpen = !isOpen
}
)
Item(
"Exit",
onClick = {
isOpen = false
}
)
}
)
if (!isTraySupported || isOpen) {
Window(
title = "Pi-helper",
onCloseRequest = {
if (isTraySupported) {
isOpen = false
} else {
exitApplication()
}
}) {
App(state)
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View file

@ -19,6 +19,3 @@ android.useAndroidX=true
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View file

@ -1,82 +0,0 @@
[versions]
androidGradlePlugin = "8.7.2"
androidx-core = "1.15.0"
androidx-appcompat = "1.7.0"
androidx-splash = "1.0.1"
androidx-test-runner = "1.6.2"
androidx-test-orchestrator = "1.5.1"
compose = "1.7.5"
compose-multiplatform = "1.7.1"
compose-compiler = "1.4.2"
compose-material = "1.7.5"
compose-material3 = "1.3.1"
espresso = "3.6.1"
hilt-android = "2.52"
kotlin = "2.0.21"
kotlin-ksp = "2.1.0-1.0.28"
kotlinx-serialization = "1.7.3"
kotlinx-coroutines = "1.9.0"
kotlinx-datetime = "0.6.1"
ktor = "3.0.1"
logbackClassic = "1.5.12"
material = "1.12.0"
maxSdk = "35"
minSdk = "23"
okhttp = "4.12.0"
settings = "1.2.0"
versionCode = "5"
versionName = "1.1.1"
[libraries]
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.9.3" }
compose-material = { module = "androidx.compose.material:material", version.ref = "compose-material" }
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "compose-material3" }
compose-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" }
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
hilt-android-ksp = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt-android" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt-android" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
junit = { module = "junit:junit", version = "4.13.2" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-jvm = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logbackClassic" }
material = { module = "com.google.android.material:material", version.ref = "material" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
preference = { module = "androidx.preference:preference-ktx", version = "1.2.1" }
test-ext = { module = "androidx.test.ext:junit", version = "1.2.1" }
[bundles]
compose = ["compose-ui", "compose-material", "compose-material3", "compose-material3-window", "compose-tooling", "compose-activity", "navigation-compose"]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "kotlin-ksp" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt-android" }
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip

6
gradlew vendored
View file

@ -82,7 +82,7 @@ location of your Java installation."
fi
else
JAVACMD="java"
which kotlin >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
@ -109,7 +109,7 @@ if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running kotlin
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
@ -161,7 +161,7 @@ save () {
}
APP_ARGS=$(save "$@")
# Collect all arguments for the kotlin command, following the shell quoting and substitution rules
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong

1
piholeclient/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

Some files were not shown because too many files have changed in this diff Show more