feat: Embed the privacy policy in the app (#139)

Instead of linking to the privacy policy embed it in the app as a string
of HTML.

The string is created with a new `markdown2resource` plugin, which
converts `PRIVACY.md` to HTML and generates a Java class with the HTML
content.

Create `PrivacyPolicyActivity` to display the HTML in a `WebView`, and
link to it from `AboutActivity`.
This commit is contained in:
Nik Clayton 2023-10-03 12:56:30 +02:00 committed by GitHub
parent 2cc534f22a
commit 802cdd4c46
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 337 additions and 17 deletions

View File

@ -5,6 +5,8 @@ plugins {
alias(libs.plugins.kotlin.kapt) alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.aboutlibraries) alias(libs.plugins.aboutlibraries)
id "app.pachli.plugins.markdown2resource"
} }
apply from: 'gitTools.gradle' apply from: 'gitTools.gradle'
@ -139,6 +141,10 @@ aboutLibraries {
prettyPrint = true prettyPrint = true
} }
markdown2resource {
files = [ layout.projectDirectory.file('../PRIVACY.md') ]
}
// library versions are in PROJECT_ROOT/gradle/libs.versions.toml // library versions are in PROJECT_ROOT/gradle/libs.versions.toml
dependencies { dependencies {
implementation libs.kotlinx.coroutines.android implementation libs.kotlinx.coroutines.android

View File

@ -47,7 +47,7 @@
errorLine2=" ~~~~~~~~~~~~"> errorLine2=" ~~~~~~~~~~~~">
<location <location
file="build.gradle" file="build.gradle"
line="33" line="35"
column="9"/> column="9"/>
</issue> </issue>
@ -2064,17 +2064,6 @@
column="9"/> column="9"/>
</issue> </issue>
<issue
id="SyntheticAccessor"
message="Access to `private` method `setClickableTextWithoutUnderlines` of class `AboutActivityKt` requires synthetic accessor"
errorLine1=" binding.aboutPrivacyPolicyTextView.setClickableTextWithoutUnderlines(R.string.about_privacy_policy)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/AboutActivity.kt"
line="76"
column="9"/>
</issue>
<issue <issue
id="SyntheticAccessor" id="SyntheticAccessor"
message="Access to `private` method `isSameDate` of class `Companion` requires synthetic accessor" message="Access to `private` method `isSameDate` of class `Companion` requires synthetic accessor"

View File

@ -145,6 +145,7 @@
</activity> </activity>
<activity android:name=".ListsActivity" /> <activity android:name=".ListsActivity" />
<activity android:name=".LicenseActivity" /> <activity android:name=".LicenseActivity" />
<activity android:name=".PrivacyPolicyActivity" />
<activity android:name=".components.filters.FiltersActivity" /> <activity android:name=".components.filters.FiltersActivity" />
<activity android:name=".components.trending.TrendingActivity" /> <activity android:name=".components.trending.TrendingActivity" />
<activity android:name=".components.followedtags.FollowedTagsActivity" /> <activity android:name=".components.followedtags.FollowedTagsActivity" />

View File

@ -73,7 +73,11 @@ class AboutActivity : BottomSheetActivity(), Injectable {
binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(R.string.about_pachli_license) binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(R.string.about_pachli_license)
binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(R.string.about_project_site) binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(R.string.about_project_site)
binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(R.string.about_bug_feature_request_site) binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(R.string.about_bug_feature_request_site)
binding.aboutPrivacyPolicyTextView.setClickableTextWithoutUnderlines(R.string.about_privacy_policy)
binding.aboutPrivacyPolicyTextView.setOnClickListener {
val intent = Intent(this, PrivacyPolicyActivity::class.java)
startActivity(intent)
}
binding.appProfileButton.setOnClickListener { binding.appProfileButton.setOnClickListener {
viewUrl(BuildConfig.SUPPORT_ACCOUNT_URL) viewUrl(BuildConfig.SUPPORT_ACCOUNT_URL)

View File

@ -0,0 +1,32 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli
import android.os.Bundle
import android.util.Base64
import app.pachli.databinding.ActivityPrivacyPolicyBinding
class PrivacyPolicyActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityPrivacyPolicyBinding.inflate(layoutInflater)
setContentView(binding.root)
val encoded = Base64.encodeToString(markdownR.html.PRIVACY_md.toByteArray(), Base64.NO_PADDING)
binding.policy.loadData(encoded, "text/html", "base64")
}
}

View File

@ -21,6 +21,7 @@ import app.pachli.EditProfileActivity
import app.pachli.LicenseActivity import app.pachli.LicenseActivity
import app.pachli.ListsActivity import app.pachli.ListsActivity
import app.pachli.MainActivity import app.pachli.MainActivity
import app.pachli.PrivacyPolicyActivity
import app.pachli.SplashActivity import app.pachli.SplashActivity
import app.pachli.StatusListActivity import app.pachli.StatusListActivity
import app.pachli.TabPreferenceActivity import app.pachli.TabPreferenceActivity
@ -128,4 +129,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesEditFilterActivity(): EditFilterActivity abstract fun contributesEditFilterActivity(): EditFilterActivity
@ContributesAndroidInjector
abstract fun contributesPrivacyPolicyActivity(): PrivacyPolicyActivity
} }

View File

@ -169,19 +169,18 @@
app:layout_constraintStart_toStartOf="@+id/aboutWebsiteInfoTextView" app:layout_constraintStart_toStartOf="@+id/aboutWebsiteInfoTextView"
app:layout_constraintTop_toBottomOf="@id/aboutWebsiteInfoTextView" /> app:layout_constraintTop_toBottomOf="@id/aboutWebsiteInfoTextView" />
<app.pachli.view.ClickableSpanTextView <TextView
android:id="@+id/aboutPrivacyPolicyTextView" android:id="@+id/aboutPrivacyPolicyTextView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp" android:layout_marginTop="24dp"
android:lineSpacingMultiplier="1.2" android:lineSpacingMultiplier="1.2"
android:text="@string/about_privacy_policy" android:text="@string/about_privacy_policy"
android:textIsSelectable="true" android:textColor="?android:attr/textColorLink"
app:layout_constraintEnd_toEndOf="@+id/aboutWebsiteInfoTextView" app:layout_constraintEnd_toEndOf="@+id/aboutWebsiteInfoTextView"
app:layout_constraintStart_toStartOf="@+id/aboutWebsiteInfoTextView" app:layout_constraintStart_toStartOf="@+id/aboutWebsiteInfoTextView"
app:layout_constraintTop_toBottomOf="@id/aboutBugsFeaturesInfoTextView" /> app:layout_constraintTop_toBottomOf="@id/aboutBugsFeaturesInfoTextView" />
<Button <Button
android:id="@+id/appProfileButton" android:id="@+id/appProfileButton"
style="@style/AppButton" style="@style/AppButton"

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Pachli Association
~
~ This file is a part of Pachli.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->
<WebView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/policy"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -418,7 +418,7 @@
* the url can be changed to link to the localized version of the license. * the url can be changed to link to the localized version of the license.
--> -->
<string name="about_project_site">Project website: https://pachli.app</string> <string name="about_project_site">Project website: https://pachli.app</string>
<string name="about_privacy_policy">Privacy policy: https://github.com/pachli/pachli-android/blob/main/PRIVACY.md</string> <string name="about_privacy_policy">Privacy policy</string>
<string name="about_bug_feature_request_site">Bug reports &amp; feature requests:\nhttps://github.com/pachli/pachli-android/issues</string> <string name="about_bug_feature_request_site">Bug reports &amp; feature requests:\nhttps://github.com/pachli/pachli-android/issues</string>
<string name="about_pachli_account">Pachli\'s Profile</string> <string name="about_pachli_account">Pachli\'s Profile</string>

View File

@ -0,0 +1,56 @@
# markdown2resource-plugin
## Synopsis
Gradle plugin to convert one or more Markdown files to Java files with static constants where the Markdown has been converted to HTML. Similar (but not quite identical) to Android resources.
## Example
In `build.gradle`:
```groovy
// Install the plugin
plugins {
id "app.pachli.plugins.markdown2resource"
}
// ...
// Configure the files to be processed
markdown2resource {
files = [ layout.projectDirectory.file('../PRIVACY.md') ]
}
```
In code:
```kotlin
// Assume binding.privacyPolicy references a `WebView`
// The generated string constant is in the `markdownR.html` package, named
// `PRIVACY_md`. To load the content in to a WebView it must be converted to
// base64
val html = Base64.encodeToString(markdownR.html.PRIVACY_md.toByteArray(), Base64.NO_PADDING)
binding.privacyPolicy.loadData(html, "text/html", "base64")
```
## Configuration
The `markdown2resource` block supports the following options.
`files` - a list of `RegularFile` in Markdown.
`packageName` - the package name to use for the generated resources. Default is the `android.namespace` of the build variant.
`resourceClassName` - the outer class name to use for the generated resources. Default is `markdownR`.
`stringClassName` - the inner class name to use for the generated resources. Default is `html`.
## Tasks
The plugin creates N tasks, one for each configured build variant, named `markdown2resource${variant.name.capitalized()}`
For example:
1. You have defined `debug` and `release` build types, two tasks will be created, `markdown2resourceDebug` and `markdown2resourceRelease`.
2. You have defined `debug` and `release` build types and `demo` and `full` product flavours, four tasks will be created, `markdown2resourceDemoDebug`, `markdown2resourceDemoRelease`, `markdown2resourceFullDebug`, `markdown2resourceFullRelease`.

View File

@ -0,0 +1,50 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
`java-gradle-plugin`
`maven-publish`
kotlin("jvm") version "1.9.10"
}
repositories {
google()
mavenCentral()
}
group = "app.pachli.plugins"
version = "0.0.1"
gradlePlugin {
plugins {
create("markdown2resource") {
id = "app.pachli.plugins.markdown2resource"
implementationClass = "app.pachli.plugins.markdown2resource.Markdown2ResourcePlugin"
}
}
}
dependencies {
implementation("com.android.tools.build:gradle:8.1.1")
implementation("org.jetbrains:markdown:0.5.0")
implementation("com.squareup:javapoet:1.12.1")
}
kotlin {
jvmToolchain(17)
}

