├── .github └── readme-images │ ├── demo.gif │ └── ic_launcher-playstore.png ├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── name │ │ └── lmj001 │ │ └── saveondevice │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── name │ │ │ └── lmj001 │ │ │ └── saveondevice │ │ │ └── MainActivity.java │ └── res │ │ ├── drawable │ │ └── ic_launcher_foreground.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ └── ic_launcher.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ └── values │ │ ├── colors.xml │ │ └── strings.xml │ └── test │ └── java │ └── name │ └── lmj001 │ └── saveondevice │ └── ExampleUnitTest.kt ├── build.gradle ├── fastlane └── metadata │ └── android │ ├── de │ ├── full_description.txt │ └── short_description.txt │ └── en-US │ ├── full_description.txt │ ├── images │ ├── featureGraphic.png │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ └── 2.png │ └── short_description.txt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.github/readme-images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AbdurazaaqMohammed/save-on-device/85481a22792850049e269b2af0e4c8037ef99209/.github/readme-images/demo.gif -------------------------------------------------------------------------------- /.github/readme-images/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AbdurazaaqMohammed/save-on-device/85481a22792850049e269b2af0e4c8037ef99209/.github/readme-images/ic_launcher-playstore.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /app/release 5 | /.idea 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | .cxx 11 | local.properties 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Save On Device 2 | 3 | An Android app that allows you to save files on your device from other apps using the Share or View functionality. 4 | 5 | 6 | 7 | ## Features 8 | - Save files from other apps to local storage 9 | - Save copied text to local storage 10 | 11 | This fork of the [original](https://github.com/lmj0011/save-on-device) by lmj0011 is just about 25KB and is compatible with all versions of Android. It also supports saving files from the View action in addition to the Share one. 12 | 13 | ## License 14 | 15 | Copyright 2023 Landan Jackson 16 | 17 | Licensed under the Apache License, Version 2.0 (the "License"); 18 | you may not use this file except in compliance with the License. 19 | You may obtain a copy of the License at 20 | 21 | http://www.apache.org/licenses/LICENSE-2.0 22 | 23 | Unless required by applicable law or agreed to in writing, software 24 | distributed under the License is distributed on an "AS IS" BASIS, 25 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 26 | See the License for the specific language governing permissions and 27 | limitations under the License. 28 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | } 4 | 5 | android { 6 | compileSdk 35 7 | 8 | defaultConfig { 9 | applicationId "name.lmj001.savetodevice" 10 | minSdk 1 11 | targetSdk 36 12 | versionCode 8 13 | versionName "0.7.1" 14 | } 15 | 16 | buildTypes { 17 | release { 18 | minifyEnabled true 19 | shrinkResources true 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | compileOptions { 24 | sourceCompatibility JavaVersion.VERSION_1_8 25 | targetCompatibility JavaVersion.VERSION_1_8 26 | } 27 | namespace 'name.lmj001.saveondevice' 28 | } 29 | 30 | dependencies { 31 | } 32 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/name/lmj001/saveondevice/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package name.lmj001.saveondevice 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("name.lmj001.saveondevice", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AbdurazaaqMohammed/save-on-device/85481a22792850049e269b2af0e4c8037ef99209/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/name/lmj001/saveondevice/MainActivity.java: -------------------------------------------------------------------------------- 1 | package name.lmj001.saveondevice; 2 | 3 | import android.Manifest; 4 | import android.app.Activity; 5 | import android.app.Dialog; 6 | import android.content.ContentResolver; 7 | import android.content.Context; 8 | import android.content.Intent; 9 | import android.content.SharedPreferences; 10 | import android.content.pm.PackageManager; 11 | import android.database.Cursor; 12 | import android.graphics.Color; 13 | import android.net.Uri; 14 | import android.os.Build; 15 | import android.os.Bundle; 16 | import android.os.Environment; 17 | import android.os.FileUtils; 18 | import android.provider.DocumentsContract; 19 | import android.provider.OpenableColumns; 20 | import android.text.TextUtils; 21 | import android.view.View; 22 | import android.webkit.MimeTypeMap; 23 | import android.widget.Button; 24 | import android.widget.EditText; 25 | import android.widget.LinearLayout; 26 | import android.widget.TextView; 27 | import android.widget.Toast; 28 | import android.widget.ToggleButton; 29 | 30 | import java.io.File; 31 | import java.io.FileOutputStream; 32 | import java.io.InputStream; 33 | import java.io.OutputStream; 34 | import java.text.Normalizer; 35 | import java.util.ArrayList; 36 | import java.util.Objects; 37 | import java.util.concurrent.Executors; 38 | 39 | public class MainActivity extends Activity { 40 | private static Uri inputUri; 41 | private static ArrayList inputUris; 42 | private static String sharedText; 43 | private static boolean saveIndividually; 44 | private final static boolean supportsBuiltInAndroidFilePicker = Build.VERSION.SDK_INT > 18; 45 | 46 | @Override 47 | protected void onCreate(Bundle savedInstanceState) { 48 | super.onCreate(savedInstanceState); 49 | 50 | SharedPreferences settings = getSharedPreferences("set", Context.MODE_PRIVATE); 51 | saveIndividually = settings.getBoolean("saveIndividually", false); 52 | final View protectEyes = new View(this); 53 | protectEyes.setBackgroundColor(Color.BLACK); 54 | setContentView(protectEyes); 55 | 56 | Intent intent = getIntent(); 57 | String action = intent.getAction(); 58 | if (Intent.ACTION_VIEW.equals(action)) { 59 | inputUri = intent.getData(); 60 | callSaveFileResultLauncherForIndividual(); 61 | } else if (Intent.ACTION_SEND.equals(action)) { 62 | if (intent.hasExtra(Intent.EXTRA_STREAM)) { 63 | inputUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); 64 | callSaveFileResultLauncherForIndividual(); 65 | } else if (intent.hasExtra(Intent.EXTRA_TEXT)) { 66 | sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); 67 | if (sharedText != null) { 68 | String fileName = sharedText.substring(0, Math.min(sharedText.length(), 20)); 69 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) { 70 | fileName = Normalizer.normalize(fileName, Normalizer.Form.NFD) 71 | .replaceAll("[^\\p{ASCII}]", "") 72 | .replaceAll("[^a-zA-Z0-9\\s]+", "") 73 | .trim() 74 | .replaceAll("\\s+", "-") 75 | .toLowerCase(); 76 | } else { 77 | StringBuilder sb = new StringBuilder(); 78 | for (char c : fileName.toCharArray()) if ((int) c <= 127) sb.append(c); 79 | fileName = sb.toString() 80 | .replaceAll("[^a-zA-Z0-9\\s]", "") 81 | .trim() 82 | .replaceAll("\\s+", "-") 83 | .toLowerCase(); 84 | } 85 | if (supportsBuiltInAndroidFilePicker) { 86 | Intent saveFileIntent = new Intent(Intent.ACTION_CREATE_DOCUMENT); 87 | saveFileIntent.addCategory(Intent.CATEGORY_OPENABLE); 88 | saveFileIntent.setType("text/plain"); 89 | saveFileIntent.putExtra(Intent.EXTRA_TITLE, fileName); 90 | startActivityForResult(saveFileIntent, 0); 91 | } else saveFile(new File( 92 | getSharedPreferences("set", Context.MODE_PRIVATE) 93 | .getString("directoryToSaveFiles", 94 | new File(Environment.getExternalStorageDirectory(), "Download").getPath()), fileName)); 95 | } else { 96 | Toast.makeText(getApplicationContext(), R.string.nothing, Toast.LENGTH_LONG).show(); 97 | finish(); 98 | } 99 | } 100 | } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { 101 | if (intent.hasExtra(Intent.EXTRA_STREAM)) { 102 | inputUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); 103 | if (saveIndividually || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { 104 | inputUri = inputUris.get(0); 105 | inputUris.remove(0); 106 | callSaveFileResultLauncherForIndividual(); 107 | } else { 108 | for (Uri uri : inputUris) { 109 | final String mimeType = getApplicationContext().getContentResolver().getType(uri); 110 | if (TextUtils.isEmpty(mimeType)) { 111 | Toast.makeText(getApplicationContext(), R.string.unsupported_mimetype, Toast.LENGTH_LONG).show(); 112 | finish(); 113 | } 114 | } 115 | Intent saveFilesIntent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); 116 | startActivityForResult(saveFilesIntent, 1); 117 | } 118 | } 119 | } else { 120 | setContentView(R.layout.activity_main); 121 | ToggleButton tb = findViewById(R.id.multiSaveSwitch); 122 | if (Build.VERSION.SDK_INT < 21) { 123 | if (supportsBuiltInAndroidFilePicker) { 124 | tb.setChecked(true); 125 | tb.setEnabled(false); 126 | findViewById(R.id.oldAndroidInfo).setVisibility(View.VISIBLE); 127 | } else { 128 | findViewById(R.id.veryOldAndroidInfo).setVisibility(View.VISIBLE); 129 | tb.setVisibility(View.INVISIBLE); 130 | findViewById(R.id.multiSaveInfo).setVisibility(View.INVISIBLE); 131 | findViewById(R.id.multiSaveSwitch).setVisibility(View.INVISIBLE); 132 | EditText outputDirectoryField = findViewById(R.id.directorySaveFiles); 133 | outputDirectoryField.setVisibility(View.VISIBLE); 134 | outputDirectoryField.setText(settings.getString("directoryToSaveFiles", Environment.getExternalStorageDirectory().getPath() + File.separator + "Download")); 135 | Button b = findViewById(R.id.saveDirectorySetting); 136 | b.setVisibility(View.VISIBLE); 137 | b.setOnClickListener(v -> { 138 | final SharedPreferences.Editor editor = settings.edit(); 139 | final String userFilePath = outputDirectoryField.getText().toString(); 140 | final File newFile = new File(userFilePath); 141 | if (newFile.exists() || newFile.mkdir()) { 142 | editor.putString("directoryToSaveFiles", userFilePath); 143 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) editor.apply(); 144 | else editor.commit(); 145 | } else 146 | showError(getString(R.string.invalid_filepath)); 147 | }); 148 | } 149 | } else { 150 | tb.setChecked(saveIndividually); 151 | tb.setOnCheckedChangeListener((buttonView, isChecked) -> settings.edit().putBoolean("saveIndividually", isChecked).apply()); 152 | } 153 | } 154 | } 155 | 156 | private void showError(Exception err) { 157 | StringBuilder sb = new StringBuilder(err.toString()); 158 | for(StackTraceElement ste : err.getStackTrace()) sb.append('\n').append(ste); 159 | showError(sb); 160 | } 161 | 162 | private void showError(CharSequence err) { 163 | runOnUiThread(() -> { 164 | Toast.makeText(this, err, Toast.LENGTH_SHORT).show(); 165 | Dialog dialog = new Dialog(this, android.R.style.Theme_Black); 166 | dialog.setTitle(R.string.err); 167 | 168 | LinearLayout layout = new LinearLayout(this); 169 | layout.setOrientation(LinearLayout.VERTICAL); 170 | layout.setPadding(16, 16, 16, 16); 171 | layout.setBackgroundColor(Color.BLACK); 172 | 173 | TextView errorMessage = new TextView(this); 174 | errorMessage.setText(err); 175 | errorMessage.setTextAppearance(this, android.R.style.TextAppearance_Large); 176 | layout.addView(errorMessage); 177 | errorMessage.setTextColor(0xFF691383); 178 | errorMessage.setBackgroundColor(Color.BLACK); 179 | 180 | Button okButton = new Button(this); 181 | okButton.setText("OK"); 182 | okButton.setOnClickListener(view -> finish()); 183 | layout.addView(okButton); 184 | 185 | dialog.setContentView(layout); 186 | dialog.show(); 187 | }); 188 | } 189 | 190 | private void callSaveFileResultLauncherForIndividual() { 191 | String fileName = getOriginalFileName(this, inputUri); 192 | if (supportsBuiltInAndroidFilePicker) { 193 | String mimeType = getApplicationContext().getContentResolver().getType(inputUri); 194 | if (TextUtils.isEmpty(mimeType)) { 195 | if (Build.VERSION.SDK_INT > 22 && Build.VERSION.SDK_INT < 29 && checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) { 196 | showError(getString(R.string.need_storage)); 197 | requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); 198 | if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) { 199 | final File tempFile = new File(getExternalCacheDir() + File.separator + fileName); 200 | saveFile(tempFile); 201 | inputUri = Uri.fromFile(tempFile); // lol 202 | } 203 | } 204 | String fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).trim(); 205 | mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.toLowerCase()); 206 | if (TextUtils.isEmpty(mimeType)) mimeType = "application/octet-stream"; // Default MIME type 207 | } 208 | 209 | Intent saveFileIntent = new Intent(Intent.ACTION_CREATE_DOCUMENT); 210 | saveFileIntent.addCategory(Intent.CATEGORY_OPENABLE); 211 | saveFileIntent.setType(mimeType); 212 | saveFileIntent.putExtra(Intent.EXTRA_TITLE, fileName); 213 | startActivityForResult(saveFileIntent, 0); 214 | } else saveFile(new File( 215 | getSharedPreferences("set", Context.MODE_PRIVATE) 216 | .getString("directoryToSaveFiles", 217 | new File(Environment.getExternalStorageDirectory(), "Download").getPath()), fileName)); 218 | } 219 | 220 | @Override 221 | protected void onActivityResult(int requestCode, int resultCode, Intent data) { 222 | super.onActivityResult(requestCode, resultCode, data); 223 | Uri outputUri; 224 | if (resultCode == RESULT_OK && data != null && (outputUri = data.getData()) != null) 225 | Executors.newSingleThreadExecutor().submit(() -> { 226 | if (requestCode == 0 || saveIndividually || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { 227 | try (OutputStream outputStream = getContentResolver().openOutputStream(outputUri)) { 228 | if (inputUri == null) outputStream.write(sharedText.getBytes()); 229 | else try (InputStream inputStream = getContentResolver().openInputStream(inputUri)) { 230 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) inputStream.transferTo(outputStream); 231 | else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) FileUtils.copy(inputStream, outputStream); 232 | else { 233 | byte[] buffer = new byte[4096]; 234 | int length; 235 | while ((length = inputStream.read(buffer)) > 0) { 236 | outputStream.write(buffer, 0, length); 237 | } 238 | } 239 | } 240 | } catch (Exception e) { 241 | showError(e); 242 | } 243 | if (inputUris == null || inputUris.isEmpty()) { 244 | runOnUiThread(() -> { 245 | Toast.makeText(this, R.string.success, Toast.LENGTH_SHORT).show(); 246 | finish(); 247 | }); 248 | } else { 249 | inputUri = inputUris.get(0); 250 | inputUris.remove(0); 251 | callSaveFileResultLauncherForIndividual(); 252 | } 253 | } else { 254 | ContentResolver resolver = getContentResolver(); 255 | try { 256 | Uri docUri = DocumentsContract.buildDocumentUriUsingTree(outputUri, DocumentsContract.getTreeDocumentId(outputUri)); 257 | for (Uri inputUri1 : inputUris) { 258 | try (InputStream inputStream = resolver.openInputStream(inputUri1); 259 | OutputStream outputStream = resolver.openOutputStream(DocumentsContract.createDocument(resolver, docUri, "*/*", getOriginalFileName(this, inputUri1)))) { 260 | byte[] buffer = new byte[4096]; 261 | int length; 262 | while ((length = inputStream.read(buffer)) > 0) { 263 | outputStream.write(buffer, 0, length); 264 | } 265 | } 266 | } 267 | runOnUiThread(() -> { 268 | Toast.makeText(this, R.string.success, Toast.LENGTH_SHORT).show(); 269 | finish(); 270 | }); 271 | } catch (Exception e) { 272 | showError(e); 273 | } 274 | } 275 | return null; 276 | }); 277 | } 278 | 279 | private void saveFile(File outputFile) { 280 | try (OutputStream outputStream = new FileOutputStream(outputFile)) { 281 | if (inputUri == null) outputStream.write(sharedText.getBytes()); 282 | else try (InputStream inputStream = getContentResolver().openInputStream(inputUri)) { 283 | byte[] buffer = new byte[4096]; 284 | int length; 285 | while ((length = inputStream.read(buffer)) > 0) { 286 | outputStream.write(buffer, 0, length); 287 | } 288 | } 289 | } catch (Exception e) { 290 | showError(e); 291 | } 292 | if (inputUris == null || inputUris.isEmpty()) { 293 | runOnUiThread(() -> { 294 | Toast.makeText(this, R.string.success, Toast.LENGTH_SHORT).show(); 295 | finish(); 296 | }); 297 | } else { 298 | inputUri = inputUris.get(0); 299 | inputUris.remove(0); 300 | callSaveFileResultLauncherForIndividual(); 301 | } 302 | } 303 | 304 | private static String getOriginalFileName(Context context, Uri uri) { 305 | String result = null; 306 | try { 307 | if (Objects.equals(uri.getScheme(), "content")) { 308 | try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { 309 | if (cursor != null && cursor.moveToFirst()) { 310 | result = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); 311 | } 312 | } 313 | } 314 | if (result == null) { 315 | result = uri.getPath(); 316 | int cut = Objects.requireNonNull(result).lastIndexOf('/'); 317 | if (cut != -1) { 318 | result = result.substring(cut + 1); 319 | } 320 | } 321 | } catch (NullPointerException | IllegalArgumentException ignored) { 322 | result = "filename_not_found"; 323 | } 324 | return result; 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 15 | 17 | 18 | 20 | 21 | 23 | 24 | 26 | 27 | 28 | 30 | 31 | 32 | 34 | 35 | 36 | 38 | 39 | 40 | 42 | 43 | 44 | 46 | 47 | 48 | 50 | 51 | 52 | 54 | 55 | 56 | 58 | 59 | 60 | 61 | 62 | 66 | 70 | 73 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 20 | 21 | 22 | 28 | 29 | 37 | 38 | 47 | 48 | 55 | 56 | 67 | 68 |