├── .github └── img │ └── share_screenshot.jpg ├── .gitignore ├── LICENSE ├── README.md ├── Shaarlier.iml ├── TODO.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── dimtion │ │ └── shaarlier │ │ └── network │ │ ├── NetworkUtilsTest.java │ │ └── RestAPINetworkManagerTest.java │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── dimtion │ │ └── shaarlier │ │ ├── activities │ │ ├── AccountsManagementActivity.java │ │ ├── AddAccountActivity.java │ │ ├── HttpSchemeHandlerActivity.java │ │ ├── MainActivity.java │ │ └── ShareActivity.java │ │ ├── dao │ │ ├── AccountsSource.java │ │ ├── MySQLiteHelper.java │ │ └── TagsSource.java │ │ ├── exceptions │ │ └── UnsupportedIntent.java │ │ ├── helper │ │ ├── AutoCompleteWrapper.java │ │ ├── DebugHelper.java │ │ ├── EncryptionHelper.java │ │ └── ShareIntentParser.java │ │ ├── models │ │ ├── Link.java │ │ ├── ShaarliAccount.java │ │ └── Tag.java │ │ ├── network │ │ ├── MockNetworkManager.java │ │ ├── NetworkManager.java │ │ ├── NetworkUtils.java │ │ ├── PasswordNetworkManager.java │ │ └── RestAPINetworkManager.java │ │ ├── services │ │ └── NetworkService.java │ │ └── utils │ │ ├── FuzzyArrayAdapter.java │ │ └── UserPreferences.java │ └── res │ ├── drawable-anydpi │ └── ic_send_white.xml │ ├── drawable-hdpi │ ├── ic_action_accept.png │ ├── ic_action_new.png │ ├── ic_launcher.png │ └── ic_send_white.png │ ├── drawable-mdpi │ ├── ic_action_accept.png │ ├── ic_action_new.png │ ├── ic_launcher.png │ └── ic_send_white.png │ ├── drawable-xhdpi │ ├── ic_action_accept.png │ ├── ic_action_new.png │ ├── ic_launcher.png │ └── ic_send_white.png │ ├── drawable-xxhdpi │ ├── ic_action_accept.png │ ├── ic_action_new.png │ ├── ic_launcher.png │ └── ic_send_white.png │ ├── drawable-xxxhdpi │ └── ic_launcher.png │ ├── drawable │ └── shaarli_icon.png │ ├── layout │ ├── activity_accounts_management.xml │ ├── activity_add_account.xml │ ├── activity_main.xml │ ├── activity_share.xml │ └── tags_list.xml │ ├── menu │ ├── menu_accounts_management.xml │ ├── menu_add_account.xml │ ├── menu_main.xml │ └── menu_share.xml │ ├── values-fr │ └── strings.xml │ ├── values-sk │ └── strings.xml │ ├── values-w820dp │ └── dimens.xml │ └── values │ ├── dimens.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.github/img/share_screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/.github/img/share_screenshot.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | *.apk 9 | *.aab 10 | *.log 11 | *Thumbs.db 12 | app/src/main/gen/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shaarlier 2 | 3 | ![GitHub](https://img.shields.io/github/license/dimtion/Shaarlier.svg) 4 | [![Codacy Badge](https://api.codacy.com/project/badge/grade/6228d636f0e44708b2739a35e8b9a2b0)](https://www.codacy.com/app/zizou-xena/Shaarlier) 5 | ![GitHub release](https://img.shields.io/github/release/dimtion/Shaarlier.svg) 6 | 7 | A simple Android app for publishing links on your Shaarli instance from android share menu 8 | 9 | ![Share screenshot](.github/img/share_screenshot.jpg) 10 | 11 | ## Installation 12 | - From the [Play Store](https://play.google.com/store/apps/details?id=com.dimtion.shaarlier) 13 | - From [F-Droid](https://f-droid.org/en/packages/com.dimtion.shaarlier/) 14 | - From the [release tab](https://github.com/dimtion/Shaarlier/releases) 15 | 16 | ## Features 17 | - Publish links on your Shaarli from the Android Share menu 18 | - Automatically add a title and a description 19 | - Optional dialog box for editing title, description or tags 20 | - Autocomplete tags 21 | - Edit existing shares 22 | - Multiple Shaarli accounts 23 | - Works either via Shaarli REST API or via username/password combo 24 | 25 | ## Planned features 26 | See [the next milestones](https://github.com/dimtion/Shaarlier/milestones) 27 | 28 | ## Links 29 | - Main and recommended fork of the Shaarli project: https://github.com/shaarli/Shaarli/ 30 | - Managed Shaarli instances using: https://my.framasoft.org/ 31 | - Thanks Jonathan Hedley for [jsoup](http://jsoup.org/) under [MIT Licence](http://jsoup.org/license) 32 | - Please swing by my personal [Shaarli](https://shaarli.dimtion.fr) 33 | 34 | -------- 35 | 36 | Software under [GPLv3](https://www.gnu.org/licenses/gpl.html) 37 | -------------------------------------------------------------------------------- /Shaarlier.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | 2 | ## To do ASAP 3 | - Improve automated testing 4 | 5 | ## Planned for future releases 6 | - Using new Shaarli API 7 | - Save link in a draft in case of an error 8 | - Feature: Handle redirection (E.g : shaarli.fr) 9 | 10 | ## Q&A 11 | - Try app on as many apps as possible 12 | - Try more device screen sizes 13 | - Try in low network conditions 14 | - Try on slow devices 15 | 16 | ## Long term evolutions (if requested) 17 | - See online Shaarli links 18 | - Save link later 19 | - Try to stay KISS 20 | 21 | [Any idea ?](https://github.com/dimtion/Shaarlier/issues) 22 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'org.jetbrains.kotlin.android' 3 | 4 | android { 5 | compileSdkVersion 33 6 | 7 | defaultConfig { 8 | applicationId "com.dimtion.shaarlier" 9 | minSdkVersion 15 10 | targetSdkVersion 33 11 | versionCode 33 12 | versionName "1.8.0" 13 | 14 | testApplicationId "com.dimtion.shaarliertest" 15 | testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled true 21 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 22 | } 23 | } 24 | namespace 'com.dimtion.shaarlier' 25 | } 26 | 27 | dependencies { 28 | implementation fileTree(dir: 'libs', include: ['*.jar']) 29 | implementation 'androidx.appcompat:appcompat:1.6.1' 30 | implementation 'org.jsoup:jsoup:1.11.3' 31 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 32 | implementation 'io.jsonwebtoken:jjwt:0.9.1' 33 | implementation 'me.xdrop:fuzzywuzzy:1.3.1' 34 | 35 | testImplementation 'junit:junit:4.13' 36 | implementation 'junit:junit:4.13' 37 | androidTestImplementation 'androidx.annotation:annotation:1.6.0' 38 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 39 | androidTestImplementation('androidx.test.uiautomator:uiautomator:2.3.0-alpha03') 40 | } 41 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in C:\Users\dimtion\AppData\Local\Android\Sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | -keep public class org.jsoup.** { 19 | public *; 20 | } 21 | 22 | -keepattributes InnerClasses 23 | 24 | -keep class io.jsonwebtoken.** { *; } 25 | -keepnames class io.jsonwebtoken.* { *; } 26 | -keepnames interface io.jsonwebtoken.* { *; } 27 | 28 | -keep class org.bouncycastle.** { *; } 29 | -keepnames class org.bouncycastle.** { *; } 30 | -dontwarn org.bouncycastle.** -------------------------------------------------------------------------------- /app/src/androidTest/java/com/dimtion/shaarlier/network/NetworkUtilsTest.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.network; 2 | 3 | import static org.junit.Assert.assertFalse; 4 | import static org.junit.Assert.assertTrue; 5 | 6 | import com.dimtion.shaarlier.network.NetworkUtils; 7 | 8 | public class NetworkUtilsTest { 9 | 10 | @org.junit.Test 11 | public void isUrl() { 12 | assertTrue(NetworkUtils.isUrl("https://dimtion.fr/")); 13 | assertTrue(NetworkUtils.isUrl("http://dimtion.fr/")); 14 | assertTrue(NetworkUtils.isUrl("http://127.0.0.1/")); 15 | 16 | assertFalse(NetworkUtils.isUrl("http://")); 17 | assertFalse(NetworkUtils.isUrl("two words")); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/dimtion/shaarlier/network/RestAPINetworkManagerTest.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.network; 2 | 3 | import com.dimtion.shaarlier.models.ShaarliAccount; 4 | 5 | import java.io.IOException; 6 | 7 | import static org.junit.Assert.assertNotNull; 8 | 9 | public class RestAPINetworkManagerTest { 10 | private ShaarliAccount mAccount; 11 | private RestAPINetworkManager mNetworkManager; 12 | 13 | @org.junit.Before 14 | public void setUp() throws Exception { 15 | this.mAccount = new ShaarliAccount(); 16 | this.mAccount.setRestAPIKey("azerty"); 17 | this.mNetworkManager = new RestAPINetworkManager(this.mAccount); 18 | } 19 | 20 | @org.junit.Test 21 | public void getJwt() throws IOException { 22 | assertNotNull(this.mAccount); 23 | String jwt = this.mNetworkManager.getJwt(); 24 | assertNotNull(jwt); 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 74 | 77 | 78 | 83 | 86 | 87 | 88 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/activities/AccountsManagementActivity.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.activities; 2 | 3 | import android.content.Intent; 4 | import android.database.SQLException; 5 | import android.os.Bundle; 6 | import android.util.Log; 7 | import android.view.Menu; 8 | import android.view.MenuItem; 9 | import android.view.View; 10 | import android.widget.AdapterView; 11 | import android.widget.ArrayAdapter; 12 | import android.widget.ListView; 13 | 14 | import androidx.appcompat.app.AppCompatActivity; 15 | 16 | import com.dimtion.shaarlier.R; 17 | import com.dimtion.shaarlier.dao.AccountsSource; 18 | import com.dimtion.shaarlier.models.ShaarliAccount; 19 | 20 | import java.util.List; 21 | 22 | 23 | public class AccountsManagementActivity extends AppCompatActivity { 24 | 25 | @Override 26 | protected void onCreate(Bundle savedInstanceState) { 27 | super.onCreate(savedInstanceState); 28 | setContentView(R.layout.activity_accounts_management); 29 | } 30 | 31 | @Override 32 | protected void onResume() { 33 | super.onResume(); 34 | final ListView accountsListView = findViewById(R.id.accountListView); 35 | AccountsSource accountsSource = new AccountsSource(getApplicationContext()); 36 | try { 37 | accountsSource.rOpen(); 38 | List accountsList = accountsSource.getAllAccounts(); 39 | ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, accountsList); 40 | 41 | accountsListView.setAdapter(adapter); 42 | 43 | if (accountsList.isEmpty()) 44 | findViewById(R.id.noAccountToShow).setVisibility(View.VISIBLE); 45 | else 46 | findViewById(R.id.noAccountToShow).setVisibility(View.GONE); 47 | 48 | 49 | } catch (SQLException e) { 50 | Log.e("DB_ERROR", e.toString()); 51 | } finally { 52 | accountsSource.close(); 53 | } 54 | accountsListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { 55 | @Override 56 | public void onItemClick(AdapterView arg0, View arg1, int position, long arg3) { 57 | ShaarliAccount clickedAccount = (ShaarliAccount) accountsListView.getItemAtPosition(position); 58 | 59 | addOrEditAccount(clickedAccount); 60 | } 61 | }); 62 | } 63 | 64 | private void addOrEditAccount(ShaarliAccount account) { 65 | Intent intent = new Intent(this, AddAccountActivity.class); 66 | if (account != null) { 67 | intent.putExtra("_id", account.getId()); 68 | Log.w("EDIT ACCOUNT", account.getShortName()); 69 | } 70 | startActivity(intent); 71 | } 72 | @Override 73 | public boolean onCreateOptionsMenu(Menu menu) { 74 | // Inflate the menu; this adds items to the action bar if it is present. 75 | getMenuInflater().inflate(R.menu.menu_accounts_management, menu); 76 | return true; 77 | } 78 | 79 | @Override 80 | public boolean onOptionsItemSelected(MenuItem item) { 81 | // Handle action bar item clicks here. The action bar will 82 | // automatically handle clicks on the Home/Up button, so long 83 | // as you specify a parent activity in AndroidManifest.xml. 84 | int id = item.getItemId(); 85 | 86 | //noinspection SimplifiableIfStatement 87 | if (id == R.id.action_add) { 88 | addOrEditAccount(null); 89 | } 90 | 91 | return super.onOptionsItemSelected(item); 92 | } 93 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/activities/AddAccountActivity.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.activities; 2 | 3 | import android.app.Activity; 4 | import android.app.AlertDialog; 5 | import android.content.Context; 6 | import android.content.DialogInterface; 7 | import android.content.Intent; 8 | import android.content.SharedPreferences; 9 | import android.os.Bundle; 10 | import android.os.Handler; 11 | import android.os.Message; 12 | import android.os.Messenger; 13 | import android.util.Log; 14 | import android.view.Menu; 15 | import android.view.MenuItem; 16 | import android.view.View; 17 | import android.view.inputmethod.InputMethodManager; 18 | import android.widget.Button; 19 | import android.widget.CheckBox; 20 | import android.widget.EditText; 21 | import android.widget.Switch; 22 | import android.widget.Toast; 23 | 24 | import androidx.appcompat.app.AppCompatActivity; 25 | 26 | import com.dimtion.shaarlier.R; 27 | import com.dimtion.shaarlier.dao.AccountsSource; 28 | import com.dimtion.shaarlier.helper.DebugHelper; 29 | import com.dimtion.shaarlier.models.ShaarliAccount; 30 | import com.dimtion.shaarlier.network.NetworkUtils; 31 | import com.dimtion.shaarlier.services.NetworkService; 32 | 33 | import java.util.List; 34 | 35 | 36 | public class AddAccountActivity extends AppCompatActivity { 37 | 38 | private String urlShaarli; 39 | private String username; 40 | private String password; 41 | private String restAPIKey; 42 | private String basicAuthUsername; 43 | private String basicAuthPassword; 44 | private String shortName; 45 | private ShaarliAccount account; 46 | private Boolean isDefaultAccount; 47 | private Boolean isValidateCert; 48 | private int authMethod; 49 | 50 | private Boolean isEditing = false; 51 | 52 | @Override 53 | protected void onCreate(Bundle savedInstanceState) { 54 | super.onCreate(savedInstanceState); 55 | setContentView(R.layout.activity_add_account); 56 | 57 | Intent intent = getIntent(); 58 | long accountId = intent.getLongExtra("_id", -1); 59 | 60 | if (accountId != -1) { 61 | isEditing = true; 62 | AccountsSource accountsSource = new AccountsSource(getApplicationContext()); 63 | try { 64 | account = accountsSource.getShaarliAccountById(accountId); 65 | } catch (Exception e) { // Editing an account that does not exist, creating a new one 66 | account = null; 67 | } 68 | fillFields(); 69 | } else { 70 | AccountsSource source = new AccountsSource(getApplicationContext()); 71 | source.rOpen(); 72 | List allAccounts = source.getAllAccounts(); 73 | if (allAccounts.isEmpty()) { // If it is the first account created 74 | CheckBox defaultCheck = findViewById(R.id.defaultAccountCheck); 75 | defaultCheck.setChecked(true); 76 | defaultCheck.setEnabled(false); 77 | } 78 | } 79 | } 80 | 81 | /** 82 | * Fill the fields with the selected account when editing a new account 83 | */ 84 | private void fillFields() { 85 | // Get the user inputs : 86 | ((EditText) findViewById(R.id.urlShaarliView)).setText(account.getUrlShaarli()); 87 | ((EditText) findViewById(R.id.usernameView)).setText(account.getUsername()); 88 | ((EditText) findViewById(R.id.passwordView)).setText(account.getPassword()); 89 | ((EditText) findViewById(R.id.shortNameView)).setText(account.getShortName()); 90 | ((EditText) findViewById(R.id.restapiView)).setText(account.getRestAPIKey()); 91 | 92 | ((Switch) findViewById(R.id.passwordAuthCheckbox)).setChecked( 93 | !(account.getRestAPIKey().length() > 0) 94 | ); 95 | togglePasswordAuth(findViewById(R.id.passwordAuthCheckbox)); 96 | 97 | if (!"".equals(account.getBasicAuthUsername())) { 98 | ((EditText) findViewById(R.id.basicUsernameView)).setText(account.getBasicAuthUsername()); 99 | ((EditText) findViewById(R.id.basicPasswordView)).setText(account.getBasicAuthPassword()); 100 | ((Switch) findViewById(R.id.basicAuthSwitch)).setChecked(true); 101 | enableBasicAuth(findViewById(R.id.basicAuthSwitch)); 102 | } 103 | 104 | // default account? 105 | SharedPreferences prefs = getSharedPreferences(getString(R.string.params), MODE_PRIVATE); 106 | this.isDefaultAccount = (prefs.getLong(getString(R.string.p_default_account), -1) == account.getId()); 107 | ((CheckBox) findViewById(R.id.defaultAccountCheck)).setChecked(this.isDefaultAccount); 108 | 109 | findViewById(R.id.deleteAccountButton).setVisibility(View.VISIBLE); 110 | } 111 | 112 | /** 113 | * Action which handle the press on the try and save button 114 | * 115 | * @param view: needed for binding with interface actions 116 | */ 117 | public void tryAndSaveAction(View view) { 118 | hideKeyboard(); 119 | findViewById(R.id.tryingConfSpinner).setVisibility(View.VISIBLE); 120 | findViewById(R.id.tryConfButton).setVisibility(View.GONE); 121 | 122 | // Get the user inputs: 123 | final String urlShaarliInput = ((EditText) findViewById(R.id.urlShaarliView)).getText().toString(); 124 | this.username = ((EditText) findViewById(R.id.usernameView)).getText().toString(); 125 | this.password = ((EditText) findViewById(R.id.passwordView)).getText().toString(); 126 | this.restAPIKey = ((EditText) findViewById(R.id.restapiView)).getText().toString(); 127 | if (((Switch) findViewById(R.id.passwordAuthCheckbox)).isChecked()) { 128 | this.authMethod = ShaarliAccount.AUTH_METHOD_PASSWORD; 129 | } else { 130 | this.authMethod = ShaarliAccount.AUTH_METHOD_RESTAPI; 131 | } 132 | if (((Switch) findViewById(R.id.basicAuthSwitch)).isChecked()) { 133 | this.basicAuthUsername = ((EditText) findViewById(R.id.basicUsernameView)).getText().toString(); 134 | this.basicAuthPassword = ((EditText) findViewById(R.id.basicPasswordView)).getText().toString(); 135 | } else { 136 | this.basicAuthUsername = ""; 137 | this.basicAuthPassword = ""; 138 | } 139 | this.shortName = ((EditText) findViewById(R.id.shortNameView)).getText().toString(); 140 | this.isDefaultAccount = ((CheckBox) findViewById(R.id.defaultAccountCheck)).isChecked(); 141 | this.isValidateCert = !((CheckBox) findViewById(R.id.disableCertValidation)).isChecked(); 142 | 143 | this.urlShaarli = NetworkUtils.toUrl(urlShaarliInput); 144 | 145 | ((EditText) findViewById(R.id.urlShaarliView)).setText(this.urlShaarli); // Update the view 146 | 147 | // Depending on the 148 | // Create a fake account: 149 | ShaarliAccount accountToTest = new ShaarliAccount(); 150 | accountToTest.setUrlShaarli(this.urlShaarli); 151 | accountToTest.setUsername(this.username); 152 | accountToTest.setPassword(this.password); 153 | accountToTest.setRestAPIKey(this.restAPIKey); 154 | accountToTest.setValidateCert(this.isValidateCert); 155 | accountToTest.setBasicAuthUsername(this.basicAuthUsername); 156 | accountToTest.setBasicAuthPassword(this.basicAuthPassword); 157 | accountToTest.setAuthMethod(this.authMethod); 158 | 159 | // Try the configuration 160 | Intent i = new Intent(this, NetworkService.class); 161 | i.putExtra("action", NetworkService.INTENT_CHECK); 162 | i.putExtra("account", accountToTest); 163 | i.putExtra(NetworkService.EXTRA_MESSENGER, new Messenger(new networkHandler(this, accountToTest))); 164 | startService(i); 165 | } 166 | 167 | /** 168 | * Handle the action of deletion: show a confirmation dialog then delete (if wanted) 169 | * @param view : The view needed for handling interface actions 170 | */ 171 | public void deleteAccountAction(View view) { 172 | // Show dialog to confirm deletion 173 | AlertDialog.Builder builder = new AlertDialog.Builder(this); 174 | builder.setMessage(getString(R.string.text_confirm_deletion_account)); 175 | builder.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { 176 | @Override 177 | public void onClick(DialogInterface dialog, int which) { 178 | deleteAccount(); 179 | finish(); 180 | } 181 | }); 182 | 183 | builder.setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() { 184 | public void onClick(DialogInterface dialog, int which) { 185 | // Just dismiss the dialog 186 | } 187 | }); 188 | builder.show(); 189 | } 190 | 191 | /** 192 | * Delete the selected account from the database 193 | */ 194 | private void deleteAccount() { 195 | AccountsSource source = new AccountsSource(getApplicationContext()); 196 | source.wOpen(); 197 | source.deleteAccount(this.account); 198 | source.close(); 199 | } 200 | 201 | @Override 202 | public boolean onCreateOptionsMenu(Menu menu) { 203 | // Inflate the menu; this adds items to the action bar if it is present. 204 | getMenuInflater().inflate(R.menu.menu_add_account, menu); 205 | return true; 206 | } 207 | 208 | @Override 209 | public boolean onOptionsItemSelected(MenuItem item) { 210 | // Handle action bar item clicks here. The action bar will 211 | // automatically handle clicks on the Home/Up button, so long 212 | // as you specify a parent activity in AndroidManifest.xml. 213 | int id = item.getItemId(); 214 | 215 | //noinspection SimplifiableIfStatement 216 | if (id == R.id.action_validate) { 217 | tryAndSaveAction(findViewById(id)); 218 | } 219 | 220 | return super.onOptionsItemSelected(item); 221 | } 222 | 223 | /** 224 | * Obviously hide the keyboard 225 | * From: http://stackoverflow.com/a/7696791/1582589 226 | */ 227 | private void hideKeyboard() { 228 | // Check if no view has focus: 229 | View view = this.getCurrentFocus(); 230 | if (view != null) { 231 | InputMethodManager inputManager = (InputMethodManager) this.getSystemService(Context.INPUT_METHOD_SERVICE); 232 | inputManager.hideSoftInputFromWindow(view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); 233 | } 234 | } 235 | 236 | public void enableBasicAuth(View toggle) { 237 | boolean checked = ((Switch) toggle).isChecked(); 238 | findViewById(R.id.basicUsernameView).setEnabled(checked); 239 | findViewById(R.id.basicPasswordView).setEnabled(checked); 240 | findViewById(R.id.basicUsernameView).setVisibility(checked ? View.VISIBLE : View.GONE); 241 | findViewById(R.id.basicPasswordView).setVisibility(checked ? View.VISIBLE : View.GONE); 242 | } 243 | 244 | public void togglePasswordAuth(View toggle) { 245 | boolean checked = ((Switch) toggle).isChecked(); 246 | findViewById(R.id.usernameView).setEnabled(checked); 247 | findViewById(R.id.usernameView).setVisibility(checked ? View.VISIBLE : View.GONE); 248 | 249 | findViewById(R.id.passwordView).setEnabled(checked); 250 | findViewById(R.id.passwordView).setVisibility(checked ? View.VISIBLE : View.GONE); 251 | 252 | findViewById(R.id.basicAuthSwitch).setEnabled(checked); 253 | findViewById(R.id.basicAuthSwitch).setVisibility(checked ? View.VISIBLE : View.GONE); 254 | 255 | findViewById(R.id.restapiView).setEnabled(!checked); 256 | findViewById(R.id.restapiView).setVisibility(!checked ? View.VISIBLE : View.GONE); 257 | } 258 | 259 | /** 260 | * Save a new account into the database, 261 | * should be called only if the account was verified. 262 | */ 263 | private void saveAccount() { 264 | AccountsSource accountsSource = new AccountsSource(getApplicationContext()); 265 | accountsSource.wOpen(); 266 | // Since we do not yet save in the database the auth method, we rely on ShaarliAccount.AUTH_METHOD_AUTO 267 | // for publishing. 268 | // TODO: add to database saved auth method 269 | switch (authMethod) { 270 | case ShaarliAccount.AUTH_METHOD_PASSWORD: 271 | this.restAPIKey = ""; 272 | break; 273 | case ShaarliAccount.AUTH_METHOD_RESTAPI: 274 | this.username = ""; 275 | this.password = ""; 276 | break; 277 | } 278 | 279 | try { 280 | if (isEditing) { // Only update the database 281 | account.setUrlShaarli(this.urlShaarli); 282 | account.setUsername(this.username); 283 | account.setPassword(this.password); 284 | account.setBasicAuthUsername(this.basicAuthUsername); 285 | account.setBasicAuthPassword(this.basicAuthPassword); 286 | account.setShortName(this.shortName); 287 | account.setValidateCert(this.isValidateCert); 288 | account.setRestAPIKey(this.restAPIKey); 289 | accountsSource.editAccount(account); 290 | } else { 291 | this.account = accountsSource.createAccount( 292 | this.urlShaarli, 293 | this.username, 294 | this.password, 295 | this.basicAuthUsername, 296 | this.basicAuthPassword, 297 | this.shortName, 298 | this.isValidateCert, 299 | this.restAPIKey 300 | ); 301 | } 302 | } catch (Exception e) { 303 | Log.e("ENCRYPTION ERROR", e.getMessage()); 304 | } finally { 305 | accountsSource.close(); 306 | } 307 | 308 | // Set the default account if needed 309 | if (this.isDefaultAccount) { 310 | SharedPreferences prefs = getSharedPreferences(getString(R.string.params), MODE_PRIVATE); 311 | SharedPreferences.Editor editor = prefs.edit(); 312 | 313 | editor.putLong(getString(R.string.p_default_account), this.account.getId()); 314 | editor.apply(); 315 | } 316 | } 317 | 318 | private class networkHandler extends Handler { 319 | private final Activity mParent; 320 | private final ShaarliAccount mAccount; 321 | 322 | public networkHandler(Activity parent, ShaarliAccount account) { 323 | this.mParent = parent; 324 | this.mAccount = account; 325 | } 326 | 327 | /** 328 | * Handle the arrival of a message coming from the network service. 329 | * 330 | * @param msg the message given by the service 331 | */ 332 | @Override 333 | public void handleMessage(Message msg) { 334 | findViewById(R.id.tryConfButton).setVisibility(View.VISIBLE); 335 | findViewById(R.id.tryingConfSpinner).setVisibility(View.GONE); 336 | 337 | // Show the returned error 338 | switch (msg.arg1) { 339 | case NetworkService.NO_ERROR: 340 | Toast.makeText(getApplicationContext(), R.string.success_test, Toast.LENGTH_LONG).show(); 341 | saveAccount(); 342 | finish(); 343 | break; 344 | case NetworkService.NETWORK_ERROR: 345 | ((EditText) findViewById(R.id.urlShaarliView)).setError(getString(R.string.error_connecting)); 346 | enableSendReport((Exception) msg.obj); 347 | break; 348 | case NetworkService.TOKEN_ERROR: 349 | ((EditText) findViewById(R.id.urlShaarliView)).setError(getString(R.string.error_parsing_token)); 350 | enableSendReport(new Exception("TOKEN ERROR")); 351 | break; 352 | case NetworkService.LOGIN_ERROR: 353 | ((EditText) findViewById(R.id.usernameView)).setError(getString(R.string.error_login)); 354 | ((EditText) findViewById(R.id.passwordView)).setError(getString(R.string.error_login)); 355 | ((EditText) findViewById(R.id.restapiView)).setError(getString(R.string.error_login)); 356 | enableSendReport(new Exception("LOGIN ERROR")); 357 | break; 358 | default: 359 | ((EditText) findViewById(R.id.urlShaarliView)).setError(getString(R.string.error_unknown)); 360 | Toast.makeText(getApplicationContext(), R.string.error_unknown, Toast.LENGTH_LONG).show(); 361 | enableSendReport(new Exception("UNKNOWN ERROR")); 362 | break; 363 | } 364 | } 365 | 366 | private void enableSendReport(final Exception error) { 367 | Button reportButton = findViewById(R.id.sendReportButton); 368 | reportButton.setVisibility(View.VISIBLE); 369 | reportButton.setOnClickListener(new View.OnClickListener() { 370 | @Override 371 | public void onClick(View v) { 372 | AlertDialog.Builder builder = new AlertDialog.Builder(mParent); 373 | 374 | builder.setMessage(R.string.report_issue).setTitle("REPORT - Shaarlier"); 375 | 376 | String extra = "Url Shaarli: " + urlShaarli + "\n"; 377 | extra += "Auth method id: " + mAccount.getAuthMethod() + "\n"; 378 | extra += "Validate cert: " + mAccount.isValidateCert() + "\n"; 379 | extra += "Basic auth: " + ((mAccount.getBasicAuthUsername().length() > 0) || (mAccount.getBasicAuthPassword().length() > 0)) + "\n"; 380 | 381 | final String finalExtra = extra; 382 | builder.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { 383 | public void onClick(DialogInterface dialog, int id) { 384 | DebugHelper.sendMailDev( 385 | mParent, 386 | "REPORT - Shaarlier", 387 | DebugHelper.generateReport(error, mParent, finalExtra) 388 | ); 389 | } 390 | }); 391 | builder.setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() { 392 | public void onClick(DialogInterface dialog, int id) { 393 | // User cancelled the dialog 394 | } 395 | }); 396 | AlertDialog dialog = builder.create(); 397 | dialog.show(); 398 | } 399 | }); 400 | } 401 | } 402 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/activities/HttpSchemeHandlerActivity.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.activities; 2 | 3 | import android.app.Activity; 4 | import android.content.Intent; 5 | import android.net.Uri; 6 | import android.os.Bundle; 7 | import android.widget.Toast; 8 | 9 | import com.dimtion.shaarlier.R; 10 | 11 | public class HttpSchemeHandlerActivity extends Activity { 12 | 13 | @Override 14 | protected void onCreate(Bundle savedInstanceState) { 15 | super.onCreate(savedInstanceState); 16 | 17 | Uri data = getIntent().getData(); 18 | if(data != null) { 19 | String url = data.toString(); 20 | 21 | Intent addActivityIntent = new Intent(this, ShareActivity.class); 22 | addActivityIntent.setAction(Intent.ACTION_SEND); 23 | addActivityIntent.setType("text/plain"); 24 | addActivityIntent.putExtra(Intent.EXTRA_TEXT, url); 25 | startActivity(addActivityIntent); 26 | } else { 27 | Toast.makeText(getApplicationContext(), R.string.add_not_handle, Toast.LENGTH_SHORT).show(); 28 | } 29 | 30 | finish(); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/activities/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.activities; 2 | 3 | import android.app.AlertDialog; 4 | import android.content.ComponentName; 5 | import android.content.DialogInterface; 6 | import android.content.Intent; 7 | import android.content.SharedPreferences; 8 | import android.content.pm.PackageManager; 9 | import android.net.Uri; 10 | import android.os.Build; 11 | import android.os.Bundle; 12 | import android.text.InputType; 13 | import android.text.method.LinkMovementMethod; 14 | import android.view.Menu; 15 | import android.view.MenuItem; 16 | import android.view.View; 17 | import android.view.ViewGroup; 18 | import android.widget.Button; 19 | import android.widget.CheckBox; 20 | import android.widget.EditText; 21 | import android.widget.LinearLayout; 22 | import android.widget.TextView; 23 | 24 | import androidx.appcompat.app.AppCompatActivity; 25 | 26 | import com.dimtion.shaarlier.R; 27 | import com.dimtion.shaarlier.dao.AccountsSource; 28 | import com.dimtion.shaarlier.models.ShaarliAccount; 29 | 30 | import java.util.List; 31 | 32 | 33 | public class MainActivity extends AppCompatActivity { 34 | private boolean m_isNoAccount; 35 | 36 | @Override 37 | protected void onCreate(Bundle savedInstanceState) { 38 | super.onCreate(savedInstanceState); 39 | setContentView(R.layout.activity_main); 40 | 41 | // Make links clickable : 42 | ((TextView) findViewById(R.id.about_details)).setMovementMethod(LinkMovementMethod.getInstance()); 43 | 44 | loadSettings(); 45 | 46 | // Load custom design : 47 | TextView textVersion = findViewById(R.id.text_version); 48 | try { 49 | String versionName = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; 50 | textVersion.setText(String.format(getString(R.string.version), versionName)); 51 | 52 | } catch (PackageManager.NameNotFoundException e) { 53 | textVersion.setText(getText(R.string.text_version)); 54 | } 55 | } 56 | 57 | @Override 58 | public void onResume() { 59 | super.onResume(); 60 | AccountsSource accountsSource = new AccountsSource(getApplicationContext()); 61 | accountsSource.rOpen(); 62 | m_isNoAccount = accountsSource.getAllAccounts().isEmpty(); 63 | accountsSource.close(); 64 | 65 | Button manageAccountsButton = findViewById(R.id.button_manage_accounts); 66 | if (m_isNoAccount) { 67 | manageAccountsButton.setText(R.string.add_account); 68 | } else { 69 | manageAccountsButton.setText(R.string.button_manage_accounts); 70 | } 71 | } 72 | 73 | @Override 74 | public void onPause() { 75 | super.onPause(); 76 | saveSettings(); 77 | } 78 | 79 | private void saveSettings() { 80 | // Get user inputs : 81 | boolean isPrivate = ((CheckBox) findViewById(R.id.default_private)).isChecked(); 82 | boolean isShareDialog = ((CheckBox) findViewById(R.id.show_share_dialog)).isChecked(); 83 | boolean isAutoTitle = ((CheckBox) findViewById(R.id.auto_load_title)).isChecked(); 84 | boolean isAutoDescription = ((CheckBox) findViewById(R.id.auto_load_description)).isChecked(); 85 | boolean isHandlingHttpScheme = ((CheckBox) findViewById(R.id.handle_http_scheme)).isChecked(); 86 | boolean useShaarli2twitter = ((CheckBox) findViewById(R.id.handle_twitter_plugin)).isChecked(); 87 | boolean useShaarli2mastodon = ((CheckBox) findViewById(R.id.handle_mastodon_plugin)).isChecked(); 88 | 89 | // Save data : 90 | SharedPreferences pref = getSharedPreferences(getString(R.string.params), MODE_PRIVATE); 91 | SharedPreferences.Editor editor = pref.edit(); 92 | editor.putBoolean(getString(R.string.p_default_private), isPrivate) 93 | .putBoolean(getString(R.string.p_show_share_dialog), isShareDialog) 94 | .putBoolean(getString(R.string.p_auto_title), isAutoTitle) 95 | .putBoolean(getString(R.string.p_auto_description), isAutoDescription) 96 | .putBoolean(getString(R.string.p_shaarli2twitter), useShaarli2twitter) 97 | .putBoolean(getString(R.string.p_shaarli2mastodon), useShaarli2mastodon) 98 | .apply(); 99 | 100 | setHandleHttpScheme(isHandlingHttpScheme); 101 | } 102 | 103 | public void openAccountsManager(View view) { 104 | Intent intent; 105 | if (m_isNoAccount) { 106 | intent = new Intent(this, AddAccountActivity.class); 107 | } else { 108 | intent = new Intent(this, AccountsManagementActivity.class); 109 | 110 | } 111 | startActivity(intent); 112 | 113 | } 114 | 115 | private void loadSettings() { 116 | // Retrieve user previous settings 117 | SharedPreferences pref = getSharedPreferences(getString(R.string.params), MODE_PRIVATE); 118 | // updateSettingsFromUpdate(pref); 119 | 120 | boolean prv = pref.getBoolean(getString(R.string.p_default_private), false); 121 | boolean sherDial = pref.getBoolean(getString(R.string.p_show_share_dialog), true); 122 | boolean isAutoTitle = pref.getBoolean(getString(R.string.p_auto_title), true); 123 | boolean isAutoDescription = pref.getBoolean(getString(R.string.p_auto_description), false); 124 | boolean isHandlingHttpScheme = isHandlingHttpScheme(); 125 | boolean isShaarli2twitter = pref.getBoolean(getString(R.string.p_shaarli2twitter), false); 126 | boolean isShaarli2mastodon = pref.getBoolean(getString(R.string.p_shaarli2mastodon), false); 127 | 128 | // Retrieve interface : 129 | CheckBox privateCheck = findViewById(R.id.default_private); 130 | CheckBox shareDialogCheck = findViewById(R.id.show_share_dialog); 131 | CheckBox autoTitleCheck = findViewById(R.id.auto_load_title); 132 | CheckBox autoDescriptionCheck = findViewById(R.id.auto_load_description); 133 | CheckBox handleHttpSchemeCheck = findViewById(R.id.handle_http_scheme); 134 | CheckBox shaarli2twitter = findViewById(R.id.handle_twitter_plugin); 135 | CheckBox shaarli2mastodon = findViewById(R.id.handle_mastodon_plugin); 136 | 137 | // Display user previous settings : 138 | privateCheck.setChecked(prv); 139 | autoTitleCheck.setChecked(isAutoTitle); 140 | autoDescriptionCheck.setChecked(isAutoDescription); 141 | handleHttpSchemeCheck.setChecked(isHandlingHttpScheme); 142 | shareDialogCheck.setChecked(sherDial); 143 | shaarli2twitter.setChecked(isShaarli2twitter); 144 | shaarli2mastodon.setChecked(isShaarli2mastodon); 145 | } 146 | 147 | @Override 148 | public boolean onCreateOptionsMenu(Menu menu) { 149 | // Inflate the menu; this adds items to the action bar if it is present. 150 | getMenuInflater().inflate(R.menu.menu_main, menu); 151 | if (m_isNoAccount) { 152 | menu.findItem(R.id.action_share).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); 153 | } else { 154 | menu.findItem(R.id.action_share).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); 155 | } 156 | 157 | return true; 158 | } 159 | 160 | @Override 161 | public boolean onOptionsItemSelected(MenuItem item) { 162 | int id = item.getItemId(); 163 | 164 | if (id == R.id.action_share) { 165 | shareDialog(); 166 | } else if (id == R.id.action_open_shaarli) { 167 | openShaarliDialog(); 168 | } else { 169 | return true; 170 | } 171 | return super.onOptionsItemSelected(item); 172 | } 173 | 174 | private void shareDialog() { 175 | AlertDialog.Builder alert = new AlertDialog.Builder(this); 176 | 177 | alert.setTitle(getString(R.string.share)); 178 | 179 | // TODO: move this to a xml file: 180 | final LinearLayout layout = new LinearLayout(this); 181 | 182 | final TextView textView = new TextView(this); 183 | if (Build.VERSION.SDK_INT < 23) { 184 | //noinspection deprecation 185 | textView.setTextAppearance(this, android.R.style.TextAppearance_Medium); 186 | } else { 187 | textView.setTextAppearance(android.R.style.TextAppearance_Medium); 188 | } 189 | textView.setText(getText(R.string.text_new_url)); 190 | 191 | // Set an EditText view to get user input 192 | final EditText input = new EditText(this); 193 | input.setInputType(InputType.TYPE_TEXT_VARIATION_URI); 194 | input.setHint(getText(R.string.hint_new_url)); 195 | 196 | layout.setOrientation(LinearLayout.VERTICAL); 197 | layout.setPadding(10, 10, 20, 20); 198 | 199 | layout.addView(textView); 200 | layout.addView(input); 201 | alert.setView(layout); 202 | 203 | alert.setPositiveButton(getString(android.R.string.yes), new DialogInterface.OnClickListener() { 204 | public void onClick(DialogInterface dialog, int whichButton) { 205 | String value = input.getText().toString(); 206 | Intent intent = new Intent(getBaseContext(), ShareActivity.class); 207 | intent.setAction(Intent.ACTION_SEND); 208 | intent.setType("text/plain"); 209 | intent.putExtra(Intent.EXTRA_TEXT, value); 210 | startActivity(intent); 211 | } 212 | }); 213 | 214 | alert.setNegativeButton(getString(android.R.string.no), new DialogInterface.OnClickListener() { 215 | public void onClick(DialogInterface dialog, int whichButton) { 216 | // Canceled. 217 | } 218 | }); 219 | 220 | alert.show(); 221 | } 222 | 223 | private void openShaarliDialog() { 224 | AccountsSource accountsSource = new AccountsSource(this); 225 | accountsSource.rOpen(); 226 | final List accounts = accountsSource.getAllAccounts(); 227 | accountsSource.close(); 228 | 229 | if (accounts == null || accounts.size() < 1) { 230 | return; 231 | } 232 | if (accounts.size() == 1) { 233 | Intent browserIntent = new Intent( 234 | Intent.ACTION_VIEW, 235 | Uri.parse(accounts.get(0).getUrlShaarli()) 236 | ); 237 | startActivity(browserIntent); 238 | } 239 | 240 | final AlertDialog.Builder alert = new AlertDialog.Builder(this); 241 | 242 | alert.setTitle(getString(R.string.action_open_shaarli)); 243 | 244 | // TODO: move this to a xml file: 245 | final LinearLayout layout = new LinearLayout(this); 246 | for (final ShaarliAccount account : accounts) { 247 | Button b = new Button(this); 248 | b.setLayoutParams(new ViewGroup.LayoutParams( 249 | ViewGroup.LayoutParams.MATCH_PARENT, 250 | ViewGroup.LayoutParams.WRAP_CONTENT) 251 | ); 252 | b.setText(account.getShortName()); 253 | b.setOnClickListener(new View.OnClickListener() { 254 | @Override 255 | public void onClick(View v) { 256 | Intent browserIntent = new Intent( 257 | Intent.ACTION_VIEW, 258 | Uri.parse(account.getUrlShaarli()) 259 | ); 260 | startActivity(browserIntent); 261 | } 262 | }); 263 | 264 | layout.addView(b); 265 | } 266 | 267 | layout.setOrientation(LinearLayout.VERTICAL); 268 | layout.setPadding(10, 10, 20, 20); 269 | 270 | alert.setView(layout); 271 | 272 | alert.setNegativeButton(getString(android.R.string.no), new DialogInterface.OnClickListener() { 273 | public void onClick(DialogInterface dialog, int whichButton) { 274 | // Canceled. 275 | } 276 | }); 277 | 278 | alert.show(); 279 | } 280 | 281 | private boolean isHandlingHttpScheme() { 282 | return getPackageManager().getComponentEnabledSetting(getHttpSchemeHandlingComponent()) 283 | == PackageManager.COMPONENT_ENABLED_STATE_ENABLED; 284 | } 285 | 286 | private void setHandleHttpScheme(boolean handleHttpScheme) { 287 | if(handleHttpScheme == isHandlingHttpScheme()) return; 288 | 289 | int flag = (handleHttpScheme ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED 290 | : PackageManager.COMPONENT_ENABLED_STATE_DISABLED); 291 | 292 | getPackageManager().setComponentEnabledSetting( 293 | getHttpSchemeHandlingComponent(), flag, PackageManager.DONT_KILL_APP); 294 | } 295 | 296 | private ComponentName getHttpSchemeHandlingComponent() { 297 | return new ComponentName(this, HttpSchemeHandlerActivity.class); 298 | } 299 | 300 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/activities/ShareActivity.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.activities; 2 | 3 | import android.content.Intent; 4 | import android.os.Bundle; 5 | import android.os.Handler; 6 | import android.os.Message; 7 | import android.os.Messenger; 8 | import android.text.Editable; 9 | import android.text.TextWatcher; 10 | import android.util.Log; 11 | import android.view.Menu; 12 | import android.view.MenuItem; 13 | import android.view.View; 14 | import android.widget.AdapterView; 15 | import android.widget.ArrayAdapter; 16 | import android.widget.Checkable; 17 | import android.widget.EditText; 18 | import android.widget.MultiAutoCompleteTextView; 19 | import android.widget.Spinner; 20 | import android.widget.Switch; 21 | import android.widget.Toast; 22 | 23 | import androidx.annotation.NonNull; 24 | import androidx.appcompat.app.AppCompatActivity; 25 | 26 | import com.dimtion.shaarlier.R; 27 | import com.dimtion.shaarlier.dao.AccountsSource; 28 | import com.dimtion.shaarlier.exceptions.UnsupportedIntent; 29 | import com.dimtion.shaarlier.helper.AutoCompleteWrapper; 30 | import com.dimtion.shaarlier.helper.ShareIntentParser; 31 | import com.dimtion.shaarlier.models.Link; 32 | import com.dimtion.shaarlier.models.ShaarliAccount; 33 | import com.dimtion.shaarlier.network.NetworkUtils; 34 | import com.dimtion.shaarlier.services.NetworkService; 35 | import com.dimtion.shaarlier.utils.UserPreferences; 36 | 37 | import java.util.ArrayList; 38 | import java.util.Collections; 39 | import java.util.List; 40 | import java.util.Objects; 41 | 42 | public class ShareActivity extends AppCompatActivity { 43 | 44 | static final int LOADER_TITLE = 0; 45 | static final int LOADER_DESCRIPTION = 1; 46 | static final int LOADER_PREFETCH = 2; 47 | private static final String LOGGER_NAME = ShareActivity.class.getSimpleName(); 48 | private Link defaults; 49 | private UserPreferences userPrefs; 50 | private List accounts; 51 | 52 | private boolean isLoadingTitle = false; 53 | private boolean isLoadingDescription = false; 54 | private boolean isPrefetching = false; 55 | 56 | private boolean isNotNewLink = false; 57 | private Menu menu; 58 | 59 | @Override 60 | public boolean onCreateOptionsMenu(final Menu menu) { 61 | getMenuInflater().inflate(R.menu.menu_share, menu); 62 | this.menu = menu; 63 | return true; 64 | } 65 | 66 | @Override 67 | protected void onCreate(final Bundle savedInstanceState) { 68 | userPrefs = UserPreferences.load(this); 69 | if (userPrefs.isOpenDialog()) { 70 | setTheme(R.style.AppTheme); 71 | } 72 | 73 | super.onCreate(savedInstanceState); 74 | 75 | final List loadedAccounts = loadAccounts(); 76 | if (loadedAccounts.isEmpty()) { 77 | Log.w(LOGGER_NAME, "No account configured, starting MainActivity"); 78 | startActivity(new Intent(this, MainActivity.class)); 79 | return; 80 | } 81 | 82 | this.accounts = loadedAccounts; 83 | final ShaarliAccount selectedAccount = loadedAccounts.get(0); 84 | 85 | final Intent intent = getIntent(); 86 | final ShareIntentParser intentParser = new ShareIntentParser(selectedAccount, userPrefs); 87 | try { 88 | defaults = intentParser.parse(this, intent); 89 | } catch (final UnsupportedIntent e) { 90 | Toast.makeText(getApplicationContext(), R.string.add_not_handle, Toast.LENGTH_SHORT).show(); 91 | finish(); 92 | return; 93 | } 94 | 95 | if (userPrefs.isOpenDialog()) { 96 | openDialog(defaults, userPrefs); 97 | } else { 98 | autoLoadTitleAndDescription(defaults); 99 | sendLink(defaults); 100 | finish(); 101 | } 102 | } 103 | 104 | /** 105 | * Load user accounts and set the default one as the first of the list 106 | */ 107 | private List loadAccounts() { 108 | final AccountsSource accountsSource = new AccountsSource(this); 109 | ShaarliAccount defaultAccount = null; 110 | List accounts = new ArrayList<>(); 111 | accountsSource.rOpen(); 112 | try { 113 | accounts = accountsSource.getAllAccounts(); 114 | defaultAccount = accountsSource.getDefaultAccount(); 115 | } catch (final Exception e) { 116 | Log.e(LOGGER_NAME, "Error while loading account sources"); 117 | e.printStackTrace(); 118 | } finally { 119 | accountsSource.close(); 120 | } 121 | 122 | if (Objects.isNull(defaultAccount)) { 123 | return accounts; 124 | } 125 | 126 | // Put the default account first in the list 127 | int indexSelectedAccount = 0; 128 | for (final ShaarliAccount account : accounts) { 129 | if (account.getId() == defaultAccount.getId()) { 130 | break; 131 | } 132 | indexSelectedAccount++; 133 | } 134 | Collections.swap(accounts, indexSelectedAccount, 0); 135 | 136 | return accounts; 137 | } 138 | 139 | /** 140 | * Open a dialog for the user to change the description of the share 141 | */ 142 | private void openDialog(final Link defaults, final UserPreferences userPrefs) { 143 | setContentView(R.layout.activity_share); 144 | 145 | initAccountSpinner(); 146 | 147 | if (NetworkUtils.isUrl(defaults.getUrl())) { 148 | prefetchLink(defaults); 149 | autoLoadTitleAndDescription(defaults); 150 | updateLoadersVisibility(); 151 | } 152 | 153 | ((EditText) findViewById(R.id.url)).setText(defaults.getUrl()); 154 | 155 | final MultiAutoCompleteTextView textView = findViewById(R.id.tags); 156 | ((EditText) findViewById(R.id.tags)).setText(defaults.getTags()); 157 | new AutoCompleteWrapper(textView, this); 158 | 159 | ((Checkable) findViewById(R.id.private_share)).setChecked(defaults.isPrivate()); 160 | 161 | { // Init tweet if necessary 162 | final Switch tweetCheckBox = findViewById(R.id.tweet); 163 | tweetCheckBox.setChecked(userPrefs.isTweet()); 164 | tweetCheckBox.setVisibility(userPrefs.isTweet() ? View.VISIBLE : View.GONE); 165 | } 166 | 167 | { // Init toot if necessary 168 | final Switch tootCheckBox = findViewById(R.id.toot); 169 | tootCheckBox.setChecked(userPrefs.isToot()); 170 | tootCheckBox.setVisibility(userPrefs.isTweet() ? View.VISIBLE : View.GONE); 171 | } 172 | } 173 | 174 | /** 175 | * Initiate a network handler to prefetch link information on Shaarli. 176 | * This can be used to know if a link was already saved, and then retrieve 177 | * its data. 178 | * 179 | * TODO: use this prefetching in order to save one network request when sharing 180 | * @param defaults defaults values 181 | */ 182 | private void prefetchLink(@NonNull Link defaults) { 183 | final Intent networkIntent = new Intent(this, NetworkService.class); 184 | networkIntent.putExtra("action", NetworkService.INTENT_PREFETCH); 185 | networkIntent.putExtra("link", defaults); 186 | networkIntent.putExtra(NetworkService.EXTRA_MESSENGER, new Messenger(new NetworkHandler())); 187 | 188 | isPrefetching = true; 189 | startService(networkIntent); 190 | } 191 | 192 | private void initAccountSpinner() { 193 | final Spinner accountSpinner = this.findViewById(R.id.chooseAccount); 194 | ArrayAdapter adapter = new ArrayAdapter<>(this, R.layout.tags_list, accounts); 195 | accountSpinner.setAdapter(adapter); 196 | if (accountSpinner.getCount() < 2) { 197 | accountSpinner.setVisibility(View.GONE); 198 | } 199 | AccountSpinnerListener listener = new AccountSpinnerListener(); 200 | accountSpinner.setOnItemSelectedListener(listener); 201 | } 202 | 203 | /** 204 | * Load everything from the interface and share the link 205 | */ 206 | private void saveAndShare() { 207 | sendLink(linkFromUserInput()); 208 | finish(); 209 | } 210 | 211 | private void autoLoadTitleAndDescription(Link defaults) { 212 | // Don't use network resources if not needed 213 | if (!userPrefs.isAutoTitle() && !userPrefs.isAutoDescription()) { 214 | return; 215 | } 216 | // Launch intent to retrieve the title and the description 217 | final Intent networkIntent = new Intent(this, NetworkService.class); 218 | networkIntent.putExtra("action", NetworkService.INTENT_RETRIEVE_TITLE_AND_DESCRIPTION); 219 | networkIntent.putExtra("url", defaults.getUrl()); 220 | networkIntent.putExtra("autoTitle", userPrefs.isAutoTitle()); 221 | networkIntent.putExtra("autoDescription", userPrefs.isAutoDescription()); 222 | networkIntent.putExtra(NetworkService.EXTRA_MESSENGER, new Messenger(new NetworkHandler())); 223 | 224 | isLoadingTitle = true; 225 | isLoadingDescription = true; 226 | startService(networkIntent); 227 | 228 | // Everything is done in the NetworkService if no dialog is opened 229 | if (!userPrefs.isOpenDialog()){ 230 | return; 231 | } 232 | 233 | if (userPrefs.isAutoDescription()) { 234 | stopEarlyAutoLoad( 235 | defaults.getDescription(), 236 | R.id.loading_description, 237 | R.id.description, 238 | R.string.loading_description_hint, 239 | LOADER_DESCRIPTION); 240 | } 241 | 242 | if (userPrefs.isAutoTitle()) { 243 | stopEarlyAutoLoad( 244 | defaults.getTitle(), 245 | R.id.loading_title, 246 | R.id.title, 247 | R.string.loading_title_hint, 248 | LOADER_TITLE); 249 | } 250 | } 251 | 252 | /** 253 | * helper function for stopEarlyAutoload closure. 254 | * It is not very useful otherwise 255 | * 256 | * @param loaderId 257 | * @param value 258 | */ 259 | private void setLoading(int loaderId, boolean value) { 260 | switch (loaderId) { 261 | case LOADER_TITLE: 262 | isLoadingTitle = value; 263 | break; 264 | case LOADER_DESCRIPTION: 265 | isLoadingDescription = value; 266 | break; 267 | case LOADER_PREFETCH: 268 | isPrefetching = value; 269 | break; 270 | default: 271 | break; 272 | } 273 | } 274 | 275 | private void updateLoadersVisibility() { 276 | View titleLoader = findViewById(R.id.loading_title); 277 | if (isLoadingTitle || isPrefetching) { 278 | titleLoader.setVisibility(View.VISIBLE); 279 | } else { 280 | titleLoader.setVisibility(View.GONE); 281 | } 282 | 283 | View descriptionLoader = findViewById(R.id.loading_description); 284 | if (isLoadingDescription || isPrefetching) { 285 | descriptionLoader.setVisibility(View.VISIBLE); 286 | } else { 287 | descriptionLoader.setVisibility(View.GONE); 288 | } 289 | } 290 | 291 | private void stopEarlyAutoLoad(String defaultValue, final int loader, final int field, int hint, final int loaderId) { 292 | if ("".equals(defaultValue)) { 293 | ((EditText) findViewById(field)).setHint(hint); 294 | 295 | // If in the meanwhile the user type text in the field, stop retrieving the data 296 | ((EditText) findViewById(field)).addTextChangedListener(new TextWatcher() { 297 | @Override 298 | public void beforeTextChanged(CharSequence s, int start, int count, int after) { 299 | setLoading(loaderId, false); 300 | findViewById(loader).setVisibility(View.GONE); 301 | ((EditText) findViewById(field)).removeTextChangedListener(this); 302 | } 303 | 304 | @Override 305 | public void onTextChanged(CharSequence s, int start, int before, int count) { 306 | // Nothing to be done 307 | } 308 | 309 | @Override 310 | public void afterTextChanged(Editable s) { 311 | // Nothing to be done 312 | } 313 | }); 314 | } else { 315 | setLoading(loaderId, false); 316 | updateTitle(defaultValue, false); 317 | } 318 | } 319 | 320 | private void updateTitle(String title, boolean isError) { 321 | EditText titleEdit = findViewById(R.id.title); 322 | 323 | titleEdit.setHint(R.string.title_hint); 324 | 325 | if (isError) { 326 | titleEdit.setHint(R.string.error_retrieving_title); 327 | } else { 328 | titleEdit.setText(title); 329 | } 330 | 331 | updateLoadersVisibility(); 332 | } 333 | 334 | private void updateDescription(String description, boolean isError) { 335 | EditText descriptionEdit = findViewById(R.id.description); 336 | descriptionEdit.setHint(R.string.description_hint); 337 | 338 | 339 | if (isError) { 340 | descriptionEdit.setHint(R.string.error_retrieving_description); 341 | } else { 342 | descriptionEdit.setText(description); 343 | } 344 | updateLoadersVisibility(); 345 | } 346 | 347 | private void updateTags(String tags, boolean isError) { 348 | EditText tagsEdit = findViewById(R.id.tags); 349 | 350 | if (isError) { 351 | // Display nothing 352 | Log.e(LOGGER_NAME, "error retrieving tags"); 353 | } else { 354 | tagsEdit.setText(tags); 355 | } 356 | } 357 | 358 | private void updatePrivate(boolean isPrivate) { 359 | Checkable tagsEdit = findViewById(R.id.private_share); 360 | tagsEdit.setChecked(isPrivate); 361 | } 362 | 363 | private void updateTweet(boolean tweet) { 364 | Checkable tweetCheck = findViewById(R.id.tweet); 365 | tweetCheck.setChecked(tweet); 366 | } 367 | 368 | private void updateToot(boolean toot) { 369 | Checkable tootCheck = findViewById(R.id.toot); 370 | tootCheck.setChecked(toot); 371 | } 372 | 373 | private Link linkFromUserInput() { 374 | return new Link( 375 | ((EditText) findViewById(R.id.url)).getText().toString(), 376 | ((EditText) findViewById(R.id.title)).getText().toString(), 377 | ((EditText) findViewById(R.id.description)).getText().toString(), 378 | ((EditText) findViewById(R.id.tags)).getText().toString(), 379 | ((Checkable) findViewById(R.id.private_share)).isChecked(), 380 | (ShaarliAccount) ((Spinner) findViewById(R.id.chooseAccount)).getSelectedItem(), 381 | ((Checkable) findViewById(R.id.tweet)).isChecked(), 382 | ((Checkable) findViewById(R.id.toot)).isChecked(), 383 | null, 384 | null 385 | ); 386 | } 387 | 388 | class AccountSpinnerListener implements AdapterView.OnItemSelectedListener { 389 | @Override 390 | public void onItemSelected(AdapterView parent, View view, int position, long id) { 391 | setLoading(LOADER_DESCRIPTION, true); 392 | setLoading(LOADER_TITLE, true); 393 | updateLoadersVisibility(); 394 | Link defaults = linkFromUserInput(); 395 | // Override text values to avoid messing with `seemsNewLink` 396 | defaults.setTitle(""); 397 | defaults.setDescription(""); 398 | defaults.setTags(""); 399 | 400 | prefetchLink(defaults); 401 | } 402 | 403 | @Override 404 | public void onNothingSelected(AdapterView parent) { 405 | // pass 406 | } 407 | } 408 | 409 | @Override 410 | public boolean onOptionsItemSelected(MenuItem item) { 411 | int id = item.getItemId(); 412 | if (id == R.id.action_share) { 413 | saveAndShare(); 414 | } else { 415 | return true; 416 | } 417 | return super.onOptionsItemSelected(item); 418 | 419 | } 420 | 421 | /** 422 | * Start the network service to send the link 423 | * @param link link to send 424 | */ 425 | private void sendLink(@NonNull Link link) { 426 | Intent networkIntent = new Intent(this, NetworkService.class); 427 | networkIntent.putExtra("action", NetworkService.INTENT_POST); 428 | networkIntent.putExtra("link", link); 429 | networkIntent.putExtra( 430 | NetworkService.EXTRA_MESSENGER, 431 | new Messenger(new NetworkHandler()) 432 | ); 433 | 434 | startService(networkIntent); 435 | } 436 | 437 | private class NetworkHandler extends Handler { 438 | /** 439 | * Handle the arrival of a message coming from the network service. 440 | * 441 | * @param msg the message given by the service 442 | */ 443 | @Override 444 | public void handleMessage(Message msg) { 445 | switch (msg.arg1) { 446 | case NetworkService.RETRIEVE_TITLE_ID: 447 | handleRetrieveTitleId(msg); 448 | break; 449 | case NetworkService.PREFETCH_LINK: 450 | handlePrefetchLink(msg); 451 | break; 452 | default: 453 | Toast.makeText(getApplicationContext(), R.string.error_unknown, Toast.LENGTH_LONG).show(); 454 | Log.e("NETWORK_MSG", "Unknown network intent received: " + msg.arg1); 455 | break; 456 | } 457 | } 458 | 459 | private void handleRetrieveTitleId(final Message msg) { 460 | Log.i("NETWORK_MSG", "Title or description retrieved"); 461 | if (userPrefs.isOpenDialog()) { 462 | final String title = ((String[]) msg.obj)[0]; 463 | final String description = ((String[]) msg.obj)[1]; 464 | 465 | if (isLoadingTitle && userPrefs.isAutoTitle()) { 466 | updateTitle(title, "".equals(title)); 467 | } 468 | if (isLoadingDescription && userPrefs.isAutoDescription()) { 469 | updateDescription(description, "".equals(description)); 470 | } 471 | setLoading(LOADER_TITLE, false); 472 | setLoading(LOADER_DESCRIPTION, false); 473 | updateLoadersVisibility(); 474 | } 475 | } 476 | 477 | private void handlePrefetchLink(final Message msg) { 478 | Log.i("NETWORK_MSG", "Link prefetched"); 479 | 480 | MenuItem isEditedIcon = menu.findItem(R.id.editing); 481 | setLoading(LOADER_PREFETCH, false); 482 | if (userPrefs.isOpenDialog()) { 483 | Link prefetchedLink = (Link) msg.obj; 484 | 485 | isNotNewLink = prefetchedLink.seemsNotNew(); 486 | Log.i("PREFETCH_LINK", "SeemsNotNew? " + isNotNewLink); 487 | // Prefetching has priority over previously saved data since it 488 | // coming from Shaarli directly 489 | if (isNotNewLink) { 490 | defaults = prefetchedLink; 491 | 492 | // Update the interface 493 | if (defaults.getTitle().length() > 0) { 494 | updateTitle(defaults.getTitle(), false); 495 | } 496 | if (defaults.getDescription().length() > 0) { 497 | updateDescription(defaults.getDescription(), false); 498 | } 499 | if (defaults.getTagList().size() > 0) { 500 | updateTags(defaults.getTags(), false); 501 | } 502 | updatePrivate(defaults.isPrivate()); 503 | updateTweet(defaults.isTweet()); 504 | updateToot(defaults.isToot()); 505 | 506 | // Show that we are editing an existing entry 507 | isEditedIcon.setVisible(true); 508 | } else { 509 | isEditedIcon.setVisible(false); 510 | } 511 | // prefetch success: stop other loaders 512 | setLoading(LOADER_TITLE, false); 513 | setLoading(LOADER_DESCRIPTION, false); 514 | updateLoadersVisibility(); 515 | } 516 | } 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/dao/AccountsSource.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.dao; 2 | 3 | import android.content.ContentValues; 4 | import android.content.Context; 5 | import android.content.SharedPreferences; 6 | import android.database.Cursor; 7 | import android.database.SQLException; 8 | import android.database.sqlite.SQLiteDatabase; 9 | import android.text.SpannableString; 10 | import android.text.Spanned; 11 | import android.text.TextUtils; 12 | import android.widget.MultiAutoCompleteTextView; 13 | 14 | import androidx.annotation.NonNull; 15 | import androidx.annotation.Nullable; 16 | 17 | import com.dimtion.shaarlier.R; 18 | import com.dimtion.shaarlier.helper.EncryptionHelper; 19 | import com.dimtion.shaarlier.models.ShaarliAccount; 20 | 21 | import java.util.ArrayList; 22 | import java.util.List; 23 | 24 | import javax.crypto.SecretKey; 25 | 26 | /** 27 | * Created by dimtion on 11/05/2015. 28 | * API for managing accounts 29 | */ 30 | public class AccountsSource { 31 | 32 | private final String[] allColumns = { 33 | MySQLiteHelper.ACCOUNTS_COLUMN_ID, 34 | MySQLiteHelper.ACCOUNTS_COLUMN_URL_SHAARLI, 35 | MySQLiteHelper.ACCOUNTS_COLUMN_USERNAME, 36 | MySQLiteHelper.ACCOUNTS_COLUMN_PASSWORD_CYPHER, 37 | MySQLiteHelper.ACCOUNTS_COLUMN_SHORT_NAME, 38 | MySQLiteHelper.ACCOUNTS_COLUMN_IV, 39 | MySQLiteHelper.ACCOUNTS_COLUMN_VALIDATE_CERT, 40 | MySQLiteHelper.ACCOUNTS_COLUMN_BASIC_AUTH_USERNAME, 41 | MySQLiteHelper.ACCOUNTS_COLUMN_BASIC_AUTH_PASSWORD_CYPHER, 42 | MySQLiteHelper.ACCOUNTS_COLUMN_REST_API_KEY, 43 | }; 44 | private final MySQLiteHelper dbHelper; 45 | private final Context mContext; 46 | private SQLiteDatabase db; 47 | 48 | public AccountsSource(Context context) { 49 | dbHelper = new MySQLiteHelper(context); 50 | this.mContext = context; 51 | } 52 | 53 | public void rOpen() throws SQLException { 54 | db = dbHelper.getReadableDatabase(); 55 | } 56 | 57 | public void wOpen() throws SQLException { 58 | db = dbHelper.getWritableDatabase(); 59 | } 60 | 61 | public void close() { 62 | dbHelper.close(); 63 | } 64 | 65 | public ShaarliAccount createAccount(String urlShaarli, String username, String password, String basicAuthUsername, String basicAuthPassword, String shortName, boolean validateCert, String restAPIKey) throws Exception { 66 | ContentValues values = new ContentValues(); 67 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_URL_SHAARLI, urlShaarli); 68 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_USERNAME, username); 69 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_BASIC_AUTH_USERNAME, basicAuthUsername); 70 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_REST_API_KEY, restAPIKey); 71 | 72 | // Generate the iv : 73 | byte[] iv = EncryptionHelper.generateInitialVector(); 74 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_IV, iv); 75 | 76 | byte[] password_cipher = encryptPassword(password, iv); 77 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_PASSWORD_CYPHER, password_cipher); 78 | 79 | byte[] basic_password_cipher = encryptPassword(basicAuthPassword, iv); 80 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_BASIC_AUTH_PASSWORD_CYPHER, basic_password_cipher); 81 | 82 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_SHORT_NAME, shortName); 83 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_VALIDATE_CERT, validateCert ? 1:0 ); // Convert bool to int 84 | 85 | long insertId = db.insert(MySQLiteHelper.TABLE_ACCOUNTS, null, values); 86 | return getShaarliAccountById(insertId); 87 | } 88 | 89 | public List getAllAccounts() { 90 | List accounts = new ArrayList<>(); 91 | 92 | Cursor cursor = db.query(MySQLiteHelper.TABLE_ACCOUNTS, allColumns, null, null, null, null, null); 93 | cursor.moveToFirst(); 94 | while (!cursor.isAfterLast()) { 95 | ShaarliAccount account = cursorToAccount(cursor); 96 | if (account != null) 97 | accounts.add(account); 98 | cursor.moveToNext(); 99 | } 100 | 101 | cursor.close(); 102 | return accounts; 103 | } 104 | 105 | private SecretKey getSecretKey() { 106 | String id = mContext.getString(R.string.params); 107 | SharedPreferences prefs = this.mContext.getSharedPreferences(id, Context.MODE_PRIVATE); 108 | 109 | String sKey = prefs.getString(this.mContext.getString(R.string.dbKey), ""); 110 | return EncryptionHelper.stringToSecretKey(sKey); 111 | } 112 | 113 | private byte[] encryptPassword(String clearPassword, byte[] initialVector) throws Exception { 114 | SecretKey key = getSecretKey(); 115 | byte[] encoded = EncryptionHelper.stringToBase64(clearPassword); 116 | return EncryptionHelper.encrypt(encoded, key, initialVector); 117 | } 118 | 119 | private String decryptPassword(byte[] cipherData, byte[] initialVector) throws Exception { 120 | SecretKey key = getSecretKey(); 121 | byte[] encodedPassword = EncryptionHelper.decrypt(cipherData, key, initialVector); 122 | return EncryptionHelper.base64ToString(encodedPassword); 123 | } 124 | 125 | @Nullable 126 | public ShaarliAccount getShaarliAccountById(long id) { 127 | rOpen(); 128 | Cursor cursor = db.query(MySQLiteHelper.TABLE_ACCOUNTS, allColumns, MySQLiteHelper.ACCOUNTS_COLUMN_ID + " = " + id, null, 129 | null, null, null); 130 | cursor.moveToFirst(); 131 | 132 | ShaarliAccount account = cursorToAccount(cursor); 133 | cursor.close(); 134 | close(); 135 | 136 | return account; 137 | } 138 | 139 | @Nullable 140 | private ShaarliAccount cursorToAccount(@NonNull Cursor cursor) { 141 | if (cursor.isAfterLast()) 142 | return null; 143 | 144 | ShaarliAccount account = new ShaarliAccount(); 145 | account.setId(cursor.getLong(0)); 146 | account.setUrlShaarli(cursor.getString(1)); 147 | account.setUsername(cursor.getString(2)); 148 | account.setInitialVector(cursor.getBlob(5)); 149 | account.setValidateCert(cursor.getInt(6) == 1); // Convert int to bool 150 | account.setBasicAuthUsername(cursor.getString(7)); 151 | account.setRestAPIKey(cursor.getString(9)); 152 | 153 | byte[] password_cypher = cursor.getBlob(3); 154 | String password; 155 | try { 156 | password = decryptPassword(password_cypher, account.getInitialVector()); 157 | } catch (Exception e) { 158 | e.printStackTrace(); 159 | return null; 160 | } 161 | account.setPassword(password); 162 | 163 | byte[] basic_password_cypher = cursor.getBlob(8); 164 | String basic_password; 165 | try { 166 | basic_password = decryptPassword(basic_password_cypher, account.getInitialVector()); 167 | } catch (Exception e) { 168 | e.printStackTrace(); 169 | basic_password = ""; 170 | } 171 | account.setBasicAuthPassword(basic_password); 172 | 173 | account.setShortName(cursor.getString(4)); 174 | 175 | return account; 176 | } 177 | 178 | public void deleteAccount(ShaarliAccount account) { 179 | db.delete(MySQLiteHelper.TABLE_ACCOUNTS, MySQLiteHelper.ACCOUNTS_COLUMN_ID + " = " + account.getId(), null); 180 | } 181 | 182 | public void editAccount(ShaarliAccount account) throws Exception { 183 | String QUERY_WHERE = MySQLiteHelper.ACCOUNTS_COLUMN_ID + " = " + account.getId(); 184 | ContentValues values = new ContentValues(); 185 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_URL_SHAARLI, account.getUrlShaarli()); 186 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_USERNAME, account.getUsername()); 187 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_REST_API_KEY, account.getRestAPIKey()); 188 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_BASIC_AUTH_USERNAME, account.getBasicAuthUsername()); 189 | 190 | // Generate a new iv : 191 | account.setInitialVector(EncryptionHelper.generateInitialVector()); 192 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_IV, account.getInitialVector()); 193 | 194 | byte[] password_cipher = encryptPassword(account.getPassword(), account.getInitialVector()); 195 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_PASSWORD_CYPHER, password_cipher); 196 | 197 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_SHORT_NAME, account.getShortName()); 198 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_VALIDATE_CERT, account.isValidateCert() ? 1:0); // convert bool to int 199 | byte[] basic_password_cipher = encryptPassword(account.getBasicAuthPassword(), account.getInitialVector()); 200 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_BASIC_AUTH_PASSWORD_CYPHER, basic_password_cipher); 201 | 202 | db.update(MySQLiteHelper.TABLE_ACCOUNTS, values, QUERY_WHERE, null); 203 | } 204 | 205 | public ShaarliAccount getDefaultAccount() { 206 | SharedPreferences prefs = this.mContext.getSharedPreferences(this.mContext.getString(R.string.params), Context.MODE_PRIVATE); 207 | long defaultAccountId = prefs.getLong(this.mContext.getString(R.string.p_default_account), -1); 208 | 209 | ShaarliAccount defaultAccount = getShaarliAccountById(defaultAccountId); 210 | if (defaultAccount == null) { 211 | rOpen(); 212 | Cursor cursor = db.query(MySQLiteHelper.TABLE_ACCOUNTS, allColumns, null, null, null, null, MySQLiteHelper.ACCOUNTS_COLUMN_ID, "1"); 213 | cursor.moveToFirst(); 214 | defaultAccount = cursorToAccount(cursor); 215 | cursor.close(); 216 | close(); 217 | } 218 | return defaultAccount; 219 | } 220 | 221 | /** 222 | * Created by dimtion on 22/02/2015. 223 | * Custom tokenizer, found on : http://stackoverflow.com/a/4596652/1582589 by vsm * 224 | */ 225 | 226 | 227 | public static class SpaceTokenizer implements MultiAutoCompleteTextView.Tokenizer { 228 | 229 | public int findTokenStart(CharSequence text, int cursor) { 230 | int i = cursor; 231 | 232 | while (i > 0 && text.charAt(i - 1) != ' ') { 233 | i--; 234 | } 235 | while (i < cursor && text.charAt(i) == ' ') { 236 | i++; 237 | } 238 | 239 | return i; 240 | } 241 | 242 | public int findTokenEnd(CharSequence text, int cursor) { 243 | int i = cursor; 244 | int len = text.length(); 245 | 246 | while (i < len) { 247 | if (text.charAt(i) == ' ') { 248 | return i; 249 | } else { 250 | i++; 251 | } 252 | } 253 | 254 | return len; 255 | } 256 | 257 | public CharSequence terminateToken(CharSequence text) { 258 | int i = text.length(); 259 | 260 | while (i > 0 && text.charAt(i - 1) == ' ') { 261 | i--; 262 | } 263 | 264 | if (i > 0 && text.charAt(i - 1) == ' ') { 265 | return text; 266 | } else { 267 | if (text instanceof Spanned) { 268 | SpannableString sp = new SpannableString(text + " "); 269 | TextUtils.copySpansFrom((Spanned) text, 0, text.length(), 270 | Object.class, sp, 0); 271 | return sp; 272 | } else { 273 | return text + " "; 274 | } 275 | } 276 | } 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/dao/MySQLiteHelper.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.dao; 2 | 3 | import android.content.ContentValues; 4 | import android.content.Context; 5 | import android.content.SharedPreferences; 6 | import android.database.sqlite.SQLiteDatabase; 7 | import android.database.sqlite.SQLiteOpenHelper; 8 | import android.util.Log; 9 | 10 | import com.dimtion.shaarlier.R; 11 | import com.dimtion.shaarlier.helper.EncryptionHelper; 12 | 13 | import java.security.NoSuchAlgorithmException; 14 | 15 | import javax.crypto.SecretKey; 16 | 17 | /** 18 | * Created by dimtion on 11/05/2015. 19 | * This class update the db scheme when necessary 20 | */ 21 | class MySQLiteHelper extends SQLiteOpenHelper { 22 | // Table: accounts 23 | static final String TABLE_ACCOUNTS = "accounts"; 24 | static final String ACCOUNTS_COLUMN_ID = "_id"; 25 | static final String ACCOUNTS_COLUMN_URL_SHAARLI = "url_shaarli"; 26 | static final String ACCOUNTS_COLUMN_USERNAME = "username"; 27 | static final String ACCOUNTS_COLUMN_PASSWORD_CYPHER = "password_cypher"; 28 | static final String ACCOUNTS_COLUMN_SHORT_NAME = "short_name"; 29 | static final String ACCOUNTS_COLUMN_IV = "initial_vector"; 30 | static final String ACCOUNTS_COLUMN_VALIDATE_CERT = "validate_cert"; 31 | static final String ACCOUNTS_COLUMN_BASIC_AUTH_USERNAME = "basic_auth_username"; 32 | static final String ACCOUNTS_COLUMN_BASIC_AUTH_PASSWORD_CYPHER = "basic_auth_password_cypher"; 33 | static final String ACCOUNTS_COLUMN_REST_API_KEY = "rest_api_key"; 34 | // Table: tags 35 | static final String TABLE_TAGS = "tags"; 36 | private static final int DATABASE_VERSION = 4; 37 | private static final String CREATE_TABLE_ACCOUNTS = "create table " 38 | + TABLE_ACCOUNTS + " (" 39 | + ACCOUNTS_COLUMN_ID + " integer primary key autoincrement, " 40 | + ACCOUNTS_COLUMN_URL_SHAARLI + " text NOT NULL, " 41 | + ACCOUNTS_COLUMN_USERNAME + " text NOT NULL, " 42 | + ACCOUNTS_COLUMN_PASSWORD_CYPHER + " BLOB, " 43 | + ACCOUNTS_COLUMN_SHORT_NAME + " text DEFAULT '', " 44 | + ACCOUNTS_COLUMN_IV + " BLOB," 45 | + ACCOUNTS_COLUMN_VALIDATE_CERT + " integer DEFAULT 1," 46 | + ACCOUNTS_COLUMN_BASIC_AUTH_USERNAME + " text NOT NULL, " 47 | + ACCOUNTS_COLUMN_BASIC_AUTH_PASSWORD_CYPHER + " BLOB, " 48 | + ACCOUNTS_COLUMN_REST_API_KEY + " text NOT NULL);"; 49 | static final String TAGS_COLUMN_ID = "_id"; 50 | static final String TAGS_COLUMN_ID_ACCOUNT = "account_id"; 51 | static final String TAGS_COLUMN_TAG = "tag"; 52 | private static final String DATABASE_NAME = "shaarlier.db"; 53 | private static final String CREATE_TABLE_TAGS = "create table " 54 | + TABLE_TAGS + " (" 55 | + TAGS_COLUMN_ID + " integer primary key autoincrement, " 56 | + TAGS_COLUMN_ID_ACCOUNT + " integer NOT NULL, " 57 | + TAGS_COLUMN_TAG + " text NOT NULL ) ;"; 58 | // Database updates 59 | private static final String[][] UPDATE_DB = { 60 | // UPDATE 1 -> 2 61 | { 62 | "ALTER TABLE " + TABLE_ACCOUNTS + 63 | " ADD `" + ACCOUNTS_COLUMN_VALIDATE_CERT + "` integer NOT NULL DEFAULT 1;", 64 | }, 65 | // UPDATE 2 -> 3 66 | { 67 | "ALTER TABLE " + TABLE_ACCOUNTS + 68 | " ADD `" + ACCOUNTS_COLUMN_BASIC_AUTH_USERNAME + "` text NOT NULL DEFAULT ``; ", 69 | "ALTER TABLE " + TABLE_ACCOUNTS + 70 | " ADD `" + ACCOUNTS_COLUMN_BASIC_AUTH_PASSWORD_CYPHER + "` BLOB;" 71 | }, 72 | // UPDATE 3 -> 4 73 | { 74 | "ALTER TABLE " + TABLE_ACCOUNTS + 75 | " ADD `" + ACCOUNTS_COLUMN_REST_API_KEY + "` text NOT NULL DEFAULT ``; " 76 | } 77 | }; 78 | 79 | private final Context mContext; 80 | 81 | MySQLiteHelper(Context context) { 82 | super(context, DATABASE_NAME, null, DATABASE_VERSION); 83 | this.mContext = context; 84 | } 85 | 86 | @Override 87 | public void onCreate(SQLiteDatabase db) { 88 | db.execSQL(CREATE_TABLE_ACCOUNTS); 89 | db.execSQL(CREATE_TABLE_TAGS); 90 | 91 | // Create a secret key 92 | String id = mContext.getString(R.string.params); 93 | SharedPreferences prefs = this.mContext.getSharedPreferences(id, Context.MODE_PRIVATE); 94 | SharedPreferences.Editor editor = prefs.edit(); 95 | SecretKey key; 96 | try { 97 | key = EncryptionHelper.generateKey(); 98 | String sKey = EncryptionHelper.secretKeyToString(key); 99 | 100 | editor.putString(mContext.getString(R.string.dbKey), sKey); 101 | editor.apply(); 102 | } catch (NoSuchAlgorithmException e) { 103 | e.printStackTrace(); 104 | key = null; 105 | Log.e("MySQLiteHelper", e.getMessage()); 106 | } 107 | updateFromV0(db, prefs, key); 108 | } 109 | 110 | /** 111 | * Update from no database to DB 112 | */ 113 | @Deprecated 114 | private void updateFromV0(SQLiteDatabase db, SharedPreferences prefs, SecretKey key) { 115 | String url = prefs.getString(mContext.getString(R.string.p_user_url), ""); 116 | String usr = prefs.getString(mContext.getString(R.string.p_username), ""); 117 | String pwd = prefs.getString(mContext.getString(R.string.p_password), ""); 118 | String busr = prefs.getString(mContext.getString(R.string.p_basic_username), ""); 119 | String bpwd = prefs.getString(mContext.getString(R.string.p_basic_password), ""); 120 | int protocol = prefs.getInt(mContext.getString(R.string.p_protocol), 0); 121 | boolean isValidated = prefs.getBoolean(mContext.getString(R.string.p_validated), false); 122 | 123 | String[] prot = {"http://", "https://"}; 124 | try { 125 | if (isValidated) { 126 | ContentValues values = new ContentValues(); 127 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_URL_SHAARLI, prot[protocol] + url); 128 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_USERNAME, usr); 129 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_BASIC_AUTH_USERNAME, busr); 130 | 131 | // Generate the iv : 132 | byte[] iv = EncryptionHelper.generateInitialVector(); 133 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_IV, iv); 134 | 135 | byte[] encoded = EncryptionHelper.stringToBase64(pwd); 136 | byte[] password_cipher = EncryptionHelper.encrypt(encoded, key, iv); 137 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_PASSWORD_CYPHER, password_cipher); 138 | 139 | byte[] basic_encoded = EncryptionHelper.stringToBase64(bpwd); 140 | byte[] basic_password_cipher = EncryptionHelper.encrypt(basic_encoded, key, iv); 141 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_BASIC_AUTH_PASSWORD_CYPHER, basic_password_cipher); 142 | 143 | values.put(MySQLiteHelper.ACCOUNTS_COLUMN_SHORT_NAME, "Shaarli"); 144 | 145 | db.insert(MySQLiteHelper.TABLE_ACCOUNTS, null, values); 146 | } 147 | } catch (Exception e) { 148 | Log.e("MySQLiteHelper", e.getMessage()); 149 | } 150 | } 151 | 152 | @Override 153 | public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { 154 | Log.w(MySQLiteHelper.class.getName(), 155 | "Upgrading database from version " + oldVersion + " to " 156 | + newVersion + ", which will destroy all old data"); 157 | for (int i = oldVersion - 1; i < newVersion - 1; i++) { 158 | for (String query : 159 | UPDATE_DB[i]) { 160 | db.execSQL(query); 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/dao/TagsSource.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.dao; 2 | 3 | import android.content.ContentValues; 4 | import android.content.Context; 5 | import android.database.Cursor; 6 | import android.database.SQLException; 7 | import android.database.sqlite.SQLiteDatabase; 8 | 9 | import androidx.annotation.NonNull; 10 | 11 | import com.dimtion.shaarlier.models.ShaarliAccount; 12 | import com.dimtion.shaarlier.models.Tag; 13 | 14 | import java.util.ArrayList; 15 | import java.util.List; 16 | 17 | /** 18 | * Created by dimtion on 12/05/2015. 19 | * Interface between the table TAGS and the JAVA objects 20 | */ 21 | public class TagsSource { 22 | private final String[] allColumns = {MySQLiteHelper.TAGS_COLUMN_ID, 23 | MySQLiteHelper.TAGS_COLUMN_ID_ACCOUNT, 24 | MySQLiteHelper.TAGS_COLUMN_TAG}; 25 | private final MySQLiteHelper dbHelper; 26 | private SQLiteDatabase db; 27 | 28 | public TagsSource(final Context context) { 29 | dbHelper = new MySQLiteHelper(context); 30 | } 31 | 32 | public void rOpen() throws SQLException { 33 | db = dbHelper.getReadableDatabase(); 34 | } 35 | 36 | public void wOpen() throws SQLException { 37 | db = dbHelper.getWritableDatabase(); 38 | } 39 | 40 | public void close() { 41 | dbHelper.close(); 42 | } 43 | 44 | public List getAllTags() { 45 | List tags = new ArrayList<>(); 46 | 47 | Cursor cursor = db.query(MySQLiteHelper.TABLE_TAGS, allColumns, null, null, null, null, null); 48 | cursor.moveToFirst(); 49 | while (!cursor.isAfterLast()) { 50 | Tag account = cursorToTag(cursor); 51 | tags.add(account); 52 | cursor.moveToNext(); 53 | } 54 | 55 | cursor.close(); 56 | return tags; 57 | } 58 | 59 | public Tag createTag(final ShaarliAccount masterAccount, final String value) { 60 | Tag tag = new Tag(); 61 | tag.setMasterAccount(masterAccount); 62 | tag.setValue(value.trim()); 63 | 64 | ContentValues values = new ContentValues(); 65 | values.put(MySQLiteHelper.TAGS_COLUMN_ID_ACCOUNT, masterAccount.getId()); 66 | values.put(MySQLiteHelper.TAGS_COLUMN_TAG, tag.getValue()); 67 | 68 | // If existing, do nothing : 69 | String[] getTagArgs = {String.valueOf(tag.getMasterAccountId()), tag.getValue()}; 70 | 71 | Cursor cursor = db.query(MySQLiteHelper.TABLE_TAGS, allColumns, 72 | MySQLiteHelper.TAGS_COLUMN_ID_ACCOUNT + " = ? AND " + 73 | MySQLiteHelper.TAGS_COLUMN_TAG + " = ?", 74 | getTagArgs, null, null, null); 75 | try { 76 | cursor.moveToFirst(); 77 | if (cursor.isAfterLast()) { 78 | long insertId = db.insert(MySQLiteHelper.TABLE_TAGS, null, values); 79 | tag.setId(insertId); 80 | return tag; 81 | } else { 82 | tag = cursorToTag(cursor); 83 | } 84 | } catch (Exception e){ 85 | tag = null; 86 | } finally { 87 | cursor.close(); 88 | } 89 | return tag; 90 | } 91 | 92 | private Tag cursorToTag(@NonNull Cursor cursor) { // If necessary (later), load the full account in the tag 93 | Tag tag = new Tag(); 94 | tag.setId(cursor.getLong(0)); 95 | tag.setMasterAccountId(cursor.getLong(1)); 96 | tag.setValue(cursor.getString(2)); 97 | return tag; 98 | } 99 | 100 | private void deleteAllTags() { 101 | db.delete(MySQLiteHelper.TABLE_TAGS, null, null); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/exceptions/UnsupportedIntent.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.exceptions; 2 | 3 | public class UnsupportedIntent extends Exception { 4 | public UnsupportedIntent() { 5 | super(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/helper/AutoCompleteWrapper.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.helper; 2 | 3 | import android.app.Activity; 4 | import android.app.AlertDialog; 5 | import android.content.Context; 6 | import android.content.DialogInterface; 7 | import android.os.AsyncTask; 8 | import android.util.Log; 9 | import android.widget.MultiAutoCompleteTextView; 10 | import android.widget.Toast; 11 | 12 | import com.dimtion.shaarlier.R; 13 | import com.dimtion.shaarlier.dao.AccountsSource; 14 | import com.dimtion.shaarlier.dao.TagsSource; 15 | import com.dimtion.shaarlier.network.NetworkManager; 16 | import com.dimtion.shaarlier.network.NetworkUtils; 17 | import com.dimtion.shaarlier.utils.FuzzyArrayAdapter; 18 | import com.dimtion.shaarlier.models.ShaarliAccount; 19 | import com.dimtion.shaarlier.models.Tag; 20 | 21 | import java.util.List; 22 | 23 | /** 24 | * Created by dimtion on 21/02/2015. 25 | * Inspired from : http://stackoverflow.com/a/5051180 26 | * and : http://www.claytical.com/blog/android-dynamic-autocompletion-using-google-places-api 27 | */ 28 | public class AutoCompleteWrapper { 29 | 30 | private final MultiAutoCompleteTextView a_textView; 31 | private final Context a_context; 32 | private final FuzzyArrayAdapter adapter; 33 | 34 | public AutoCompleteWrapper(final MultiAutoCompleteTextView textView, Context context) { 35 | this.a_textView = textView; 36 | this.a_context = context; 37 | 38 | this.a_textView.setTokenizer(new AccountsSource.SpaceTokenizer()); 39 | 40 | this.adapter = new FuzzyArrayAdapter<>(a_context, R.layout.tags_list); 41 | this.a_textView.setAdapter(this.adapter); 42 | this.a_textView.setThreshold(1); 43 | updateTagsView(); 44 | 45 | AutoCompleteRetriever task = new AutoCompleteRetriever(); 46 | task.execute(); 47 | } 48 | 49 | private void updateTagsView() { 50 | try { 51 | TagsSource tagsSource = new TagsSource(a_context); 52 | tagsSource.rOpen(); 53 | List tagList = tagsSource.getAllTags(); 54 | tagsSource.close(); 55 | 56 | this.adapter.clear(); 57 | this.adapter.addAll(tagList); 58 | this.adapter.notifyDataSetChanged(); 59 | 60 | this.a_textView.setAdapter(this.adapter); 61 | 62 | } catch (Exception e) { 63 | sendReport(e); 64 | } 65 | } 66 | 67 | private class AutoCompleteRetriever extends AsyncTask { 68 | private Exception mError; 69 | @Override 70 | protected Boolean doInBackground(String... foo) { 71 | AccountsSource accountsSource = new AccountsSource(a_context); 72 | accountsSource.rOpen(); 73 | List accounts = accountsSource.getAllAccounts(); 74 | TagsSource tagsSource = new TagsSource(a_context); 75 | tagsSource.wOpen(); 76 | 77 | boolean success = true; 78 | /* For the moment we keep all the tags, if later somebody wants to have the tags 79 | ** separated for each accounts, we will see 80 | */ 81 | for (ShaarliAccount account : accounts) { 82 | // Download tags: 83 | NetworkManager manager = NetworkUtils.getNetworkManager(account); 84 | try { 85 | if (!manager.isCompatibleShaarli() || !manager.login()) { 86 | throw new Exception(account + ": Login Error"); 87 | } 88 | List tags = manager.retrieveTags(); 89 | Log.d("TAGS", tags.toString()); 90 | for (String tag : tags) { 91 | tagsSource.createTag(account, tag.trim()); 92 | } 93 | } catch (Exception e) { 94 | mError = e; 95 | success = false; 96 | Log.e("ERROR", e.toString()); 97 | } 98 | } 99 | 100 | tagsSource.close(); 101 | accountsSource.close(); 102 | return success; 103 | } 104 | 105 | // onPostExecute displays the results of the AsyncTask. 106 | @Override 107 | protected void onPostExecute(Boolean r) { 108 | if(!r) { 109 | String error = (mError != null) ? mError.getMessage() : ""; 110 | Toast.makeText(a_context, a_context.getString(R.string.error_retrieving_tags) + " - " + error, Toast.LENGTH_LONG).show(); 111 | } else { 112 | updateTagsView(); 113 | } 114 | } 115 | } 116 | 117 | private void sendReport(final Exception error) { 118 | final Activity activity = (Activity) a_context; 119 | AlertDialog.Builder builder = new AlertDialog.Builder(a_context); 120 | 121 | builder.setMessage("Would you like to report this issue ?").setTitle("REPORT - Shaarlier: add link"); 122 | 123 | 124 | final String extra = ""; // "Url Shaarli: " + account.getUrlShaarli(); 125 | 126 | builder.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { 127 | public void onClick(DialogInterface dialog, int id) { 128 | DebugHelper.sendMailDev(activity, "REPORT - Shaarlier: load tags", DebugHelper.generateReport(error, activity, extra)); 129 | } 130 | }); 131 | builder.setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() { 132 | public void onClick(DialogInterface dialog, int id) { 133 | // User cancelled the dialog 134 | } 135 | }); 136 | AlertDialog dialog = builder.create(); 137 | dialog.show(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/helper/DebugHelper.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.helper; 2 | 3 | import android.app.Activity; 4 | import android.content.Intent; 5 | import android.content.pm.PackageManager; 6 | import android.net.Uri; 7 | import android.os.Build; 8 | import android.util.Log; 9 | 10 | import com.dimtion.shaarlier.R; 11 | 12 | import java.text.DateFormat; 13 | import java.text.SimpleDateFormat; 14 | import java.util.Date; 15 | import java.util.TimeZone; 16 | 17 | 18 | /** 19 | * Created by dimtion on 16/05/2015. 20 | * A class to help debugging and error reporting 21 | */ 22 | public class DebugHelper { 23 | 24 | public static void sendMailDev(Activity context, String subject, String content) { 25 | Log.d("sendMailDev", content); 26 | Intent intent = new Intent(Intent.ACTION_SENDTO); 27 | intent.setData(Uri.parse("mailto:")); 28 | intent.putExtra(Intent.EXTRA_EMAIL, new String[]{context.getString(R.string.developer_mail)}); 29 | intent.putExtra(Intent.EXTRA_SUBJECT, subject); 30 | intent.putExtra(Intent.EXTRA_TEXT, content); 31 | 32 | context.startActivity(intent); 33 | } 34 | 35 | public static String generateReport(Exception e, Activity activity, String extra) { 36 | String[] errorMessage = {e.getMessage(), e.toString()}; 37 | 38 | return generateReport(errorMessage, activity, extra); 39 | } 40 | 41 | public static String generateReport(String[] errorMessage, Activity activity, String extra) { 42 | String message = "Feel free to add a message explaining the circumstances of the error below, I'll do my best to help you:\n\n\n\n"; 43 | 44 | message += "-------------\n"; 45 | message += "Also make sure that your Shaarli instance is correctly setup. Common causes for issues:\n"; 46 | message += " - RestAPI not enabled in Shaarli settings\n"; 47 | message += " - mod_rewrite not enabled on Apache\n"; 48 | message += " - Using password authentication with a custom theme\n"; 49 | message += " - Using a hosting provider that injects Javascript in webpage.\n\n"; 50 | 51 | message += "Thanks for the report, I'll try to answer as soon as possible!\n\n"; 52 | 53 | message += "Bugtracker: https://github.com/dimtion/Shaarlier/issues\n\n"; 54 | 55 | message += "-------------\n"; 56 | 57 | message += "Below this line is an automated report, if you don't feel comfortable sharing "; 58 | message += "some of these fields please remove them. This data is exclusively used for debugging.\n\n"; 59 | 60 | message += "-----BEGIN REPORT-----\n"; 61 | message += "Report type: ERROR \n"; 62 | message += "Android version: " + " " + Build.VERSION.RELEASE + "\n"; 63 | try { 64 | message += "App version: " + activity.getPackageManager() 65 | .getPackageInfo(activity.getPackageName(), 0).versionName + "\n"; 66 | } catch (PackageManager.NameNotFoundException e1) { 67 | e1.printStackTrace(); 68 | } 69 | message += "Activity: " + activity.toString() + "\n"; 70 | 71 | TimeZone tz = TimeZone.getTimeZone("UTC"); 72 | DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'"); 73 | df.setTimeZone(tz); 74 | 75 | message += "Date: " + df.format(new Date()) + "\n\n"; 76 | 77 | for (String m : errorMessage) { 78 | message += m + "\n\n"; 79 | } 80 | 81 | message += "-----EXTRA-----\n" + extra + "\n"; 82 | 83 | message += "-----END REPORT-----\n\n"; 84 | return message; 85 | } 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/helper/EncryptionHelper.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.helper; 2 | 3 | import android.util.Base64; 4 | 5 | import java.security.InvalidAlgorithmParameterException; 6 | import java.security.InvalidKeyException; 7 | import java.security.NoSuchAlgorithmException; 8 | import java.security.SecureRandom; 9 | 10 | import javax.crypto.BadPaddingException; 11 | import javax.crypto.Cipher; 12 | import javax.crypto.IllegalBlockSizeException; 13 | import javax.crypto.KeyGenerator; 14 | import javax.crypto.NoSuchPaddingException; 15 | import javax.crypto.SecretKey; 16 | import javax.crypto.spec.IvParameterSpec; 17 | import javax.crypto.spec.SecretKeySpec; 18 | 19 | /** 20 | * Created by dimtion on 13/05/2015. 21 | * A simple class to encrypt and decrypt simple data 22 | * (Probably needs review) 23 | */ 24 | public class EncryptionHelper { 25 | public final static int KEY_LENGTH = 256; 26 | public final static int IV_LENGTH = 16; 27 | 28 | public static SecretKey generateKey() throws NoSuchAlgorithmException { 29 | 30 | SecureRandom secureRandom = new SecureRandom(); 31 | // Do *not* seed secureRandom! Automatically seeded from system entropy. 32 | KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); 33 | keyGenerator.init(KEY_LENGTH, secureRandom); 34 | return keyGenerator.generateKey(); 35 | } 36 | 37 | public static byte[] generateInitialVector() { 38 | SecureRandom random = new SecureRandom(); 39 | return random.generateSeed(IV_LENGTH); 40 | } 41 | 42 | public static String secretKeyToString(SecretKey secretKey) { 43 | return Base64.encodeToString(secretKey.getEncoded(), Base64.DEFAULT); 44 | } 45 | 46 | public static SecretKey stringToSecretKey(String stringKey) { 47 | byte[] encodedKey = Base64.decode(stringKey, Base64.DEFAULT); 48 | return new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES"); 49 | } 50 | 51 | private static byte[] encryptDecrypt(int mode, byte[] clear, SecretKey key, byte[] initialVector) 52 | throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidAlgorithmParameterException { 53 | Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); 54 | IvParameterSpec ivParameterSpec = new IvParameterSpec(initialVector); 55 | 56 | cipher.init(mode, key, ivParameterSpec); 57 | return cipher.doFinal(clear); 58 | } 59 | 60 | public static byte[] encrypt(byte[] clear, SecretKey key, byte[] initialVector) 61 | throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidAlgorithmParameterException { 62 | return encryptDecrypt(Cipher.ENCRYPT_MODE, clear, key, initialVector); 63 | } 64 | 65 | public static byte[] decrypt(byte[] encrypted, SecretKey key, byte[] initialVector) 66 | throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidAlgorithmParameterException { 67 | return encryptDecrypt(Cipher.DECRYPT_MODE, encrypted, key, initialVector); 68 | } 69 | 70 | public static byte[] stringToBase64(String clear) { 71 | return Base64.encode(clear.getBytes(), Base64.DEFAULT); 72 | } 73 | 74 | public static String base64ToString(byte[] data) { 75 | return new String(Base64.decode(data, Base64.DEFAULT)); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/helper/ShareIntentParser.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.helper; 2 | 3 | import android.app.Activity; 4 | import android.content.Intent; 5 | import android.util.Log; 6 | 7 | import androidx.core.app.ShareCompat; 8 | 9 | import com.dimtion.shaarlier.exceptions.UnsupportedIntent; 10 | import com.dimtion.shaarlier.models.Link; 11 | import com.dimtion.shaarlier.models.ShaarliAccount; 12 | import com.dimtion.shaarlier.network.NetworkUtils; 13 | import com.dimtion.shaarlier.utils.UserPreferences; 14 | 15 | import java.util.Objects; 16 | 17 | public class ShareIntentParser { 18 | 19 | private static final String LOGGER_NAME = "ShareIntentParser"; 20 | 21 | private static final String EXTRA_TAGS = "tags"; 22 | private static final String EXTRA_DESCRIPTION = "description"; 23 | 24 | private static final String TEXT_MIME_TYPE = "text/plain"; 25 | 26 | private final UserPreferences userPreferences; 27 | private final ShaarliAccount shaarliAccount; 28 | 29 | public ShareIntentParser(final ShaarliAccount shaarliAccount, final UserPreferences userPreferences) { 30 | this.userPreferences = userPreferences; 31 | this.shaarliAccount = shaarliAccount; 32 | } 33 | 34 | /** 35 | * Create a populated {@link Link} from an {@link Intent}. 36 | * 37 | * @param activity source activity 38 | * @param intent intent to parse 39 | * @return a populated Link 40 | * @throws UnsupportedIntent if throwing an unsupported intent. 41 | */ 42 | public Link parse(final Activity activity, final Intent intent) throws UnsupportedIntent { 43 | if (!Intent.ACTION_SEND.equals(intent.getAction()) || !TEXT_MIME_TYPE.equals(intent.getType())) { 44 | Log.e(LOGGER_NAME, "Unsupported action " + intent.getAction() + " or mime type " + intent.getType()); 45 | throw new UnsupportedIntent(); 46 | } 47 | 48 | final ShareCompat.IntentReader reader = ShareCompat.IntentReader.from(activity); 49 | 50 | final String url = extractUrl(reader.getText().toString()); 51 | final String title = userPreferences.isAutoTitle() ? extractTitle(reader.getSubject()) : ""; 52 | final String defaultTags = intent.getStringExtra(EXTRA_TAGS) != null ? 53 | intent.getStringExtra(EXTRA_TAGS) : ""; 54 | final String description; 55 | if (userPreferences.isAutoDescription() && Objects.nonNull(intent.getStringExtra(EXTRA_DESCRIPTION))) { 56 | description = intent.getStringExtra(EXTRA_DESCRIPTION); 57 | } else { 58 | description = ""; 59 | } 60 | 61 | return new Link( 62 | url, 63 | title, 64 | description, 65 | defaultTags, 66 | userPreferences.isPrivateShare(), 67 | shaarliAccount, 68 | userPreferences.isTweet(), 69 | userPreferences.isToot(), 70 | null, 71 | null 72 | ); 73 | } 74 | 75 | /** 76 | * Extract an url located in a text 77 | * 78 | * @param text: a text containing an url 79 | * @return url present in the input text 80 | */ 81 | private String extractUrl(final String text) { 82 | String finalUrl; 83 | 84 | // Trim the url because for annoying apps that send to much data: 85 | finalUrl = text.trim(); 86 | 87 | String[] possible_urls = finalUrl.split(" "); 88 | 89 | for (String url : possible_urls) { 90 | if (NetworkUtils.isUrl(url)) { 91 | finalUrl = url; 92 | break; 93 | } 94 | } 95 | 96 | finalUrl = finalUrl.substring(finalUrl.lastIndexOf(" ") + 1); 97 | finalUrl = finalUrl.substring(finalUrl.lastIndexOf("\n") + 1); 98 | 99 | // If the url is incomplete: 100 | if (NetworkUtils.isUrl("http://" + finalUrl) && !NetworkUtils.isUrl(finalUrl)) { 101 | finalUrl = "http://" + finalUrl; 102 | } 103 | // Delete trackers: 104 | if (finalUrl.contains("&utm_source=")) { 105 | finalUrl = finalUrl.substring(0, finalUrl.indexOf("&utm_source=")); 106 | } 107 | if (finalUrl.contains("?utm_source=")) { 108 | finalUrl = finalUrl.substring(0, finalUrl.indexOf("?utm_source=")); 109 | } 110 | if (finalUrl.contains("#xtor=RSS-")) { 111 | finalUrl = finalUrl.substring(0, finalUrl.indexOf("#xtor=RSS-")); 112 | } 113 | 114 | return finalUrl; 115 | } 116 | 117 | /** 118 | * Extract the title from a subject line 119 | * 120 | * @param subject: intent subject 121 | * @return Title 122 | */ 123 | private String extractTitle(final String subject) { 124 | if (subject != null && !NetworkUtils.isUrl(subject)) { 125 | return subject; 126 | } 127 | 128 | return ""; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/models/Link.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.models; 2 | 3 | import java.io.Serializable; 4 | import java.util.Arrays; 5 | import java.util.List; 6 | 7 | public class Link implements Serializable { 8 | private Integer id; 9 | private String url; 10 | private String title; 11 | private String description; 12 | private String tags; 13 | private boolean isPrivate; 14 | private ShaarliAccount account; 15 | private boolean tweet; 16 | private boolean toot; 17 | 18 | private String datePostLink; 19 | private String token; 20 | 21 | public Link(String url, String title, String description, String tags, boolean isPrivate, ShaarliAccount account, boolean tweet, boolean toot, String datePostLink, String token) { 22 | this.url = url; 23 | this.title = title; 24 | this.description = description; 25 | this.tags = tags; 26 | this.isPrivate = isPrivate; 27 | this.account = account; 28 | this.tweet = tweet; 29 | this.toot = toot; 30 | this.datePostLink = datePostLink; 31 | this.token = token; 32 | } 33 | 34 | public Link(Link other) { 35 | this.id = other.id; 36 | this.url = other.url; 37 | this.title = other.title; 38 | this.description = other.description; 39 | this.tags = other.tags; 40 | this.isPrivate = other.isPrivate; 41 | this.account = other.account; 42 | this.tweet = other.tweet; 43 | this.toot = other.toot; 44 | this.datePostLink = other.datePostLink; 45 | this.token = other.token; 46 | } 47 | 48 | public Integer getId() { 49 | return id; 50 | } 51 | 52 | public void setId(int id) { 53 | this.id = id; 54 | } 55 | 56 | public String getUrl() { 57 | return url; 58 | } 59 | 60 | public void setUrl(String url) { 61 | this.url = url; 62 | } 63 | 64 | public String getTitle() { 65 | return title; 66 | } 67 | 68 | public void setTitle(String title) { 69 | this.title = title; 70 | } 71 | 72 | public String getDescription() { 73 | return description; 74 | } 75 | 76 | public void setDescription(String description) { 77 | this.description = description; 78 | } 79 | 80 | @Deprecated 81 | public String getTags() { 82 | return tags; 83 | } 84 | 85 | public List getTagList() { 86 | return Arrays.asList(tags.split(", ")); 87 | } 88 | 89 | public void setTags(String tags) { 90 | this.tags = tags; 91 | } 92 | 93 | public boolean isPrivate() { 94 | return isPrivate; 95 | } 96 | 97 | public void setPrivate(boolean aPrivate) { 98 | isPrivate = aPrivate; 99 | } 100 | 101 | public ShaarliAccount getAccount() { 102 | return account; 103 | } 104 | 105 | public void setAccount(ShaarliAccount account) { 106 | this.account = account; 107 | } 108 | 109 | public boolean isTweet() { 110 | return tweet; 111 | } 112 | 113 | public void setTweet(boolean tweet) { 114 | this.tweet = tweet; 115 | } 116 | 117 | public boolean isToot() { 118 | return toot; 119 | } 120 | 121 | public void setToot(boolean toot) { 122 | this.toot = toot; 123 | } 124 | 125 | public String getDatePostLink() { 126 | return datePostLink; 127 | } 128 | 129 | public void setDatePostLink(String datePostLink) { 130 | this.datePostLink = datePostLink; 131 | } 132 | 133 | public String getToken() { 134 | return token; 135 | } 136 | 137 | public void setToken(String token) { 138 | this.token = token; 139 | } 140 | 141 | public boolean seemsNotNew() { 142 | return ( 143 | (description != null && !description.trim().equals("")) 144 | || (tags != null && !tags.trim().equals("")) 145 | || id != null 146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/models/ShaarliAccount.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.models; 2 | 3 | import androidx.annotation.NonNull; 4 | 5 | import java.io.Serializable; 6 | 7 | /** 8 | * Created by dimtion on 11/05/2015. 9 | * A Shaarli Account 10 | */ 11 | 12 | public class ShaarliAccount implements Serializable { 13 | public static final int AUTH_METHOD_AUTO = 0; 14 | public static final int AUTH_METHOD_PASSWORD = 1; 15 | public static final int AUTH_METHOD_RESTAPI = 2; 16 | public static final int AUTH_METHOD_MOCK = 3; 17 | 18 | private long id; 19 | private String urlShaarli; 20 | private String username; 21 | private String password; 22 | private String basicAuthUsername; 23 | private String basicAuthPassword; 24 | private String shortName; 25 | private String restAPIKey; 26 | private byte[] initialVector; 27 | private boolean validateCert; 28 | private int authMethod = AUTH_METHOD_AUTO; 29 | 30 | @NonNull 31 | @Override 32 | public String toString() { 33 | if (this.shortName != null && !this.shortName.equals("")) { 34 | return shortName; 35 | } else { 36 | return this.urlShaarli; 37 | } 38 | } 39 | 40 | public long getId() { 41 | return id; 42 | } 43 | 44 | public void setId(long id) { 45 | this.id = id; 46 | } 47 | 48 | public String getUrlShaarli() { 49 | return urlShaarli; 50 | } 51 | 52 | public void setUrlShaarli(String urlShaarli) { 53 | this.urlShaarli = urlShaarli; 54 | } 55 | 56 | public String getUsername() { 57 | return username; 58 | } 59 | 60 | public void setUsername(String username) { 61 | this.username = username; 62 | } 63 | 64 | public String getPassword() { 65 | return password; 66 | } 67 | 68 | public void setPassword(String password) { 69 | this.password = password; 70 | } 71 | 72 | public String getBasicAuthUsername() { 73 | return basicAuthUsername; 74 | } 75 | 76 | public void setBasicAuthUsername(String basicAuthUsername) { 77 | this.basicAuthUsername = basicAuthUsername; 78 | } 79 | 80 | public String getBasicAuthPassword() { 81 | return basicAuthPassword; 82 | } 83 | 84 | public void setBasicAuthPassword(String basicAuthPassword) { 85 | this.basicAuthPassword = basicAuthPassword; 86 | } 87 | 88 | public String getShortName() { 89 | return shortName; 90 | } 91 | 92 | public void setShortName(String shortName) { 93 | this.shortName = shortName; 94 | } 95 | 96 | public byte[] getInitialVector() { 97 | return initialVector; 98 | } 99 | 100 | public void setInitialVector(byte[] initialVector) { 101 | this.initialVector = initialVector; 102 | } 103 | 104 | public boolean isValidateCert() { 105 | return validateCert; 106 | } 107 | 108 | public void setValidateCert(boolean validateCert) { 109 | this.validateCert = validateCert; 110 | } 111 | 112 | public String getRestAPIKey() { 113 | return restAPIKey; 114 | } 115 | 116 | public void setRestAPIKey(String restAPIKey) { 117 | this.restAPIKey = restAPIKey; 118 | } 119 | 120 | public int getAuthMethod() { 121 | return authMethod; 122 | } 123 | 124 | public void setAuthMethod(int authMethod) { 125 | this.authMethod = authMethod; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/models/Tag.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.models; 2 | 3 | /** 4 | * Created by dimtion on 12/05/2015. 5 | * A tag from the SQLite db 6 | */ 7 | public class Tag { 8 | private long id; 9 | private ShaarliAccount masterAccount; 10 | private String value; 11 | private long masterAccountId; 12 | 13 | public long getId() { 14 | return id; 15 | } 16 | 17 | public void setId(long id) { 18 | this.id = id; 19 | } 20 | 21 | public ShaarliAccount getMasterAccount() { 22 | return masterAccount; 23 | } 24 | 25 | public void setMasterAccount(ShaarliAccount masterAccount) { 26 | this.masterAccount = masterAccount; 27 | this.masterAccountId = masterAccount.getId(); 28 | } 29 | 30 | public String getValue() { 31 | return value; 32 | } 33 | 34 | public void setValue(String value) { 35 | this.value = value; 36 | } 37 | 38 | public long getMasterAccountId() { 39 | return masterAccountId; 40 | } 41 | 42 | public void setMasterAccountId(long masterAccountId) { 43 | this.masterAccountId = masterAccountId; 44 | } 45 | 46 | @Override 47 | public String toString() { 48 | return getValue(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/network/MockNetworkManager.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.network; 2 | 3 | import com.dimtion.shaarlier.models.Link; 4 | 5 | import java.io.IOException; 6 | import java.util.ArrayList; 7 | import java.util.List; 8 | 9 | /** 10 | * Dummy NetworkManager used for debugging purposes 11 | */ 12 | public class MockNetworkManager implements NetworkManager { 13 | @Override 14 | public boolean isCompatibleShaarli() throws IOException { 15 | return true; 16 | } 17 | 18 | @Override 19 | public boolean login() throws IOException { 20 | return true; 21 | } 22 | 23 | @Override 24 | public Link prefetchLinkData(Link link) throws IOException { 25 | return link; 26 | } 27 | 28 | @Override 29 | public void pushLink(Link link) { 30 | } 31 | 32 | @Override 33 | public List retrieveTags() throws Exception { 34 | ArrayList tags = new ArrayList<>(); 35 | tags.add("test-tag1"); 36 | tags.add("test-tag2"); 37 | return tags; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/network/NetworkManager.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.network; 2 | 3 | import com.dimtion.shaarlier.models.Link; 4 | 5 | import java.io.IOException; 6 | import java.util.List; 7 | 8 | public interface NetworkManager { 9 | /** 10 | * returns whether the distant Shaarli server is compatible with this network manager 11 | * 12 | * @return true if compatible, false otherwise 13 | * @throws IOException 14 | */ 15 | boolean isCompatibleShaarli() throws IOException; 16 | 17 | /** 18 | * Check if the provided credentials are valid 19 | * Can set some state in the NetworkManager (like a cookie if necessary) 20 | * 21 | * @return true if the credentials are correct, false otherwise 22 | * @throws IOException 23 | */ 24 | boolean login() throws IOException; 25 | 26 | Link prefetchLinkData(Link link) throws IOException; 27 | 28 | void pushLink(Link link) throws IOException; 29 | 30 | List retrieveTags() throws Exception; 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/network/NetworkUtils.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.network; 2 | 3 | import android.app.Activity; 4 | import android.content.Context; 5 | import android.net.ConnectivityManager; 6 | import android.net.NetworkInfo; 7 | import android.util.Log; 8 | import android.webkit.URLUtil; 9 | 10 | import androidx.annotation.NonNull; 11 | 12 | import com.dimtion.shaarlier.models.ShaarliAccount; 13 | 14 | import org.jsoup.Jsoup; 15 | import org.jsoup.nodes.Document; 16 | 17 | public abstract class NetworkUtils { 18 | protected static final int TIME_OUT = 60_000; // Better for mobile connections 19 | 20 | private final static String LOGGER_NAME = NetworkUtils.class.getSimpleName(); 21 | 22 | private static final String[] DESCRIPTION_SELECTORS = { 23 | "meta[property=og:description]", 24 | "meta[name=description]", 25 | "meta[name=twitter:description]", 26 | "meta[name=mastodon:description]", 27 | }; 28 | 29 | /** 30 | * Check if a string is an url 31 | * TODO : unit test on this, I'm not quite sure it is perfect... 32 | */ 33 | public static boolean isUrl(String url) { 34 | return URLUtil.isValidUrl(url) && !"http://".equals(url); 35 | } 36 | 37 | /** 38 | * Change something which is close to a url to something that is really one 39 | */ 40 | public static String toUrl(String givenUrl) { 41 | String finalUrl = givenUrl; 42 | String protocol = "http://"; // Default value 43 | if ("".equals(givenUrl)) { 44 | return givenUrl; // Edge case, maybe need some discussion 45 | } 46 | 47 | if (!finalUrl.endsWith("/")) { 48 | finalUrl += '/'; 49 | } 50 | 51 | if (!(finalUrl.startsWith("http://") || finalUrl.startsWith("https://"))) { 52 | finalUrl = protocol + finalUrl; 53 | } 54 | 55 | return finalUrl; 56 | } 57 | 58 | /** 59 | * Method to test the network connection 60 | * 61 | * @return true if the device is connected to the network 62 | */ 63 | public static boolean testNetwork(@NonNull Activity parentActivity) { 64 | ConnectivityManager connMgr = (ConnectivityManager) parentActivity.getSystemService(Context.CONNECTIVITY_SERVICE); 65 | NetworkInfo networkInfo = connMgr.getActiveNetworkInfo(); 66 | return (networkInfo != null && networkInfo.isConnected()); 67 | } 68 | 69 | /** 70 | * Static method to load the title of a web page 71 | * 72 | * @param url the url of the web page 73 | * @return "" if there is an error, the page title in other cases 74 | */ 75 | public static String[] loadTitleAndDescription(@NonNull String url) { 76 | String title = ""; 77 | String description = ""; 78 | final Document pageResp; 79 | try { 80 | Log.i(LOGGER_NAME, "Loading url: " + url); 81 | pageResp = Jsoup.connect(url) 82 | .followRedirects(true) 83 | .execute() 84 | .parse(); 85 | title = pageResp.title(); 86 | } catch (final Exception e) { 87 | // Just abandon the task if there is a problem 88 | Log.e(LOGGER_NAME, "Failed to load title: " + e); 89 | return new String[]{title, description}; 90 | } 91 | 92 | // Many ways to get the description 93 | for (String selector : NetworkUtils.DESCRIPTION_SELECTORS) { 94 | try { 95 | description = pageResp.head().select(selector).first().attr("content"); 96 | } catch (final Exception e) { 97 | Log.e(LOGGER_NAME, "Failed to load description: " + e); 98 | } 99 | if (!"".equals(description)) { 100 | break; 101 | } 102 | } 103 | return new String[]{title, description}; 104 | } 105 | 106 | /** 107 | * Select the correct network manager based on the passed account 108 | */ 109 | public static NetworkManager getNetworkManager(ShaarliAccount account) { 110 | switch (account.getAuthMethod()) { 111 | case ShaarliAccount.AUTH_METHOD_MOCK: 112 | Log.i(LOGGER_NAME, "Selected MockNetworkManager (forced)"); 113 | return new MockNetworkManager(); 114 | case ShaarliAccount.AUTH_METHOD_PASSWORD: 115 | Log.i(LOGGER_NAME, "Selected PasswordNetworkManager (forced)"); 116 | return new PasswordNetworkManager(account); 117 | case ShaarliAccount.AUTH_METHOD_RESTAPI: 118 | Log.i(LOGGER_NAME, "Selected RestAPiNetworkManager (forced)"); 119 | return new RestAPINetworkManager(account); 120 | case ShaarliAccount.AUTH_METHOD_AUTO: 121 | if (1 == 0) { // Enabled only for debugging purposes 122 | Log.i(LOGGER_NAME, "Selected MockNetworkManager (auto)"); 123 | return new MockNetworkManager(); 124 | } 125 | 126 | if (account.getRestAPIKey() != null && account.getRestAPIKey().length() > 0) { 127 | Log.i(LOGGER_NAME, "Selected RestAPiNetworkManager (auto)"); 128 | return new RestAPINetworkManager(account); 129 | } else { 130 | Log.i(LOGGER_NAME, "Selected PasswordNetworkManager (auto)"); 131 | return new PasswordNetworkManager(account); 132 | } 133 | default: 134 | throw new RuntimeException("Invalid shaarli auth method"); 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/network/PasswordNetworkManager.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.network; 2 | 3 | import android.util.Base64; 4 | import android.util.Log; 5 | 6 | import androidx.annotation.NonNull; 7 | 8 | import com.dimtion.shaarlier.models.Link; 9 | import com.dimtion.shaarlier.models.ShaarliAccount; 10 | 11 | import org.json.JSONArray; 12 | import org.json.JSONException; 13 | import org.jsoup.Connection; 14 | import org.jsoup.Jsoup; 15 | import org.jsoup.nodes.Element; 16 | 17 | import java.io.IOException; 18 | import java.net.URLEncoder; 19 | import java.util.ArrayList; 20 | import java.util.Collections; 21 | import java.util.List; 22 | import java.util.Map; 23 | 24 | /** 25 | * Created by dimtion on 05/04/2015. 26 | * This class handle all the communications with Shaarli and other web services 27 | */ 28 | public class PasswordNetworkManager implements NetworkManager { 29 | 30 | private final String mShaarliUrl; 31 | private final String mUsername; 32 | private final String mPassword; 33 | private final boolean mValidateCert; 34 | private final String mBasicAuth; 35 | 36 | private Map mCookies; 37 | private String mToken; 38 | 39 | private String mDatePostLink; 40 | private String mSharedUrl; 41 | private Exception mLastError; 42 | 43 | private String mPrefetchedTitle; 44 | private String mPrefetchedDescription; 45 | private String mPrefetchedTags; 46 | 47 | PasswordNetworkManager(@NonNull ShaarliAccount account) { 48 | this.mShaarliUrl = account.getUrlShaarli(); 49 | this.mUsername = account.getUsername(); 50 | this.mPassword = account.getPassword(); 51 | this.mValidateCert = account.isValidateCert(); 52 | 53 | if (!"".equals(account.getBasicAuthUsername()) && !"".equals(account.getBasicAuthPassword())) { 54 | String login = account.getBasicAuthUsername() + ":" + account.getBasicAuthPassword(); 55 | this.mBasicAuth = new String(Base64.encode(login.getBytes(), Base64.NO_WRAP)); 56 | } else { 57 | this.mBasicAuth = ""; 58 | } 59 | } 60 | 61 | /** 62 | * Helper method which create a new connection to Shaarli 63 | * 64 | * @param url the url of the shaarli 65 | * @param method set the HTTP method to use 66 | * @return pre-made jsoupConnection 67 | */ 68 | private Connection newConnection(String url, Connection.Method method) { 69 | Connection jsoupConnection = Jsoup.connect(url); 70 | 71 | if (!"".equals(this.mBasicAuth)) { 72 | jsoupConnection = jsoupConnection.header("Authorization", "Basic " + this.mBasicAuth); 73 | } 74 | if (this.mCookies != null) { 75 | jsoupConnection = jsoupConnection.cookies(this.mCookies); 76 | } 77 | 78 | return jsoupConnection 79 | .validateTLSCertificates(this.mValidateCert) 80 | .timeout(NetworkUtils.TIME_OUT) 81 | .followRedirects(true) 82 | .method(method); 83 | } 84 | 85 | @Override 86 | public boolean isCompatibleShaarli() throws IOException { 87 | final String loginFormUrl = this.mShaarliUrl + "?do=login"; 88 | try { 89 | Connection.Response loginFormPage = this.newConnection(loginFormUrl, Connection.Method.GET).execute(); 90 | this.mCookies = loginFormPage.cookies(); 91 | this.mToken = loginFormPage.parse().body().select("input[name=token]").first().attr("value"); 92 | } catch (NullPointerException | IllegalArgumentException e) { 93 | Log.e("PasswordNetworkManager", "incompatible shaarli: " + e); 94 | return false; 95 | } 96 | Log.i("PasswordNetworkManager", "Compatible shaarli: "); 97 | return true; 98 | } 99 | 100 | /** 101 | * Method which retrieve the cookie saying that we are logged in 102 | */ 103 | @Override 104 | public boolean login() throws IOException { 105 | try { 106 | Connection.Response loginPage = this.newConnection(this.mShaarliUrl, Connection.Method.POST) 107 | .data("login", this.mUsername) 108 | .data("password", this.mPassword) 109 | .data("token", this.mToken) 110 | .data("returnurl", this.mShaarliUrl) 111 | .execute(); 112 | 113 | this.mCookies = loginPage.cookies(); 114 | loginPage.parse().body().select("a[href=?do=logout]").first() 115 | .attr("href"); // If this fails, you're not connected 116 | 117 | } catch (NullPointerException e) { 118 | Log.e("PasswordNetworkManager", "login error: " + e); 119 | return false; 120 | } 121 | return true; 122 | } 123 | 124 | /** 125 | * Method which retrieve a token for posting links 126 | * Update the cookie, the token and the date 127 | * Assume being logged in 128 | */ 129 | private void retrievePostLinkToken(String encodedSharedLink) throws IOException { 130 | final String postFormUrl = this.mShaarliUrl + "?post=" + encodedSharedLink; 131 | 132 | Connection.Response postFormPage = this.newConnection(postFormUrl, Connection.Method.GET) 133 | .execute(); 134 | final Element postFormBody = postFormPage.parse().body(); 135 | 136 | // Update our situation: 137 | // TODO: Soft fail: if one field does not load, try the others anyway 138 | mToken = postFormBody.select("input[name=token]").first().attr("value"); 139 | mDatePostLink = postFormBody.select("input[name=lf_linkdate]").first().attr("value"); // Date chosen by the server 140 | mSharedUrl = postFormBody.select("input[name=lf_url]").first().attr("value"); 141 | mPrefetchedTitle = postFormBody.select("input[name=lf_title]").first().attr("value"); 142 | mPrefetchedDescription = postFormBody.select("textarea[name=lf_description]").first().html(); 143 | mPrefetchedTags = postFormBody.select("input[name=lf_tags]").first().attr("value"); 144 | } 145 | 146 | @Override 147 | public Link prefetchLinkData(Link link) throws IOException { 148 | String encodedShareUrl = URLEncoder.encode(link.getUrl(), "UTF-8"); 149 | retrievePostLinkToken(encodedShareUrl); 150 | 151 | Link newLink = new Link(link); 152 | newLink.setUrl(mSharedUrl); 153 | newLink.setTitle(mPrefetchedTitle); 154 | newLink.setDescription(mPrefetchedDescription); 155 | newLink.setTags(mPrefetchedTags); 156 | newLink.setDatePostLink(mDatePostLink); 157 | newLink.setToken(mToken); 158 | return newLink; 159 | } 160 | 161 | /** 162 | * Method which publishes a link to shaarli 163 | * Assume being logged in 164 | * TODO: use the prefetch function 165 | */ 166 | @Override 167 | public void pushLink(Link link) throws IOException { 168 | String encodedShareUrl = URLEncoder.encode(link.getUrl(), "UTF-8"); 169 | retrievePostLinkToken(encodedShareUrl); 170 | 171 | if (NetworkUtils.isUrl(link.getUrl())) { // In case the url isn't really one, just post the one chosen by the server. 172 | this.mSharedUrl = link.getUrl(); 173 | } 174 | 175 | final String postUrl = this.mShaarliUrl + "?post=" + encodedShareUrl; 176 | 177 | Connection postPageConn = this.newConnection(postUrl, Connection.Method.POST) 178 | .data("save_edit", "Save") 179 | .data("token", this.mToken) 180 | .data("lf_tags", link.getTags()) 181 | .data("lf_linkdate", this.mDatePostLink) 182 | .data("lf_url", this.mSharedUrl) 183 | .data("lf_title", link.getTitle()) 184 | .data("lf_description", link.getDescription()); 185 | if (link.isPrivate()) postPageConn.data("lf_private", "on"); 186 | if (link.isTweet()) postPageConn.data("tweet", "on"); 187 | if (link.isToot()) postPageConn.data("toot", "on"); 188 | postPageConn.execute(); // Then we post 189 | } 190 | 191 | @Override 192 | public List retrieveTags() { 193 | List tags = new ArrayList<>(); 194 | try { 195 | String[] awesompleteTags = this.retrieveTagsFromAwesomplete(); 196 | Collections.addAll(tags, awesompleteTags); 197 | } catch (Exception e) { 198 | Log.w("TAG", e.toString()); 199 | } 200 | try { 201 | String[] wsTags = this.retrieveTagsFromWs(); // kept for compatibility with old Shaarli instances 202 | Collections.addAll(tags, wsTags); 203 | } catch (Exception e) { 204 | Log.w("TAG", e.toString()); 205 | } 206 | return tags; 207 | } 208 | 209 | /** 210 | * Method which retrieve tags from the WS (old shaarli) 211 | * Assume being logged in 212 | */ 213 | private String[] retrieveTagsFromWs() throws IOException, JSONException { 214 | final String requestUrl = this.mShaarliUrl + "?ws=tags&term=+"; 215 | String[] predictionsArr = {}; 216 | String json = this.newConnection(requestUrl, Connection.Method.POST) 217 | .ignoreContentType(true) 218 | .execute() 219 | .body(); 220 | 221 | JSONArray ja = new JSONArray(json); 222 | predictionsArr = new String[ja.length()]; 223 | for (int i = 0; i < ja.length(); i++) { 224 | // add each entry to our array 225 | predictionsArr[i] = ja.getString(i); 226 | } 227 | 228 | return predictionsArr; 229 | } 230 | 231 | /** 232 | * Method which retrieve tags from awesomplete (new shaarli) 233 | * Assume being logged in 234 | */ 235 | private String[] retrieveTagsFromAwesomplete() throws IOException { 236 | final String requestUrl = this.mShaarliUrl + "?post="; 237 | String tagsString = this.newConnection(requestUrl, Connection.Method.GET) 238 | .execute() 239 | .parse() 240 | .body() 241 | .select("input[name=lf_tags]") 242 | .first() 243 | .attr("data-list"); 244 | return tagsString.split(", "); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/network/RestAPINetworkManager.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.network; 2 | 3 | import android.util.Log; 4 | 5 | import androidx.annotation.NonNull; 6 | 7 | import com.dimtion.shaarlier.models.Link; 8 | import com.dimtion.shaarlier.models.ShaarliAccount; 9 | 10 | import org.json.JSONArray; 11 | import org.json.JSONException; 12 | import org.json.JSONObject; 13 | import org.jsoup.Connection; 14 | import org.jsoup.HttpStatusException; 15 | import org.jsoup.Jsoup; 16 | 17 | import java.io.IOException; 18 | import java.net.URL; 19 | import java.util.ArrayList; 20 | import java.util.Date; 21 | import java.util.List; 22 | 23 | import io.jsonwebtoken.Jwts; 24 | import io.jsonwebtoken.SignatureAlgorithm; 25 | 26 | public class RestAPINetworkManager implements NetworkManager { 27 | private static final String TAGS_URL = "api/v1/tags"; 28 | private static final String INFO_URL = "api/v1/info"; 29 | private static final String LINK_URL = "api/v1/links"; 30 | 31 | private final static String LOGGER_NAME = NetworkUtils.class.getSimpleName(); 32 | 33 | private final ShaarliAccount mAccount; 34 | 35 | RestAPINetworkManager(@NonNull ShaarliAccount account) { 36 | this.mAccount = account; 37 | } 38 | 39 | @Override 40 | public boolean isCompatibleShaarli() throws IOException { 41 | String url = new URL(this.mAccount.getUrlShaarli() + INFO_URL).toExternalForm(); 42 | Log.i(LOGGER_NAME, "Check Shaarli compatibility"); 43 | try { 44 | final String body = this.newConnection(url, Connection.Method.GET) 45 | .execute() 46 | .body(); 47 | Log.d(LOGGER_NAME, "JWT used: " + body); 48 | final JSONObject resp = new JSONObject(body); 49 | 50 | // For example check that the settings var exists 51 | if (resp.getJSONObject("settings") == null) { 52 | Log.i(LOGGER_NAME, "Settings var does not exists"); 53 | return false; 54 | } 55 | } catch (final HttpStatusException e) { 56 | Log.w(LOGGER_NAME, "Exception while calling Shaarli: " + e.getMessage(), e); 57 | if (e.getStatusCode() == 404) { 58 | Log.i(LOGGER_NAME, "API V1 not supported"); 59 | return false; // API V1 not supported 60 | } else { 61 | return e.getStatusCode() == 401; // API V1 supported 62 | } 63 | } catch (final JSONException e) { 64 | Log.e(LOGGER_NAME, "JSONException while calling shaarli: " + e.getMessage(), e); 65 | return false; 66 | } catch (final IllegalArgumentException e) { 67 | // This exception arises in a bug in JWT module. I added that to help with the debugging 68 | Log.e(LOGGER_NAME, "Error generating JWT: " + e.getMessage(), e); 69 | throw new IOException("isCompatibleShaarli: " + e, e); 70 | } 71 | // assume a 2XX or 3XX means API V1 supported 72 | Log.i(LOGGER_NAME, "API V1 supported"); 73 | return true; 74 | } 75 | 76 | @Override 77 | public boolean login() throws IOException { 78 | // TODO: we could set some account parameters from here like default_private_links 79 | String url = new URL(this.mAccount.getUrlShaarli() + INFO_URL).toExternalForm(); 80 | try { 81 | Log.d("Login", this.mAccount.getRestAPIKey()); 82 | String body = this.newConnection(url, Connection.Method.GET) 83 | .execute() 84 | .body(); 85 | Log.i("Login", body); 86 | } catch (HttpStatusException e) { 87 | Log.w("Login", e); 88 | Log.w("Login", e.getMessage()); 89 | return false; 90 | } 91 | return true; 92 | } 93 | 94 | @Override 95 | public Link prefetchLinkData(Link link) throws IOException { 96 | // TODO: There might be some bugs here, e.g: 97 | // - If the scheme used is not the same that on the saved link 98 | // - If there are tracking tags that don't match 99 | // We might want to open an Issue on Shaarli to get feedback 100 | String url = new URL(this.mAccount.getUrlShaarli() + LINK_URL).toExternalForm(); 101 | String body = this.newConnection(url, Connection.Method.GET) 102 | .data("offset", "0") 103 | .data("limit", "1") 104 | .data("searchterm", link.getUrl()) 105 | .execute() 106 | .body(); 107 | Log.d("RestAPI:prefetch", body); 108 | 109 | Link updatedLink = new Link(link); 110 | try { 111 | JSONArray resp = new JSONArray(body); 112 | if (resp.length() < 1) { 113 | Log.i("RestAPI:prefetch", "New link"); 114 | } else { 115 | Log.i("RestAPI:prefetch", "Found 1 link result (not new link)"); 116 | JSONObject returnedLink = resp.getJSONObject(0); 117 | updatedLink.setUrl(returnedLink.getString("url")); 118 | updatedLink.setTitle(returnedLink.getString("title")); 119 | updatedLink.setDescription(returnedLink.getString("description")); 120 | updatedLink.setPrivate(returnedLink.getBoolean("private")); 121 | JSONArray jsonTags = returnedLink.getJSONArray("tags"); 122 | ArrayList tags = new ArrayList<>(); 123 | for (int i = 0; i < jsonTags.length(); i++) { 124 | tags.add(jsonTags.getString(i)); 125 | } 126 | updatedLink.setTags(String.join(", ", tags)); 127 | } 128 | } catch (JSONException e) { 129 | Log.e("RestAPI:prefetch", e.toString()); 130 | } 131 | return updatedLink; 132 | } 133 | 134 | @Override 135 | public void pushLink(Link link) throws IOException { 136 | String url = new URL(link.getAccount().getUrlShaarli() + LINK_URL).toExternalForm(); 137 | 138 | // Create the request body: 139 | JSONObject requestBody = new JSONObject(); 140 | try { 141 | requestBody.put("url", link.getUrl()); 142 | requestBody.put("title", link.getTitle()); 143 | requestBody.put("description", link.getDescription()); 144 | requestBody.put("tags", new JSONArray(link.getTagList())); 145 | requestBody.put("private", link.isPrivate()); 146 | if (link.isTweet()) { // TODO tweet 147 | Log.e("RequestAPI:post", "Tweet feature not implemented"); 148 | } 149 | if (link.isToot()) { // TODO toot 150 | Log.e("RequestAPI:post", "Toot feature not implemented"); 151 | } 152 | Log.d("PushLink", requestBody.toString(2)); 153 | } catch (JSONException e) { 154 | Log.e("RequestAPI:post", e.toString()); 155 | } 156 | 157 | // 1. Try POST only if link id is null 158 | if (link.getId() == null) { 159 | Connection.Response resp = this.newConnection(url, Connection.Method.POST) 160 | .requestBody(requestBody.toString()) 161 | .ignoreHttpErrors(true) 162 | .execute(); 163 | if (200 <= resp.statusCode() && resp.statusCode() <= 201) { // HTTP Ok after redirect 164 | return; // We are done here 165 | } else if (resp.statusCode() != 409) { // 409 HTTP Conflict 166 | // every other error we bubble up 167 | throw new HttpStatusException("Error requesting: " + url + " : " + resp.statusCode(), resp.statusCode(), url); 168 | } 169 | // On conflict, we update our id 170 | try { 171 | link.setId(new JSONObject(resp.body()).getInt("id")); 172 | } catch (JSONException e) { 173 | throw new IOException("Invalid id sent by Shaarli: " + e); 174 | } 175 | } 176 | 177 | // 2. If POST failed or link had id try PUT: 178 | this.newConnection(url + "/" + link.getId(), Connection.Method.PUT) 179 | .requestBody(requestBody.toString()) 180 | .ignoreHttpErrors(false) 181 | .execute(); 182 | } 183 | 184 | /** 185 | * Helper method to a new connection to the shaarli instance 186 | * 187 | * @param url to connect to 188 | * @param method HTTP method 189 | * @return a new opened connection 190 | */ 191 | private Connection newConnection(String url, Connection.Method method) { 192 | Log.i(LOGGER_NAME, "Creating new connection " + url + " : " + method); 193 | return Jsoup.connect(url) 194 | .header("Authorization", "Bearer " + this.getJwt()) 195 | .header("Content-Type", "application/json") 196 | .ignoreContentType(true) // application/json 197 | .validateTLSCertificates(this.mAccount.isValidateCert()) 198 | .timeout(NetworkUtils.TIME_OUT) 199 | .followRedirects(true) 200 | .method(method); 201 | } 202 | 203 | @Override 204 | public List retrieveTags() throws Exception { 205 | String url = new URL(this.mAccount.getUrlShaarli() + TAGS_URL).toExternalForm(); 206 | String body = this.newConnection(url, Connection.Method.GET) 207 | .execute() 208 | .body(); 209 | List tags = new ArrayList(); 210 | JSONArray resp = new JSONArray(body); 211 | for (int i = 0; i < resp.length(); i++) { 212 | tags.add(resp.getJSONObject(i).getString("name")); 213 | } 214 | return tags; 215 | } 216 | 217 | /** 218 | * Inspired by gitlab 219 | * License: MIT 220 | * Copyright 2017 braincoke 221 | * 222 | * @return JWT encoded in base 64 223 | */ 224 | String getJwt() { 225 | // TODO: we are obligated to stay with jjwt 0.9.1 because of Shaarli weak keys 226 | 227 | Log.i("JWT", "Generating JWT for account " + this.mAccount); 228 | // iat in the payload 229 | Date date = new Date(); 230 | // During debugging I found that given that some servers and phones are not absolutely in sync 231 | // It happens that the token would looked like being generated in the future 232 | // To compensate that we remove 5 seconds from the actual date. 233 | date.setTime(date.getTime() - 5000); 234 | // The key used to sign the token, you can find it by logging to your Shaarli instance 235 | // and then going to "Tools" 236 | byte[] signingKey = this.mAccount.getRestAPIKey().getBytes(); 237 | // We create the token with the Jwts library 238 | return Jwts.builder() 239 | .setIssuedAt(date) 240 | .setHeaderParam("typ", "JWT") 241 | .signWith(SignatureAlgorithm.HS512, signingKey) 242 | .compact(); 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/services/NetworkService.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.services; 2 | 3 | import android.app.IntentService; 4 | import android.app.NotificationChannel; 5 | import android.app.NotificationManager; 6 | import android.app.PendingIntent; 7 | import android.content.Context; 8 | import android.content.Intent; 9 | import android.os.Build; 10 | import android.os.Handler; 11 | import android.os.Looper; 12 | import android.os.Message; 13 | import android.os.Messenger; 14 | import android.util.Log; 15 | import android.widget.Toast; 16 | 17 | import androidx.annotation.NonNull; 18 | import androidx.annotation.Nullable; 19 | import androidx.core.app.NotificationCompat; 20 | import androidx.core.app.TaskStackBuilder; 21 | 22 | import com.dimtion.shaarlier.R; 23 | import com.dimtion.shaarlier.models.Link; 24 | import com.dimtion.shaarlier.models.ShaarliAccount; 25 | import com.dimtion.shaarlier.network.NetworkManager; 26 | import com.dimtion.shaarlier.network.NetworkUtils; 27 | 28 | import java.io.IOException; 29 | import java.util.Arrays; 30 | 31 | public class NetworkService extends IntentService { 32 | public static final String EXTRA_MESSENGER = "com.dimtion.shaarlier.networkservice.EXTRA_MESSENGER"; 33 | private static final String LOGGER_NAME = NetworkService.class.getSimpleName(); 34 | public static final int NO_ERROR = 0; 35 | public static final int NETWORK_ERROR = 1; 36 | public static final int TOKEN_ERROR = 2; 37 | public static final int LOGIN_ERROR = 3; 38 | 39 | public static final int RETRIEVE_TITLE_ID = 100; 40 | public static final int PREFETCH_LINK = 101; 41 | 42 | public static final int INTENT_CHECK = 201; 43 | public static final int INTENT_POST = 202; 44 | public static final int INTENT_PREFETCH = 203; 45 | public static final int INTENT_RETRIEVE_TITLE_AND_DESCRIPTION = 204; 46 | 47 | // Notification channels 48 | public static final String CHANNEL_ID = "error_channel"; 49 | 50 | private String loadedTitle; 51 | 52 | private Context mContext; 53 | private Handler mToastHandler; 54 | private Exception mError; 55 | private String loadedDescription; 56 | 57 | public NetworkService() { 58 | super("NetworkService"); 59 | } 60 | 61 | @Override 62 | public void onCreate(){ 63 | super.onCreate(); 64 | mContext = this; 65 | mToastHandler = new Handler(Looper.getMainLooper()); 66 | this.createNotificationChannel(); 67 | } 68 | 69 | @Override 70 | protected void onHandleIntent(Intent intent) { 71 | int action = intent.getIntExtra("action", -1); 72 | 73 | switch (action) { 74 | case INTENT_CHECK: 75 | ShaarliAccount accountToTest = (ShaarliAccount) intent.getSerializableExtra("account"); 76 | 77 | int shaarliLstatus = checkShaarli(accountToTest); 78 | Exception object = shaarliLstatus == NETWORK_ERROR ? mError : null; 79 | sendBackMessage(intent, shaarliLstatus, object); 80 | break; 81 | 82 | case INTENT_POST: 83 | Link link = (Link) intent.getSerializableExtra("link"); 84 | 85 | if ("".equals(link.getTitle()) && this.loadedTitle != null) { 86 | link.setTitle(this.loadedTitle); 87 | this.loadedTitle = null; 88 | } 89 | if ("".equals(link.getDescription()) && this.loadedDescription != null) { 90 | link.setDescription(this.loadedDescription); 91 | this.loadedDescription = null; 92 | } 93 | postLink(link); 94 | stopSelf(); 95 | break; 96 | 97 | case INTENT_PREFETCH: 98 | Link sharedLink = (Link) intent.getSerializableExtra("link"); 99 | Link prefetchedLink = prefetchLink(sharedLink); 100 | sendBackMessage(intent, PREFETCH_LINK, prefetchedLink); 101 | break; 102 | 103 | case INTENT_RETRIEVE_TITLE_AND_DESCRIPTION: 104 | this.loadedTitle = ""; 105 | this.loadedDescription = ""; 106 | 107 | final String url = intent.getStringExtra("url"); 108 | 109 | boolean autoTitle = intent.getBooleanExtra("autoTitle", true); 110 | boolean autoDescription = intent.getBooleanExtra("autoDescription", false); 111 | 112 | final String[] pageTitleAndDescription = getPageTitleAndDescription(url); 113 | Log.i(LOGGER_NAME, "Title and description: " + Arrays.toString(pageTitleAndDescription)); 114 | 115 | if (autoTitle) { 116 | this.loadedTitle = pageTitleAndDescription[0]; 117 | } 118 | if (autoDescription) { 119 | this.loadedDescription = pageTitleAndDescription[1]; 120 | } 121 | 122 | sendBackMessage(intent, RETRIEVE_TITLE_ID, pageTitleAndDescription); 123 | break; 124 | default: 125 | // Do nothing 126 | Log.e("NETWORK_ERROR", "Unknown intent action received: " + action); 127 | break; 128 | } 129 | } 130 | 131 | private void sendBackMessage(@NonNull Intent intent, int message_id, @Nullable Object message_content) { 132 | // Load the messenger to communicate back to the activity 133 | Messenger messenger = (Messenger) intent.getExtras().get(EXTRA_MESSENGER); 134 | Message msg = Message.obtain(); 135 | 136 | msg.arg1 = message_id; 137 | msg.obj = message_content; 138 | try { 139 | assert messenger != null; 140 | messenger.send(msg); 141 | } catch (android.os.RemoteException | AssertionError e1) { 142 | Log.w(getClass().getName(), "Exception sending message", e1); 143 | } 144 | } 145 | 146 | /** 147 | * Try to prefetch the data of the link 148 | * Will return exactly the same link if the link does not exist 149 | * or if the prefetch failed. 150 | * 151 | * @param sharedLink partial link 152 | * @return new link with the prefetched data 153 | */ 154 | private Link prefetchLink(Link sharedLink) { 155 | Link prefetchedLink = new Link(sharedLink); 156 | try { 157 | NetworkManager manager = NetworkUtils.getNetworkManager(sharedLink.getAccount()); 158 | 159 | if (manager.isCompatibleShaarli() && manager.login()) { 160 | prefetchedLink = manager.prefetchLinkData(sharedLink); 161 | } else { 162 | mError = new Exception("Could not connect to shaarli. Possibles causes: unhandled shaarli, bad username or password"); 163 | Log.e("ERROR", mError.getMessage()); 164 | } 165 | } catch (IOException | NullPointerException e) { 166 | mError = e; 167 | Log.e("ERROR", mError.getMessage()); 168 | } 169 | return prefetchedLink; 170 | } 171 | 172 | /** 173 | * Check if the given credentials are correct 174 | * 175 | * @param account The account with the credentials 176 | * @return NO_ERROR if nothing is wrong 177 | */ 178 | private int checkShaarli(final ShaarliAccount account) { 179 | final NetworkManager manager = NetworkUtils.getNetworkManager(account); 180 | Log.i(LOGGER_NAME, "Checking Shaarli account " + account); 181 | try { 182 | if (!manager.isCompatibleShaarli()) { 183 | return TOKEN_ERROR; 184 | } 185 | if (!manager.login()) { 186 | return LOGIN_ERROR; 187 | } 188 | } catch (final IOException e) { 189 | Log.e(LOGGER_NAME, "Error checking Shaarli credentials: " + e.getMessage(), e); 190 | mError = e; 191 | return NETWORK_ERROR; 192 | } 193 | return NO_ERROR; 194 | } 195 | 196 | private void postLink(Link link) { 197 | boolean posted = true; // Assume it is shared 198 | try { 199 | // Connect the user to the site : 200 | NetworkManager manager = NetworkUtils.getNetworkManager(link.getAccount()); 201 | if (manager.isCompatibleShaarli() && manager.login()) { 202 | manager.pushLink(link); 203 | } else { 204 | mError = new Exception("Could not connect to the shaarli. Possibles causes : unhandled shaarli, bad username or password"); 205 | posted = false; 206 | } 207 | } catch (IOException | NullPointerException e) { 208 | mError = e; 209 | Log.e("ERROR", e.getMessage()); 210 | posted = false; 211 | } 212 | 213 | if (!posted) { 214 | sendNotificationShareError(link); 215 | } else { 216 | mToastHandler.post(new DisplayToast(getString(R.string.add_success))); 217 | Log.i("SUCCESS", "Success while sharing link"); 218 | } 219 | } 220 | 221 | /** 222 | * Display Toast in the main thread 223 | * Thanks: http://stackoverflow.com/a/3955826 224 | */ 225 | private class DisplayToast implements Runnable { 226 | private final String mText; 227 | 228 | public DisplayToast(String text) { 229 | mText = text; 230 | } 231 | 232 | public void run() { 233 | Toast.makeText(mContext, mText, Toast.LENGTH_SHORT).show(); 234 | } 235 | } 236 | 237 | /** 238 | * Retrieve the title of a page 239 | * 240 | * @param url the page to get the title 241 | * @return the title page, "" if there is an error 242 | */ 243 | @NonNull 244 | private String[] getPageTitleAndDescription(String url) { 245 | return NetworkUtils.loadTitleAndDescription(url); 246 | } 247 | 248 | private void sendNotificationShareError(Link link) { 249 | NotificationCompat.Builder mBuilder = 250 | new NotificationCompat.Builder(this, CHANNEL_ID) 251 | .setSmallIcon(R.drawable.ic_launcher) 252 | .setContentTitle("Failed to share " + link.getTitle()) 253 | .setContentText("Press to try again") 254 | .setAutoCancel(true) 255 | .setPriority(NotificationCompat.PRIORITY_LOW); 256 | 257 | // Creates an explicit intent To relaunch this service 258 | Intent resultIntent = new Intent(this, NetworkService.class); 259 | 260 | resultIntent.putExtra("action", NetworkService.INTENT_POST); 261 | resultIntent.putExtra("link", link); 262 | 263 | resultIntent.putExtra(Intent.EXTRA_TEXT, link.getUrl()); 264 | resultIntent.putExtra(Intent.EXTRA_SUBJECT, link.getTitle()); 265 | 266 | // The stack builder object will contain an artificial back stack for the 267 | // started Activity. 268 | // This ensures that navigating backward from the Activity leads out of 269 | // your application to the Home screen. 270 | TaskStackBuilder stackBuilder = TaskStackBuilder.create(this); 271 | // Adds the Intent that starts the Activity to the top of the stack 272 | stackBuilder.addNextIntent(resultIntent); 273 | PendingIntent resultPendingIntent = PendingIntent.getService( 274 | this, 275 | 0, 276 | resultIntent, 277 | PendingIntent.FLAG_IMMUTABLE); 278 | 279 | mBuilder.setContentIntent(resultPendingIntent); 280 | NotificationManager mNotificationManager = 281 | (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); 282 | // mId allows you to update the notification later on. 283 | mNotificationManager.notify(link.getUrl().hashCode(), mBuilder.build()); 284 | } 285 | 286 | private void createNotificationChannel() { 287 | // Create the NotificationChannel, but only on API 26+ because 288 | // the NotificationChannel class is new and not in the support library 289 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 290 | CharSequence name = getString(R.string.notification_channel_error_name); 291 | String description = getString(R.string.notification_channel_error_description); 292 | int importance = NotificationManager.IMPORTANCE_DEFAULT; 293 | NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); 294 | channel.setDescription(description); 295 | // Register the channel with the system; you can't change the importance 296 | // or other notification behaviors after this 297 | NotificationManager notificationManager = getSystemService(NotificationManager.class); 298 | notificationManager.createNotificationChannel(channel); 299 | } 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /app/src/main/java/com/dimtion/shaarlier/utils/UserPreferences.java: -------------------------------------------------------------------------------- 1 | package com.dimtion.shaarlier.utils; 2 | 3 | import android.app.Activity; 4 | import android.content.SharedPreferences; 5 | 6 | import com.dimtion.shaarlier.R; 7 | 8 | import static android.content.Context.MODE_PRIVATE; 9 | 10 | public class UserPreferences { 11 | private boolean privateShare; 12 | private boolean openDialog; 13 | private boolean autoTitle; 14 | private boolean autoDescription; 15 | private boolean tweet; 16 | private boolean toot; 17 | 18 | public static UserPreferences load(Activity a) { 19 | UserPreferences p = new UserPreferences(); 20 | SharedPreferences pref = a.getSharedPreferences(a.getString(R.string.params), MODE_PRIVATE); 21 | p.privateShare = pref.getBoolean(a.getString(R.string.p_default_private), true); 22 | p.openDialog = pref.getBoolean(a.getString(R.string.p_show_share_dialog), true); 23 | p.autoTitle = pref.getBoolean(a.getString(R.string.p_auto_title), true); 24 | p.autoDescription = pref.getBoolean(a.getString(R.string.p_auto_description), false); 25 | p.tweet = pref.getBoolean(a.getString(R.string.p_shaarli2twitter), false); 26 | p.toot = pref.getBoolean(a.getString(R.string.p_shaarli2mastodon), false); 27 | 28 | return p; 29 | } 30 | 31 | public boolean isPrivateShare() { 32 | return privateShare; 33 | } 34 | 35 | public void setPrivateShare(boolean m_privateShare) { 36 | this.privateShare = m_privateShare; 37 | } 38 | 39 | public boolean isOpenDialog() { 40 | return openDialog; 41 | } 42 | 43 | public void setOpenDialog(boolean m_prefOpenDialog) { 44 | this.openDialog = m_prefOpenDialog; 45 | } 46 | 47 | public boolean isAutoTitle() { 48 | return autoTitle; 49 | } 50 | 51 | public void setAutoTitle(boolean m_autoTitle) { 52 | this.autoTitle = m_autoTitle; 53 | } 54 | 55 | public boolean isAutoDescription() { 56 | return autoDescription; 57 | } 58 | 59 | public void setAutoDescription(boolean m_autoDescription) { 60 | this.autoDescription = m_autoDescription; 61 | } 62 | 63 | public boolean isTweet() { return tweet; } 64 | 65 | public void setTweet(boolean tweet) { 66 | this.tweet = tweet; 67 | } 68 | 69 | public boolean isToot() { 70 | return toot; 71 | } 72 | 73 | public void setToot(boolean toot) { 74 | this.toot = toot; 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_send_white.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_action_accept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-hdpi/ic_action_accept.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_action_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-hdpi/ic_action_new.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_send_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-hdpi/ic_send_white.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_action_accept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-mdpi/ic_action_accept.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_action_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-mdpi/ic_action_new.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_send_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-mdpi/ic_send_white.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_action_accept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-xhdpi/ic_action_accept.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_action_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-xhdpi/ic_action_new.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_send_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-xhdpi/ic_send_white.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_action_accept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-xxhdpi/ic_action_accept.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_action_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-xxhdpi/ic_action_new.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_send_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-xxhdpi/ic_send_white.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/shaarli_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimtion/Shaarlier/ffb379a1c90799c9ca5293ba3a52408044d493a2/app/src/main/res/drawable/shaarli_icon.png -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_accounts_management.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_add_account.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | 19 |