From 9b656434bde0f735b919a7a3b7ce10c00737cb80 Mon Sep 17 00:00:00 2001 From: M66B Date: Wed, 17 Apr 2024 12:53:07 +0200 Subject: [PATCH] PoC rule expressions --- ATTRIBUTION.md | 1 + app/build.gradle | 4 + app/proguard-rules.pro | 3 + app/src/main/assets/ATTRIBUTION.md | 1 + .../java/eu/faircode/email/EntityRule.java | 115 +++++++++++++++++- .../java/eu/faircode/email/FragmentRule.java | 18 ++- app/src/main/res/layout/fragment_rule.xml | 50 +++++++- app/src/main/res/values/strings.xml | 1 + 8 files changed, 189 insertions(+), 4 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 148e645934..a21b07872f 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -57,3 +57,4 @@ FairEmail uses parts or all of: * [ZXing](https://github.com/zxing/zxing). Copyright (C) 2014 ZXing authors. [Apache License 2.0](https://github.com/zxing/zxing/blob/master/LICENSE). * [commonmark-java](https://github.com/commonmark/commonmark-java). Copyright (c) 2015, Atlassian Pty Ltd. All rights reserved. [BSD-2-Clause license](https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt). * [flexmark-java](https://github.com/vsch/flexmark-java). Copyright (c) 2016-2018, Vladimir Schneider. All rights reserved. [BSD-2-Clause license](https://github.com/vsch/flexmark-java/blob/master/LICENSE.txt). +* [EvalEx](https://github.com/ezylang/EvalEx). Copyright 2012-2022 Udo Klimaschewski. [Apache License 2.0](https://github.com/ezylang/EvalEx/blob/main/LICENSE). diff --git a/app/build.gradle b/app/build.gradle index 7ed5862b75..4c90f06d6b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -587,6 +587,7 @@ dependencies { def ws_version = "2.14" def tinylog_version = "2.6.2" def zxing_version = "3.5.3" + def evalex_version = "3.2.0" // https://mvnrepository.com/artifact/com.android.tools/desugar_jdk_libs?repo=google coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugar_version" @@ -857,4 +858,7 @@ dependencies { // https://github.com/zxing/zxing // https://mvnrepository.com/artifact/com.google.zxing/core implementation "com.google.zxing:core:$zxing_version" + + // https://github.com/ezylang/EvalEx + implementation "com.ezylang:EvalEx:$evalex_version" } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index b1a4226b20..d3e91784e9 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -161,3 +161,6 @@ -dontwarn java.lang.** -dontwarn javax.naming.** -dontwarn sun.reflect.Reflection + +#EvalEx +-dontwarn lombok.Generated \ No newline at end of file diff --git a/app/src/main/assets/ATTRIBUTION.md b/app/src/main/assets/ATTRIBUTION.md index 148e645934..a21b07872f 100644 --- a/app/src/main/assets/ATTRIBUTION.md +++ b/app/src/main/assets/ATTRIBUTION.md @@ -57,3 +57,4 @@ FairEmail uses parts or all of: * [ZXing](https://github.com/zxing/zxing). Copyright (C) 2014 ZXing authors. [Apache License 2.0](https://github.com/zxing/zxing/blob/master/LICENSE). * [commonmark-java](https://github.com/commonmark/commonmark-java). Copyright (c) 2015, Atlassian Pty Ltd. All rights reserved. [BSD-2-Clause license](https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt). * [flexmark-java](https://github.com/vsch/flexmark-java). Copyright (c) 2016-2018, Vladimir Schneider. All rights reserved. [BSD-2-Clause license](https://github.com/vsch/flexmark-java/blob/master/LICENSE.txt). +* [EvalEx](https://github.com/ezylang/EvalEx). Copyright 2012-2022 Udo Klimaschewski. [Apache License 2.0](https://github.com/ezylang/EvalEx/blob/main/LICENSE). diff --git a/app/src/main/java/eu/faircode/email/EntityRule.java b/app/src/main/java/eu/faircode/email/EntityRule.java index dbd4baea74..263e4b07eb 100644 --- a/app/src/main/java/eu/faircode/email/EntityRule.java +++ b/app/src/main/java/eu/faircode/email/EntityRule.java @@ -21,6 +21,8 @@ package eu.faircode.email; import static androidx.room.ForeignKey.CASCADE; +import static com.ezylang.evalex.operators.OperatorIfc.OPERATOR_PRECEDENCE_COMPARISON; + import android.Manifest; import android.content.ContentResolver; import android.content.Context; @@ -41,6 +43,15 @@ import androidx.room.ForeignKey; import androidx.room.Index; import androidx.room.PrimaryKey; +import com.ezylang.evalex.EvaluationException; +import com.ezylang.evalex.Expression; +import com.ezylang.evalex.config.ExpressionConfiguration; +import com.ezylang.evalex.data.EvaluationValue; +import com.ezylang.evalex.operators.AbstractOperator; +import com.ezylang.evalex.operators.InfixOperator; +import com.ezylang.evalex.parser.ParseException; +import com.ezylang.evalex.parser.Token; + import org.json.JSONException; import org.json.JSONObject; import org.jsoup.nodes.Document; @@ -62,6 +73,7 @@ import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.regex.Matcher; @@ -151,6 +163,10 @@ public class EntityRule { private static final int MAX_NOTES_LENGTH = 512; // characters private static final int URL_TIMEOUT = 15 * 1000; // milliseconds + private static final List EXPR_VARIABLES = Collections.unmodifiableList(Arrays.asList( + "sender", "subject" + )); + static boolean needsHeaders(EntityMessage message, List rules) { return needsHeaders(rules); } @@ -183,6 +199,16 @@ public class EntityRule { } return true; } + if (jcondition.has("expression")) { + Expression expression = getExpression(rule, null); + if (expression != null) + for (String variable : expression.getUsedVariables()) + if ("body".equals(what) && "body".equalsIgnoreCase(variable)) + return true; + else if ("header".equals(what) && "header".equalsIgnoreCase(variable)) + return true; + return false; + } } catch (Throwable ex) { Log.e(ex); } @@ -463,6 +489,19 @@ public class EntityRule { return false; } + Expression expression = getExpression(this, message); + if (expression != null) { + for (String variable : expression.getUsedVariables()) + if ("header".equalsIgnoreCase(variable) && message.headers == null) + throw new IllegalArgumentException(context.getString(R.string.title_rule_no_headers)); + + Log.i("EXPR evaluating " + jcondition.getString("expression")); + Boolean result = expression.evaluate().getBooleanValue(); + Log.i("EXPR evaluated=" + result); + if (!Boolean.TRUE.equals(result)) + return false; + } + // Safeguard if (jsender == null && jrecipient == null && @@ -472,9 +511,10 @@ public class EntityRule { jbody == null && jdate == null && jschedule == null && - !jcondition.has("younger")) + !jcondition.has("younger") && + !jcondition.has("expression")) return false; - } catch (JSONException ex) { + } catch (JSONException | ParseException | EvaluationException ex) { Log.e(ex); return false; } @@ -593,6 +633,58 @@ public class EntityRule { return matched; } + @InfixOperator(precedence = OPERATOR_PRECEDENCE_COMPARISON) + public static class ContainsOperator extends AbstractOperator { + @Override + public EvaluationValue evaluate( + Expression expression, Token operatorToken, EvaluationValue... operands) { + String op1 = operands[0].getStringValue(); + String op2 = operands[1].getStringValue(); + Log.i("EXPR " + op1 + " CONTAINS " + op2); + return expression.convertValue( + !TextUtils.isEmpty(op1) && !TextUtils.isEmpty(op2) && + op1.toLowerCase().contains(op2.toLowerCase())); + } + } + + @InfixOperator(precedence = OPERATOR_PRECEDENCE_COMPARISON) + public static class MatchesOperator extends AbstractOperator { + @Override + public EvaluationValue evaluate( + Expression expression, Token operatorToken, EvaluationValue... operands) { + String op1 = operands[0].getStringValue(); + String op2 = operands[1].getStringValue(); + Log.i("EXPR " + op1 + " MATCHES " + op2); + return expression.convertValue( + !TextUtils.isEmpty(op1) && !TextUtils.isEmpty(op2) && + Pattern.compile(op2, Pattern.DOTALL).matcher(op1).matches()); + } + } + + static Expression getExpression(EntityRule rule, EntityMessage message) throws JSONException { + // https://ezylang.github.io/EvalEx/ + + JSONObject jcondition = new JSONObject(rule.condition); + if (!jcondition.has("expression")) + return null; + + String sender = null; + String subject = null; + if (message != null) { + if (message.from != null && message.from.length == 1) + sender = MessageHelper.formatAddresses(message.from); + subject = message.subject; + } + + ExpressionConfiguration configuration = ExpressionConfiguration.defaultConfiguration(); + configuration.getOperatorDictionary().addOperator("CONTAINS", new ContainsOperator()); + configuration.getOperatorDictionary().addOperator("MATCHES", new MatchesOperator()); + + return new Expression(jcondition.getString("expression"), configuration) + .with("sender", sender) + .with("subject", subject); + } + boolean execute(Context context, EntityMessage message, String html) throws JSONException, IOException { boolean executed = _execute(context, message, html); if (this.id != null && executed) { @@ -655,6 +747,25 @@ public class EntityRule { } void validate(Context context) throws JSONException, IllegalArgumentException { + Expression expression = getExpression(this, null); + if (expression != null) + try { + for (String variable : expression.getUsedVariables()) { + Log.i("EXPR variable=" + variable); + if (!EXPR_VARIABLES.contains(variable)) + throw new IllegalArgumentException("Unknown variable '" + variable + "'"); + } + Log.i("EXPR validating"); + expression.validate(); + Log.i("EXPR validated"); + } catch (ParseException ex) { + Log.w("EXPR", ex); + String message = ex.getMessage(); + if (TextUtils.isEmpty(message)) + message = "Invalid expression"; + throw new IllegalArgumentException(message, ex); + } + JSONObject jargs = new JSONObject(action); int type = jargs.getInt("type"); diff --git a/app/src/main/java/eu/faircode/email/FragmentRule.java b/app/src/main/java/eu/faircode/email/FragmentRule.java index 7393f8e578..bf86061efc 100644 --- a/app/src/main/java/eu/faircode/email/FragmentRule.java +++ b/app/src/main/java/eu/faircode/email/FragmentRule.java @@ -133,6 +133,8 @@ public class FragmentRule extends FragmentBase { private CheckBox cbEveryDay; private EditText etYounger; + private EditText etExpression; + private Spinner spAction; private TextView tvActionRemark; @@ -184,6 +186,7 @@ public class FragmentRule extends FragmentBase { private ContentLoadingProgressBar pbWait; private Group grpReady; + private Group grpExpression; private Group grpAge; private Group grpSnooze; private Group grpFlag; @@ -333,6 +336,8 @@ public class FragmentRule extends FragmentBase { cbEveryDay = view.findViewById(R.id.cbEveryDay); etYounger = view.findViewById(R.id.etYounger); + etExpression = view.findViewById(R.id.etExpression); + spAction = view.findViewById(R.id.spAction); tvActionRemark = view.findViewById(R.id.tvActionRemark); @@ -385,6 +390,7 @@ public class FragmentRule extends FragmentBase { pbWait = view.findViewById(R.id.pbWait); grpReady = view.findViewById(R.id.grpReady); + grpExpression = view.findViewById(R.id.grpExpression); grpAge = view.findViewById(R.id.grpAge); grpSnooze = view.findViewById(R.id.grpSnooze); grpFlag = view.findViewById(R.id.grpFlag); @@ -854,6 +860,7 @@ public class FragmentRule extends FragmentBase { tvFolder.setText(null); bottom_navigation.setVisibility(View.GONE); grpReady.setVisibility(View.GONE); + grpExpression.setVisibility(View.GONE); grpAge.setVisibility(View.GONE); grpSnooze.setVisibility(View.GONE); grpFlag.setVisibility(View.GONE); @@ -1286,6 +1293,8 @@ public class FragmentRule extends FragmentBase { etYounger.setText(jcondition.has("younger") ? Integer.toString(jcondition.optInt("younger")) : null); + etExpression.setText(jcondition.optString("expression")); + spScheduleDayStart.setSelection(start / (24 * 60)); spScheduleDayEnd.setSelection(end / (24 * 60)); @@ -1423,6 +1432,7 @@ public class FragmentRule extends FragmentBase { Log.e(ex); } finally { grpReady.setVisibility(View.VISIBLE); + grpExpression.setVisibility(BuildConfig.DEBUG ? View.VISIBLE : View.GONE); grpAge.setVisibility(cbDaily.isChecked() ? View.VISIBLE : View.GONE); if (id < 0) bottom_navigation.getMenu().removeItem(R.id.action_delete); @@ -1561,7 +1571,9 @@ public class FragmentRule extends FragmentBase { jheader == null && jbody == null && jdate == null && - jschedule == null) + jschedule == null && + !jcondition.has("younger") && + !jcondition.has("expression")) throw new IllegalArgumentException(context.getString(R.string.title_rule_condition_missing)); if (TextUtils.isEmpty(order)) @@ -1729,6 +1741,10 @@ public class FragmentRule extends FragmentBase { Log.e(ex); } + String expression = etExpression.getText().toString().trim(); + if (!TextUtils.isEmpty(expression)) + jcondition.put("expression", expression); + return jcondition; } diff --git a/app/src/main/res/layout/fragment_rule.xml b/app/src/main/res/layout/fragment_rule.xml index 86517b47b8..48b8588637 100644 --- a/app/src/main/res/layout/fragment_rule.xml +++ b/app/src/main/res/layout/fragment_rule.xml @@ -818,6 +818,54 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tvYounger" /> + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@+id/etExpression" /> Relative time (received) between Every day Messages younger than (hours) + Expression Regex AND NOT