View File

@ -0,0 +1,156 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.plugins.markdown2resource
import com.android.build.gradle.AppExtension
import com.squareup.javapoet.FieldSpec
import com.squareup.javapoet.JavaFile
import com.squareup.javapoet.TypeSpec
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.configurationcache.extensions.capitalized
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.html.HtmlGenerator
import org.intellij.markdown.parser.MarkdownParser
import java.io.IOException
import javax.lang.model.element.Modifier
interface Markdown2ResourcePluginExtension {
/** List of files */
val files: ListProperty<RegularFile>
/** Class name for the generated resources. Default is "markdownR" */
val resourceClassName: Property<String>
/** Class name for the generated strings. Default is "html". */
val stringClassName: Property<String>
/**
* Package name for the generated class. Default is the value of the android.namespace
* property.
*/
val packageName: Property<String>
}
abstract class Markdown2ResourceTask : DefaultTask() {
@get:InputFiles
abstract val files: ListProperty<RegularFile>
@get:Input
abstract val resourceClassName: Property<String>
@get:Input
abstract val stringClassName: Property<String>
@get:Input
abstract val packageName: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun execute() {
logger.info("outputDir: ${outputDir.get()}")
val resourceClass = createResourceClass(resourceClassName.get())!!
val stringClass = createStringClass(stringClassName.get())!!
val flavour = GFMFlavourDescriptor()
files.get().forEach { markdownFile ->
logger.info("Processing ${markdownFile.asFile.absoluteFile.name}")
val f = markdownFile.asFile.readText()
val parsedTree = MarkdownParser(flavour).buildMarkdownTreeFromString(f)
val html = HtmlGenerator(f, parsedTree, flavour).generateHtml()
val resourceName = markdownFile.asFile.absoluteFile.name.replace("""[./\\]""".toRegex(), "_", )
logger.info(" Resource name: ${resourceClassName.get()}.${stringClassName.get()}.$resourceName")
stringClass.addField(
FieldSpec.builder(String::class.java, resourceName)
.addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL)
.initializer("\$S", html)
.build(),
)
resourceClass.addType(stringClass.build())
}
generateStringResourceFile(packageName.get(), resourceClass)
}
private fun createResourceClass(name: String): TypeSpec.Builder? {
return TypeSpec.classBuilder(name).addModifiers(Modifier.PUBLIC, Modifier.FINAL)
}
private fun createStringClass(name: String): TypeSpec.Builder? {
return TypeSpec.classBuilder(name).addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL)
}
private fun generateStringResourceFile(packageName: String, classBuilder: TypeSpec.Builder) {
val javaFile = JavaFile.builder(packageName, classBuilder.build()).build()
try {
javaFile.writeTo(outputDir.get().asFile)
logger.info(javaFile.toString())
} catch (e: IOException) {
e.printStackTrace()
}
}
}
class Markdown2ResourcePlugin : Plugin<Project> {
override fun apply(target: Project) {
val extension = target.extensions.create(
"markdown2resource",
Markdown2ResourcePluginExtension::class.java
)
extension.resourceClassName.convention("markdownR")
extension.stringClassName.convention("html")
target.extensions.findByType(AppExtension::class.java)?.let { appExtension ->
appExtension.applicationVariants.all { variant ->
val outputDir =
target.layout.buildDirectory.dir("generated/source/${variant.name}")
val taskName = "markdown2resource${variant.name.capitalized()}"
extension.packageName.convention(variant.mergeResourcesProvider.get().namespace)
val task =
target.tasks.register(taskName, Markdown2ResourceTask::class.java) { task ->
task.files.set(extension.files)
task.resourceClassName.set(extension.resourceClassName)
task.stringClassName.set(extension.stringClassName)
task.packageName.set(extension.packageName)
task.outputDir.set(outputDir)
}
variant.registerJavaGeneratingTask(task, outputDir.get().asFile)
}
} ?: throw GradleException("'android' configuration block not found")
}
}

View File

@ -3,6 +3,8 @@ pluginManagement {
google() google()
gradlePluginPortal() gradlePluginPortal()
} }
includeBuild 'plugins/markdown2resource'
} }
dependencyResolutionManagement { dependencyResolutionManagement {