View Javadoc
1   package de.spiritscorp.datasync.controller;
2   
3   /*-
4    * 		Data Sync
5    *
6    * 		Copyright ©   2022    The Spirit
7    * 		@email                        thespirit@spiritscorp.network
8    *
9    * 		This program is free software; you can redistribute it and/or modify
10   * 		it under the terms of the GNU General Public License as published by
11   * 		the Free Software Foundation; either version 3 of the License, or
12   * 		(at your option) any later version.
13   *
14   * 		This program is distributed in the hope that it will be useful,
15   * 		but WITHOUT ANY WARRANTY; without even the implied warranty of
16   * 		MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
17   * 		See the GNU General Public License for more details.
18   *
19   * 		You should have received a copy of the GNU General Public License
20   * 		along with this program. If not, see <http://www.gnu.org/licenses/>.
21   */
22  
23  import java.io.BufferedWriter;
24  import java.io.IOException;
25  import java.io.OutputStreamWriter;
26  import java.nio.charset.StandardCharsets;
27  import java.nio.file.Files;
28  import java.nio.file.Path;
29  import java.nio.file.Paths;
30  import java.util.ArrayList;
31  import java.util.List;
32  import java.util.Locale;
33  import java.util.Map;
34  
35  import de.spiritscorp.datasync.Main;
36  import de.spiritscorp.datasync.gui.DialogService;
37  import de.spiritscorp.datasync.io.Debug;
38  import de.spiritscorp.datasync.io.Logger;
39  import de.spiritscorp.datasync.io.Preference;
40  import de.spiritscorp.datasync.io.PreferenceManager;
41  import de.spiritscorp.datasync.model.FileAttributes;
42  import de.spiritscorp.datasync.model.Model;
43  import javafx.application.Platform;
44  
45  /**
46   * Orchestrates background thread processing for synchronization, backup,
47   * and duplicate detection, utilizing task-isolated configuration states.
48   *
49   * @author Tom Spirit
50   */
51  public class SyncJobService {
52  
53  	/**
54  	 * Service used to display modal dialogs and confirmation prompts to the user.
55  	 */
56  	private final DialogService dialogService;
57  
58  	/**
59  	 * Formatter utility responsible for converting raw sync metrics into human-readable UI logs.
60  	 */
61  	private final UiLogFormatter uiLog;
62  
63  	/**
64  	 * Constructs a new {@code SyncJobService} with the required UI and logging dependencies.
65  	 *
66  	 * @param dialogService the service provider for user interaction dialogs
67  	 * @param uiLog         the formatter instance used to compile text summaries for the UI
68  	 */
69  	public SyncJobService( DialogService dialogService, UiLogFormatter uiLog ) {
70  		this.dialogService = dialogService;
71  		this.uiLog = uiLog;
72  	}
73  
74  	/**
75  	 * Executes bidirectional folder synchronization using task-bound preferences.
76  	 *
77  	 * @param context Target environment details providing task variables
78  	 */
79  	public void startSynchronize( SyncJobContext context ) {
80  		if( context.isRunning() ) return;
81  
82  		context.setRunning( true );
83  		context.setStatusMessage( "Synchronisation gestartet. Scanne Verzeichnisse..." );
84  		context.clearLog();
85  
86  		final Map<Path, FileAttributes> sourceMap = Model.createMap();
87  		final Map<Path, FileAttributes> destMap = Model.createMap();
88  		final Map<Path, FileAttributes> failMap = Model.createMap();
89  		final Preference pref = context.getPreference();
90  		final Model model = new Model( new Logger(), sourceMap, destMap );
91  		final Long[] stats = new Long[4];
92  
93  		final Thread worker = new Thread( () -> {
94  			long startTime = System.nanoTime();
95  			try {
96  				final Path startDestPath = pref.getStartDestPath();
97  				final Path startSourcePath = pref.getStartSourcePath();
98  
99  				if( startDestPath == null || !Files.exists( startDestPath ) ) {
100 					updateUIStatus( context, false, "Kein Ziellaufwerk vorhanden" );
101 					return;
102 				}
103 
104 				if( pref.getSourcePath().size() > 1 ) {
105 					updateUIStatus( context, false, "Die Synchronisierung funktioniert nur mit einem Quellordner!" );
106 					return;
107 				}
108 
109 				failMap.putAll( model.scanSyncFiles( pref.getSourcePath(), pref.getDestPath(), stats, pref.getScanMode(), false, false ) );
110 				if( Thread.currentThread().isInterrupted() ) throw new InterruptedException();
111 
112 				final ArrayList<Map<Path, FileAttributes>> result = model.getSyncFiles( pref.getSyncMap(), startSourcePath, startDestPath );
113 				final String scanTimeFormatted = uiLog.getEndTimeFormatted( System.nanoTime() - startTime ) + " für das Scannen";
114 
115 				if( Thread.currentThread().isInterrupted() ) throw new InterruptedException();
116 
117 				appendLogData( context, uiLog.formatMaps( pref.getScanMode(), sourceMap, destMap, failMap ) );
118 				appendLogData( context, String.format( "Quelldateien: %d Stück und Zieldateien: %d Stück", stats[0], stats[1] ) );
119 				appendLogData( context, String.format( "Größe aller Quelldateien: %s | Größe aller Zieldateien: %s", uiLog.getReadableBytes( stats[2] ), uiLog.getReadableBytes( stats[3] ) ) );
120 				appendLogData( context, String.format( "Fehlerhafter Zugriff: %d", failMap.size() ) );
121 
122 				startTime = System.nanoTime();
123 				final boolean success = model.syncFiles( context, result, pref.getSyncMap(), startSourcePath, startDestPath, false );
124 				final String syncTimeFormatted = uiLog.getEndTimeFormatted( System.nanoTime() - startTime ) + " für das Synchronisieren";
125 
126 				if( success ) {
127 					pref.saveLastScanTime();
128 					appendLogData( context, scanTimeFormatted );
129 					appendLogData( context, syncTimeFormatted );
130 					updateUIStatus( context, false, "Synchronisation erfolgreich!" );
131 					Debug.printDebug( "[Controller Helper]  Synchronization completed successfully for profile: %s", context.getJobName() );
132 				}else {
133 					updateUIStatus( context, false, "Synchronisation fehlgeschlagen!" );
134 					Debug.printDebug( "[Controller Helper Error] Synchronization routine failed for: %s", context.getJobName() );
135 				}
136 				Debug.printDebug( "[Controller Helper] Scan Time -> " + scanTimeFormatted );
137 				Debug.printDebug( "[Controller Helper]  Sync Time -> " + syncTimeFormatted );
138 
139 			}catch( final InterruptedException e ) {
140 				updateUIStatus( context, false, "Synchronisation abgebrochen." );
141 				Debug.printDebug( "[Controller Helper Error] Synchronization routine aborted due to interruption: %s", e.getMessage() );
142 				Debug.printException( this.getClass(), e );
143 			}catch( final Exception e ) {
144 				updateUIStatus( context, false, "Fehler: " + e.getMessage() );
145 				Debug.printDebug( "[Controller Helper Error] Synchronization routine failed: %s", e.getMessage() );
146 				Debug.printException( this.getClass(), e );
147 			}
148 			context.setRunning( false );
149 		} );
150 
151 		worker.setDaemon( true );
152 		context.setActiveWorkerThread( worker );
153 		worker.start();
154 	}
155 
156 	/**
157 	 * Runs localized multi-threaded incremental backups using task-isolated rules.
158 	 *
159 	 * @param context Target environment details providing task variables
160 	 */
161 	public void startBackup( SyncJobContext context ) {
162 		if( context.isRunning() ) return;
163 
164 		context.setRunning( true );
165 		context.setStatusMessage( "Backup gestartet. Analysiere geänderte Daten..." );
166 		context.clearLog();
167 
168 		final Map<Path, FileAttributes> sourceMap = Model.createMap();
169 		final Map<Path, FileAttributes> destMap = Model.createMap();
170 		final Map<Path, FileAttributes> failMap = Model.createMap();
171 		final Preference pref = context.getPreference();
172 		final Model model = new Model( new Logger(), sourceMap, destMap );
173 		final Long[] stats = new Long[4];
174 
175 		final Thread worker = new Thread( () -> {
176 			long startTime = System.nanoTime();
177 			try {
178 				final Path startDestPath = pref.getStartDestPath();
179 				if( startDestPath == null || !Files.exists( startDestPath ) ) {
180 					updateUIStatus( context, false, "Kein Ziellaufwerk vorhanden" );
181 					return;
182 				}
183 
184 				failMap.putAll( model.scanSyncFiles( pref.getSourcePath(), pref.getDestPath(), stats, pref.getScanMode(), pref.isSubDir(), pref.isTrashbin() ) );
185 				model.getEqualsFiles();
186 				final String scanTimeFormatted = uiLog.getEndTimeFormatted( System.nanoTime() - startTime ) + " für das Scannen";
187 				Debug.printDebug( "[Controller Helper]  sourceMap size = %d, destMap size = %d, failtures = %d", stats[0], stats[1], failMap.size() );
188 
189 				if( Thread.currentThread().isInterrupted() ) throw new InterruptedException();
190 
191 				appendLogData( context, uiLog.formatMaps( pref.getScanMode(), sourceMap, destMap, failMap ) );
192 				appendLogData( context, String.format( "Quelldateien: %d Stück und Zieldateien: %d Stück", stats[0], stats[1] ) );
193 				appendLogData( context, String.format( "Größe aller Quelldateien: %s | Größe aller Zieldateien: %s", uiLog.getReadableBytes( stats[2] ), uiLog.getReadableBytes( stats[3] ) ) );
194 				appendLogData( context, String.format( "Fehlerhafter Zugriff: %d", failMap.size() ) );
195 
196 				boolean success = false;
197 				String backupTimeFormatted = "";
198 
199 				final int del = ( pref.isAutoDel() || dialogService.askUser( "Dateien löschen", "Löschen bestätigen?", "Alle gelöschten Dateien auch im Zielverzeichnis löschen?" ) ) ? 0 : 1;
200 				if( pref.isAutoSync() || dialogService.askUser( "Dateien sichern", "Kopieren bestätigen?", "Alle neuen Dateien in  das Zielverzeichnis kopieren?" ) ) {
201 					startTime = System.nanoTime();
202 					success = model.backupFiles( del, pref.isLogOn(), startDestPath, pref.isTrashbin(), pref.getTrashbinPath() );
203 					backupTimeFormatted = uiLog.getEndTimeFormatted( System.nanoTime() - startTime ) + " für das Synchronisieren";
204 				}
205 //				TODO output at manual abort
206 				if( success ) {
207 					pref.saveLastScanTime();
208 					appendLogData( context, scanTimeFormatted );
209 					appendLogData( context, backupTimeFormatted );
210 					updateUIStatus( context, false, "Backup erfolgreich abgeschlossen!" );
211 					Debug.printDebug( "[Controller Helper]  Backup completed successfully for profile: %s", context.getJobName() );
212 				}else {
213 					updateUIStatus( context, false, "Backup fehlgeschlagen!" );
214 					Debug.printDebug( "[Controller Helper Error] Backup routine failed in: %s", context.getJobName() );
215 				}
216 				Debug.printDebug( "[Controller Helper] Scan Time -> " + scanTimeFormatted );
217 				Debug.printDebug( "[Controller Helper]  Backup Time -> " + backupTimeFormatted );
218 			}catch( final InterruptedException e ) {
219 				updateUIStatus( context, false, "Backup-Vorgang abgebrochen." );
220 				Debug.printDebug( "[Controller Helper Error] Backup routine aborted due to interruption: %s", e.getMessage() );
221 				Debug.printException( this.getClass(), e );
222 			}catch( final Exception e ) {
223 				updateUIStatus( context, false, "Fehler während des Backups: " + e.getMessage() );
224 				Debug.printDebug( "[Controller Helper Error] Backup routine failed: %s", e.getMessage() );
225 				Debug.printException( this.getClass(), e );
226 			}
227 			context.setRunning( false );
228 		} );
229 
230 		worker.setDaemon( true );
231 		context.setActiveWorkerThread( worker );
232 		worker.start();
233 	}
234 
235 	/**
236 	 * Scans source paths asynchronously to list file checksum collisions.
237 	 *
238 	 * @param job Target environment details providing task variables
239 	 */
240 	public void startDuplicateScan( final SyncJobContext job ) {
241 		if( job.isRunning() ) return;
242 
243 		job.setRunning( true );
244 		job.setStatusMessage( "Scanne nach Duplikaten..." );
245 		job.clearLog();
246 
247 		final Map<Path, FileAttributes> sourceMap = Model.createMap();
248 		final Map<Path, FileAttributes> destMap = Model.createMap();
249 		final Long[] stats = new Long[4];
250 
251 		final Preference pref = job.getPreference();
252 		final Model model = new Model( new Logger(), sourceMap, destMap );
253 
254 		final Thread worker = new Thread( () -> {
255 			final long startTime = System.nanoTime();
256 			try {
257 				final Map<Path, FileAttributes> duplicateMap = model.scanDublicates( pref.getSourcePath(), stats );
258 				final String scanTimeFormatted = uiLog.getEndTimeFormatted( System.nanoTime() - startTime );
259 
260 				if( Thread.currentThread().isInterrupted() ) throw new InterruptedException( "Manual abort ..." );
261 
262 				final List<SyncJobContext.FileRow> preparedRows = new ArrayList<>();
263 				if( duplicateMap != null && !duplicateMap.isEmpty() ) {
264 					for( final Map.Entry<Path, FileAttributes> entry : duplicateMap.entrySet() ) {
265 						preparedRows.add( new SyncJobContext.FileRow(
266 								entry.getKey(),
267 								entry.getValue(),
268 								uiLog.getReadableBytes( entry.getValue().getSize() ) ) );
269 					}
270 				}
271 
272 				Platform.runLater( () -> {
273 					job.getDuplicateFiles().clear();
274 					job.getDuplicateFiles().addAll( preparedRows );
275 					job.setRunning( false );
276 					job.setStatusMessage( "Scan abgeschlossen. Duplikate insgesamt gefunden: " + job.getDuplicateFiles().size() + " (" + scanTimeFormatted + "s)" );
277 					Debug.printDebug( "[Controller Helper] duplicateMap size = %d, sourceMap size = %d, failtures = %d", duplicateMap.size(), stats[0], stats[1] );
278 					Debug.printDebug( "[Controller Helper] Scan Time -> " + scanTimeFormatted );
279 				} );
280 			}catch( final InterruptedException e ) {
281 				updateUIStatus( job, false, "Scan abgebrochen." );
282 				Debug.printDebug( "[Controller Helper Error] Duplicate scan aborted due to interruption: %s", e.getMessage() );
283 			}catch( final Exception e ) {
284 				updateUIStatus( job, false, "Fehler beim Duplikat Scan: " + e.getMessage() );
285 				Debug.printDebug( "[Controller Helper Error] Duplicat scan abord: %s", e.getMessage() );
286 				Debug.printException( this.getClass(), e );
287 			}
288 			job.setRunning( false );
289 		} );
290 
291 		worker.setDaemon( true );
292 		job.setActiveWorkerThread( worker );
293 		worker.start();
294 	}
295 
296 	/**
297 	 * Purges marked elements from active disks securely on a detached stack.
298 	 *
299 	 * @param context Target environment details providing task variables
300 	 */
301 	public void deleteSelectedDuplicates( SyncJobContext context ) {
302 		final ArrayList<SyncJobContext.FileRow> toDelete = new ArrayList<>();
303 		for( final SyncJobContext.FileRow row : context.getDuplicateFiles() ) {
304 			if( row.isSelected() ) {
305 				toDelete.add( row );
306 			}
307 		}
308 
309 		if( toDelete.isEmpty() ) {
310 			context.setStatusMessage( "Keine Dateien zum Löschen ausgewählt." );
311 			return;
312 		}
313 		if( !dialogService.askUser( "Duplikate entfernen", "Löschen bestätigen?", "Alle ausgewählten Dateien wirklich löschen?" ) ) return;
314 
315 		context.setRunning( true );
316 		context.setStatusMessage( "Lösche ausgewählte Duplikate..." );
317 
318 		final Thread worker = new Thread( () -> {
319 			int successCount = 0;
320 			try {
321 				for( final SyncJobContext.FileRow row : toDelete ) {
322 					if( Thread.currentThread().isInterrupted() ) throw new InterruptedException();
323 					if( Files.deleteIfExists( row.getFileSystemPath() ) ) {
324 						successCount++;
325 					}
326 				}
327 				final int finalSuccess = successCount;
328 				Platform.runLater( () -> {
329 					context.getDuplicateFiles().removeIf( SyncJobContext.FileRow::isSelected );
330 					context.setRunning( false );
331 					context.setStatusMessage( finalSuccess + " Duplikate erfolgreich gelöscht." );
332 					Debug.printDebug( "[Controller Helper] Duplicates successfully deleted: %d items.", finalSuccess );
333 				} );
334 			}catch( final InterruptedException e ) {
335 				updateUIStatus( context, false, "Löschvorgang unterbrochen." );
336 				Debug.printDebug( "[Controller Helper Error] Duplicate deletion aborted: %s", e.getMessage() );
337 				Debug.printException( this.getClass(), e );
338 			}catch( final Exception e ) {
339 				updateUIStatus( context, false, "Fehler beim Löschen: " + e.getMessage() );
340 				Debug.printDebug( "[Controller Helper Error] Duplicate deletion failed: %s", e.getMessage() );
341 				Debug.printException( this.getClass(), e );
342 			}
343 		} );
344 
345 		worker.setDaemon( true );
346 		context.setActiveWorkerThread( worker );
347 		worker.start();
348 	}
349 
350 	/**
351 	 * Configures or evicts background host operating system daemon startup triggers.
352 	 * Adjusts system registry run hives on Windows environments or modifies cron execution
353 	 * tables on Linux platforms via secure subprocess execution abstractions.
354 	 *
355 	 * @param set Target flag to dictate whether to register or clear system integration entries.
356 	 * @return true if the underlying system environment sub-process sequences executed without exceptions.
357 	 */
358 	public boolean setOSAutostart( boolean set ) {
359 		final String javaPath = System.getProperty( "sun.boot.library.path" );
360 		final String exePath = System.getProperty( "jpackage.app-path" );
361 		final String datei = System.getProperty( "sun.java.command" );
362 		final String fullPath = Paths.get( "" ).toAbsolutePath().toString() + System.getProperty( "file.separator" ) + datei;
363 		final String possibleOS = System.getProperty( "os.name" );
364 		String os = "";
365 		if( possibleOS != null ) os = possibleOS.toLowerCase( Locale.ROOT );
366 		final String flags = computeBootFlags();
367 
368 		if( os.contains( "win" ) ) {
369 			final String regCmd = "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run";
370 			try {
371 				if( set ) {
372 					// No literal interior quote escapes required; ProcessBuilder insulates whitespaces
373 					final String dataPayload = ( exePath == null )
374 							? String.format( "%s\\javaw.exe -Xmx200m -jar %s %s %s", javaPath, datei, Main.BOOT_DELAY_LONG, flags )
375 							: String.format( "%s %s %s", exePath, Main.BOOT_DELAY_LONG, flags );
376 
377 					final ProcessBuilder pb = new ProcessBuilder( "reg", "add", regCmd, "/v", "DataSync", "/t", "REG_SZ", "/d", dataPayload, "/f" );
378 					pb.start();
379 				}else {
380 					final ProcessBuilder pb = new ProcessBuilder( "reg", "delete", regCmd, "/v", "DataSync", "/f" );
381 					pb.start();
382 				}
383 			}catch( final IOException e ) {
384 				Debug.printError( "[Controller Helper Error] Set Windows registry autostart tracking hive failed: %s", e.getMessage() );
385 				Debug.printException( getClass(), e );
386 				return false;
387 			}
388 		}else if( os.contains( "nix" ) || os.contains( "aix" ) || os.contains( "nux" ) ) {
389 			final String crontab = "crontab";
390 			try {
391 				if( set ) {
392 					final String cronPayload = ( exePath == null )
393 							? String.format( "@reboot %s/java -jar %s %s %s", javaPath, fullPath, Main.BOOT_DELAY_LONG, flags )
394 							: String.format( "@reboot %s %s %s", exePath, Main.BOOT_DELAY_LONG, flags );
395 
396 					// Feed crontab structural inputs directly via process input stream pipelining
397 					final ProcessBuilder pb = new ProcessBuilder( crontab, "-" );
398 					final Process process = pb.start();
399 
400 					try( BufferedWriter writer = new BufferedWriter(
401 							new OutputStreamWriter( process.getOutputStream(), StandardCharsets.UTF_8 ) ) ) {
402 						writer.write( cronPayload );
403 						writer.newLine(); // Unix crontab standard strictly requires a trailing newline character
404 					}
405 					process.waitFor();
406 				}else {
407 					final ProcessBuilder pb = new ProcessBuilder( crontab, "-r" );
408 					pb.start().waitFor();
409 				}
410 			}catch( final IOException | InterruptedException e ) {
411 				Debug.printError( "[Controller Helper Error] Set Unix crontab daemon automated launch failed: %s", e.getMessage() );
412 				Debug.printException( getClass(), e );
413 				if( e instanceof InterruptedException ) {
414 					Thread.currentThread().interrupt();
415 				}
416 				return false;
417 			}
418 		}
419 		return true;
420 	}
421 
422 	private String computeBootFlags() {
423 		final StringBuilder stringBuilder = new StringBuilder();
424 		final PreferenceManager manager = PreferenceManager.getInstance();
425 		if( Main.isDebugToFile() ) {
426 			stringBuilder.append( " " + Main.DEBUG_TO_FILE_LONG );
427 		}
428 		if( manager.isCustomConfigDir() ) {
429 			stringBuilder.append( " " + Main.CONFIG_DIR_LONG );
430 			stringBuilder.append( " " + manager.getConfigPath().getParent().toString() );
431 		}
432 		return stringBuilder.toString();
433 	}
434 
435 	private void updateUIStatus( SyncJobContext context, boolean running, String message ) {
436 		Platform.runLater( () -> {
437 			context.setRunning( running );
438 			context.setStatusMessage( message );
439 		} );
440 	}
441 
442 	private void appendLogData( SyncJobContext context, String line ) {
443 		Platform.runLater( () -> context.appendLog( line ) );
444 	}
445 }